refactor: Implement custom DioClient with AuthInterceptor and strongly typed API service responses.

This commit is contained in:
Achintha Isuru
2026-02-25 15:10:44 -05:00
parent 9c9cdaca78
commit 6eafba311b
12 changed files with 137 additions and 54 deletions

View File

@@ -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';

View File

@@ -13,7 +13,7 @@ class CoreModule extends Module {
@override
void exportedBinds(Injector i) {
// 1. Register the base HTTP client
i.addSingleton<Dio>(() => Dio());
i.addSingleton<Dio>(() => DioClient());
// 2. Register the base API service
i.addSingleton<BaseApiService>(() => ApiService(i.get<Dio>()));
@@ -31,7 +31,7 @@ class CoreModule extends Module {
i.addSingleton<LlmService>(() => LlmService(i.get<BaseApiService>()));
// 4. Register Device dependency
i.addSingleton<ImagePicker>(ImagePicker.new);
i.addSingleton<ImagePicker>(() => ImagePicker());
// 5. Register Device Services
i.addSingleton<CameraService>(() => CameraService(i.get<ImagePicker>()));

View File

@@ -88,21 +88,9 @@ class ApiService implements BaseApiService {
/// Extracts [ApiResponse] from a successful [Response].
ApiResponse _handleResponse(Response<dynamic> response) {
if (response.data is Map<String, dynamic>) {
final Map<String, dynamic> body = response.data as Map<String, dynamic>;
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,
);
}

View File

@@ -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<ApiResponse> uploadFile({
Future<FileUploadResponse> 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(<String, dynamic>{
'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<String, dynamic>);
}
throw Exception(res.message);
}
}

View File

@@ -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<ApiResponse> invokeLlm({
Future<LlmResponse> invokeLlm({
required String prompt,
Map<String, dynamic>? responseJsonSchema,
List<String>? fileUrls,
}) async {
return action(() async {
final ApiResponse res = await action(() async {
return api.post(
CoreApiEndpoints.invokeLlm,
data: <String, dynamic>{
@@ -27,5 +28,11 @@ class LlmService extends BaseCoreService {
},
);
});
if (res.code.startsWith('2')) {
return LlmResponse.fromJson(res.data as Map<String, dynamic>);
}
throw Exception(res.message);
}
}

View File

@@ -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<ApiResponse> createSignedUrl({
Future<SignedUrlResponse> createSignedUrl({
required String fileUri,
int expiresInSeconds = 300,
}) async {
return action(() async {
final ApiResponse res = await action(() async {
return api.post(
CoreApiEndpoints.createSignedUrl,
data: <String, dynamic>{
@@ -23,5 +24,11 @@ class SignedUrlService extends BaseCoreService {
},
);
});
if (res.code.startsWith('2')) {
return SignedUrlResponse.fromJson(res.data as Map<String, dynamic>);
}
throw Exception(res.message);
}
}

View File

@@ -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<ApiResponse> createVerification({
Future<VerificationResponse> createVerification({
required String type,
required String subjectType,
required String subjectId,
required String fileUri,
Map<String, dynamic>? rules,
}) async {
return action(() async {
final ApiResponse res = await action(() async {
return api.post(
CoreApiEndpoints.verifications,
data: <String, dynamic>{
@@ -30,25 +31,37 @@ class VerificationService extends BaseCoreService {
},
);
});
if (res.code.startsWith('2')) {
return VerificationResponse.fromJson(res.data as Map<String, dynamic>);
}
throw Exception(res.message);
}
/// Polls the status of a specific verification.
Future<ApiResponse> getStatus(String verificationId) async {
return action(() async {
Future<VerificationResponse> 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<String, dynamic>);
}
throw Exception(res.message);
}
/// Submits a manual review decision.
///
/// [decision] should be 'APPROVED' or 'REJECTED'.
Future<ApiResponse> reviewVerification({
Future<VerificationResponse> 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: <String, dynamic>{
@@ -58,12 +71,24 @@ class VerificationService extends BaseCoreService {
},
);
});
if (res.code.startsWith('2')) {
return VerificationResponse.fromJson(res.data as Map<String, dynamic>);
}
throw Exception(res.message);
}
/// Retries a verification job that failed or needs re-processing.
Future<ApiResponse> retryVerification(String verificationId) async {
return action(() async {
Future<VerificationResponse> 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<String, dynamic>);
}
throw Exception(res.message);
}
}

View File

@@ -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(<Interceptor>[
AuthInterceptor(),
LogInterceptor(
requestBody: true,
responseBody: true,
), // Added for better debugging
]);
}
}

View File

@@ -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<void> 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);
}
}

View File

@@ -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<ApiResponse?> uploadFromCamera({
Future<FileUploadResponse?> 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<ApiResponse?> uploadFromGallery({
Future<FileUploadResponse?> uploadFromGallery({
required String fileName,
FileVisibility visibility = FileVisibility.private,
String? category,

View File

@@ -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

View File

@@ -37,23 +37,18 @@ class AttireRepositoryImpl implements AttireRepository {
Future<AttireItem> uploadPhoto(String itemId, String filePath) async {
// 1. Upload file to Core API
final FileUploadService uploadService = Modular.get<FileUploadService>();
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<SignedUrlService>();
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: <String, dynamic>{'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: <String, dynamic>{'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<void>.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++;