refactor: Implement custom DioClient with AuthInterceptor and strongly typed API service responses.
This commit is contained in:
@@ -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';
|
||||
|
||||
@@ -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>()));
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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++;
|
||||
|
||||
Reference in New Issue
Block a user