diff --git a/apps/mobile/apps/staff/lib/main.dart b/apps/mobile/apps/staff/lib/main.dart index 1f2dea9f..440dba19 100644 --- a/apps/mobile/apps/staff/lib/main.dart +++ b/apps/mobile/apps/staff/lib/main.dart @@ -5,23 +5,23 @@ import 'package:design_system/design_system.dart'; import 'package:firebase_core/firebase_core.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:marionette_flutter/marionette_flutter.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_modular/flutter_modular.dart'; +import 'package:krow_core/core.dart'; import 'package:krow_data_connect/krow_data_connect.dart'; import 'package:krowwithus_staff/firebase_options.dart'; +import 'package:marionette_flutter/marionette_flutter.dart'; import 'package:staff_authentication/staff_authentication.dart' as staff_authentication; import 'package:staff_main/staff_main.dart' as staff_main; -import 'package:krow_core/core.dart'; -import 'package:image_picker/image_picker.dart'; import 'src/widgets/session_listener.dart'; void main() async { - final bool isFlutterTest = - !kIsWeb ? Platform.environment.containsKey('FLUTTER_TEST') : false; + final bool isFlutterTest = !kIsWeb + ? Platform.environment.containsKey('FLUTTER_TEST') + : false; if (kDebugMode && !isFlutterTest) { MarionetteBinding.ensureInitialized( MarionetteConfiguration( @@ -63,20 +63,9 @@ void main() async { /// The main application module. class AppModule extends Module { - @override - void binds(Injector i) { - i.addLazySingleton(ImagePicker.new); - i.addLazySingleton( - () => CameraService(i.get()), - ); - i.addLazySingleton( - () => GalleryService(i.get()), - ); - i.addLazySingleton(FilePickerService.new); - } - @override List get imports => [ + CoreModule(), core_localization.LocalizationModule(), staff_authentication.StaffAuthenticationModule(), ]; diff --git a/apps/mobile/packages/core/lib/core.dart b/apps/mobile/packages/core/lib/core.dart index 1eb94306..f6ef5e80 100644 --- a/apps/mobile/packages/core/lib/core.dart +++ b/apps/mobile/packages/core/lib/core.dart @@ -1,5 +1,7 @@ library; +export 'src/core_module.dart'; + export 'src/domain/arguments/usecase_argument.dart'; export 'src/domain/usecases/usecase.dart'; export 'src/utils/date_time_utils.dart'; diff --git a/apps/mobile/packages/core/lib/src/core_module.dart b/apps/mobile/packages/core/lib/src/core_module.dart new file mode 100644 index 00000000..78e584b0 --- /dev/null +++ b/apps/mobile/packages/core/lib/src/core_module.dart @@ -0,0 +1,48 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_modular/flutter_modular.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:krow_domain/krow_domain.dart'; + +import '../core.dart'; + +/// A module that provides core services and shared dependencies. +/// +/// This module should be imported by the root [AppModule] to make +/// core services available globally as singletons. +class CoreModule extends Module { + @override + void exportedBinds(Injector i) { + // 1. Register the base HTTP client + i.addSingleton(() => Dio()); + + // 2. Register the base API service + i.addSingleton(() => ApiService(i.get())); + + // 3. Register Core API Services (Orchestrators) + i.addSingleton( + () => FileUploadService(i.get()), + ); + i.addSingleton( + () => SignedUrlService(i.get()), + ); + i.addSingleton( + () => VerificationService(i.get()), + ); + i.addSingleton(() => LlmService(i.get())); + + // 4. Register Device dependency + i.addSingleton(ImagePicker.new); + + // 5. Register Device Services + i.addSingleton(() => CameraService(i.get())); + i.addSingleton(() => GalleryService(i.get())); + i.addSingleton(FilePickerService.new); + i.addSingleton( + () => DeviceFileUploadService( + cameraService: i.get(), + galleryService: i.get(), + apiUploadService: i.get(), + ), + ); + } +} diff --git a/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/core_api_endpoints.dart b/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/core_api_endpoints.dart index 66c1a009..1c2a80cd 100644 --- a/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/core_api_endpoints.dart +++ b/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/core_api_endpoints.dart @@ -8,24 +8,26 @@ class CoreApiEndpoints { static const String baseUrl = AppConfig.coreApiBaseUrl; /// Upload a file. - static const String uploadFile = '/core/upload-file'; + static const String uploadFile = '$baseUrl/core/upload-file'; /// Create a signed URL for a file. - static const String createSignedUrl = '/core/create-signed-url'; + static const String createSignedUrl = '$baseUrl/core/create-signed-url'; /// Invoke a Large Language Model. - static const String invokeLlm = '/core/invoke-llm'; + static const String invokeLlm = '$baseUrl/core/invoke-llm'; /// Root for verification operations. - static const String verifications = '/core/verifications'; + static const String verifications = '$baseUrl/core/verifications'; /// Get status of a verification job. - static String verificationStatus(String id) => '/core/verifications/$id'; + static String verificationStatus(String id) => + '$baseUrl/core/verifications/$id'; /// Review a verification decision. static String verificationReview(String id) => - '/core/verifications/$id/review'; + '$baseUrl/core/verifications/$id/review'; /// Retry a verification job. - static String verificationRetry(String id) => '/core/verifications/$id/retry'; + static String verificationRetry(String id) => + '$baseUrl/core/verifications/$id/retry'; } diff --git a/apps/mobile/packages/data_connect/lib/src/connectors/staff/data/repositories/staff_connector_repository_impl.dart b/apps/mobile/packages/data_connect/lib/src/connectors/staff/data/repositories/staff_connector_repository_impl.dart index 9cdf0888..edbfa78e 100644 --- a/apps/mobile/packages/data_connect/lib/src/connectors/staff/data/repositories/staff_connector_repository_impl.dart +++ b/apps/mobile/packages/data_connect/lib/src/connectors/staff/data/repositories/staff_connector_repository_impl.dart @@ -229,7 +229,8 @@ class StaffConnectorRepositoryImpl implements StaffConnectorRepository { return optionsResponse.data.attireOptions.map((e) { final GetStaffAttireStaffAttires? userAttire = attireMap[e.id]; return AttireItem( - id: e.itemId, + id: e.id, + code: e.itemId, label: e.label, description: e.description, imageUrl: e.imageUrl, @@ -238,6 +239,7 @@ class StaffConnectorRepositoryImpl implements StaffConnectorRepository { userAttire?.verificationStatus?.stringValue, ), photoUrl: userAttire?.verificationPhotoUrl, + verificationId: userAttire?.verificationId, ); }).toList(); }); @@ -263,7 +265,7 @@ class StaffConnectorRepositoryImpl implements StaffConnectorRepository { await _service.connector .upsertStaffAttire(staffId: staffId, attireOptionId: attireOptionId) .verificationPhotoUrl(photoUrl) - // .verificationId(verificationId) // Uncomment after SDK regeneration + .verificationId(verificationId) .execute(); }); } diff --git a/apps/mobile/packages/domain/lib/src/entities/profile/attire_item.dart b/apps/mobile/packages/domain/lib/src/entities/profile/attire_item.dart index d830add4..d794ca9e 100644 --- a/apps/mobile/packages/domain/lib/src/entities/profile/attire_item.dart +++ b/apps/mobile/packages/domain/lib/src/entities/profile/attire_item.dart @@ -9,6 +9,7 @@ class AttireItem extends Equatable { /// Creates an [AttireItem]. const AttireItem({ required this.id, + required this.code, required this.label, this.description, this.imageUrl, @@ -18,9 +19,12 @@ class AttireItem extends Equatable { this.verificationId, }); - /// Unique identifier of the attire item. + /// Unique identifier of the attire item (UUID). final String id; + /// String code for the attire item (e.g. BLACK_TSHIRT). + final String code; + /// Display name of the item. final String label; @@ -45,6 +49,7 @@ class AttireItem extends Equatable { @override List get props => [ id, + code, label, description, imageUrl, @@ -53,4 +58,29 @@ class AttireItem extends Equatable { photoUrl, verificationId, ]; + + /// Creates a copy of this [AttireItem] with the given fields replaced. + AttireItem copyWith({ + String? id, + String? code, + String? label, + String? description, + String? imageUrl, + bool? isMandatory, + AttireVerificationStatus? verificationStatus, + String? photoUrl, + String? verificationId, + }) { + return AttireItem( + id: id ?? this.id, + code: code ?? this.code, + label: label ?? this.label, + description: description ?? this.description, + imageUrl: imageUrl ?? this.imageUrl, + isMandatory: isMandatory ?? this.isMandatory, + verificationStatus: verificationStatus ?? this.verificationStatus, + photoUrl: photoUrl ?? this.photoUrl, + verificationId: verificationId ?? this.verificationId, + ); + } } diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/attire_module.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/attire_module.dart index eb32cf88..dc1218fa 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/attire_module.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/attire_module.dart @@ -1,4 +1,5 @@ import 'package:flutter_modular/flutter_modular.dart'; +import 'package:image_picker/image_picker.dart'; import 'package:krow_core/core.dart'; import 'package:staff_attire/src/presentation/blocs/attire/attire_cubit.dart'; import 'package:staff_attire/src/presentation/blocs/attire_capture/attire_capture_cubit.dart'; @@ -13,6 +14,14 @@ import 'presentation/pages/attire_page.dart'; class StaffAttireModule extends Module { @override void binds(Injector i) { + /// third party services + i.addLazySingleton(ImagePicker.new); + + /// local services + i.addLazySingleton( + () => CameraService(i.get()), + ); + // Repository i.addLazySingleton(AttireRepositoryImpl.new); 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 21b00a93..4b278417 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 @@ -1,3 +1,6 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_modular/flutter_modular.dart'; +import 'package:krow_core/core.dart'; import 'package:krow_data_connect/krow_data_connect.dart'; import 'package:krow_domain/krow_domain.dart'; @@ -31,16 +34,78 @@ class AttireRepositoryImpl implements AttireRepository { } @override - Future uploadPhoto(String itemId, String filePath) async { - // In a real app, this would upload to Firebase Storage first. - // Since the prototype returns a mock URL, we'll use that to upsert our record. - final String mockUrl = 'mock_url_for_$itemId'; - - await _connector.upsertStaffAttire( - attireOptionId: itemId, - photoUrl: mockUrl, + Future uploadPhoto(String itemId, String filePath) async { + // 1. Upload file to Core API + final FileUploadService uploadService = Modular.get(); + final ApiResponse uploadRes = await uploadService.uploadFile( + filePath: filePath, + fileName: filePath.split('/').last, ); - return mockUrl; + if (!uploadRes.code.startsWith('2')) { + throw Exception('Upload failed: ${uploadRes.message}'); + } + + final String fileUri = uploadRes.data?['fileUri'] as String; + + // 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; + + // 3. Initiate verification job + final VerificationService verificationService = + Modular.get(); + final Staff staff = await _connector.getStaffProfile(); + + // Get item details for verification rules + final List options = await _connector.getAttireOptions(); + final AttireItem targetItem = options.firstWhere( + (AttireItem e) => e.id == itemId, + ); + 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; + + // 4. Poll for status until it's finished or timeout (max 10 seconds) + try { + int attempts = 0; + 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') { + isFinished = true; + } + attempts++; + } + } catch (e) { + debugPrint('Polling failed or timed out: $e'); + // Continue anyway, as we have the verificationId + } + + // 5. Update Data Connect + await _connector.upsertStaffAttire( + attireOptionId: itemId, + photoUrl: photoUrl, + verificationId: verificationId, + ); + + // 6. Return updated AttireItem by re-fetching to get the PENDING/SUCCESS status + final List finalOptions = await _connector.getAttireOptions(); + return finalOptions.firstWhere((AttireItem e) => e.id == itemId); } } diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/domain/repositories/attire_repository.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/domain/repositories/attire_repository.dart index a0452704..a57107c0 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/domain/repositories/attire_repository.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/domain/repositories/attire_repository.dart @@ -5,7 +5,7 @@ abstract interface class AttireRepository { Future> getAttireOptions(); /// Uploads a photo for a specific attire item. - Future uploadPhoto(String itemId, String filePath); + Future uploadPhoto(String itemId, String filePath); /// Saves the user's attire selection and attestations. Future saveAttire({ diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/domain/usecases/upload_attire_photo_usecase.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/domain/usecases/upload_attire_photo_usecase.dart index d76edf06..39cd456b 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/domain/usecases/upload_attire_photo_usecase.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/domain/usecases/upload_attire_photo_usecase.dart @@ -1,16 +1,17 @@ import 'package:krow_core/core.dart'; +import 'package:krow_domain/krow_domain.dart'; import '../arguments/upload_attire_photo_arguments.dart'; import '../repositories/attire_repository.dart'; /// Use case to upload a photo for an attire item. class UploadAttirePhotoUseCase - extends UseCase { + extends UseCase { /// Creates a [UploadAttirePhotoUseCase]. UploadAttirePhotoUseCase(this._repository); final AttireRepository _repository; @override - Future call(UploadAttirePhotoArguments arguments) { + Future call(UploadAttirePhotoArguments arguments) { return _repository.uploadPhoto(arguments.itemId, arguments.filePath); } } diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/blocs/attire/attire_cubit.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/blocs/attire/attire_cubit.dart index ce9862d5..b0739dee 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/blocs/attire/attire_cubit.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/blocs/attire/attire_cubit.dart @@ -64,9 +64,21 @@ class AttireCubit extends Cubit emit(state.copyWith(selectedIds: currentSelection)); } - void syncCapturedPhoto(String itemId, String url) { - // When a photo is captured, we refresh the options to get the updated status from backend - loadOptions(); + void syncCapturedPhoto(AttireItem item) { + // Update the options list with the new item data + final List updatedOptions = state.options + .map((AttireItem e) => e.id == item.id ? item : e) + .toList(); + + // Update the photo URLs map + final Map updatedPhotos = Map.from( + state.photoUrls, + ); + if (item.photoUrl != null) { + updatedPhotos[item.id] = item.photoUrl!; + } + + emit(state.copyWith(options: updatedOptions, photoUrls: updatedPhotos)); } Future save() async { diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/blocs/attire/attire_state.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/blocs/attire/attire_state.dart index 3d882c07..43caeada 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/blocs/attire/attire_state.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/blocs/attire/attire_state.dart @@ -22,7 +22,7 @@ class AttireState extends Equatable { return options .firstWhere( (AttireItem e) => e.id == id, - orElse: () => const AttireItem(id: '', label: ''), + orElse: () => const AttireItem(id: '', code: '', label: ''), ) .isMandatory; } diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/blocs/attire_capture/attire_capture_cubit.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/blocs/attire_capture/attire_capture_cubit.dart index cad159e0..a3b9eca1 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/blocs/attire_capture/attire_capture_cubit.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/blocs/attire_capture/attire_capture_cubit.dart @@ -1,5 +1,6 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:krow_core/core.dart'; +import 'package:krow_domain/krow_domain.dart'; import 'package:staff_attire/src/domain/arguments/upload_attire_photo_arguments.dart'; import 'package:staff_attire/src/domain/usecases/upload_attire_photo_usecase.dart'; @@ -22,12 +23,16 @@ class AttireCaptureCubit extends Cubit await handleError( emit: emit, action: () async { - final String url = await _uploadAttirePhotoUseCase( + final AttireItem item = await _uploadAttirePhotoUseCase( UploadAttirePhotoArguments(itemId: itemId, filePath: filePath), ); emit( - state.copyWith(status: AttireCaptureStatus.success, photoUrl: url), + state.copyWith( + status: AttireCaptureStatus.success, + photoUrl: item.photoUrl, + updatedItem: item, + ), ); }, onError: (String errorKey) => state.copyWith( diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/blocs/attire_capture/attire_capture_state.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/blocs/attire_capture/attire_capture_state.dart index 6b776816..79f6e28a 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/blocs/attire_capture/attire_capture_state.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/blocs/attire_capture/attire_capture_state.dart @@ -1,4 +1,5 @@ import 'package:equatable/equatable.dart'; +import 'package:krow_domain/krow_domain.dart'; enum AttireCaptureStatus { initial, uploading, success, failure } @@ -7,24 +8,28 @@ class AttireCaptureState extends Equatable { this.status = AttireCaptureStatus.initial, this.isAttested = false, this.photoUrl, + this.updatedItem, this.errorMessage, }); final AttireCaptureStatus status; final bool isAttested; final String? photoUrl; + final AttireItem? updatedItem; final String? errorMessage; AttireCaptureState copyWith({ AttireCaptureStatus? status, bool? isAttested, String? photoUrl, + AttireItem? updatedItem, String? errorMessage, }) { return AttireCaptureState( status: status ?? this.status, isAttested: isAttested ?? this.isAttested, photoUrl: photoUrl ?? this.photoUrl, + updatedItem: updatedItem ?? this.updatedItem, errorMessage: errorMessage, ); } @@ -34,6 +39,7 @@ class AttireCaptureState extends Equatable { status, isAttested, photoUrl, + updatedItem, errorMessage, ]; } diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/pages/attire_capture_page.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/pages/attire_capture_page.dart index 138dceff..e535b568 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/pages/attire_capture_page.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/pages/attire_capture_page.dart @@ -298,7 +298,7 @@ class _AttireCapturePageState extends State { fullWidth: true, text: 'Submit Image', onPressed: () { - Modular.to.pop(currentPhotoUrl); + Modular.to.pop(state.updatedItem); }, ), ], diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/pages/attire_page.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/pages/attire_page.dart index c2782981..7a0417ab 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/pages/attire_page.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/pages/attire_page.dart @@ -113,10 +113,10 @@ class _AttirePageState extends State { isUploading: false, uploadedPhotoUrl: state.photoUrls[item.id], onTap: () async { - final String? resultUrl = - await Navigator.push( + final AttireItem? updatedItem = + await Navigator.push( context, - MaterialPageRoute( + MaterialPageRoute( builder: (BuildContext ctx) => AttireCapturePage( item: item, @@ -126,8 +126,8 @@ class _AttirePageState extends State { ), ); - if (resultUrl != null && mounted) { - cubit.syncCapturedPhoto(item.id, resultUrl); + if (updatedItem != null && mounted) { + cubit.syncCapturedPhoto(updatedItem); } }, ), diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_item_card.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_item_card.dart index 43c88fbc..abeab814 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_item_card.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_item_card.dart @@ -89,7 +89,9 @@ class AttireItemCard extends StatelessWidget { UiChip( label: statusText, size: UiChipSize.xSmall, - variant: item.verificationStatus == 'SUCCESS' + variant: + item.verificationStatus == + AttireVerificationStatus.success ? UiChipVariant.primary : UiChipVariant.secondary, ), @@ -112,10 +114,12 @@ class AttireItemCard extends StatelessWidget { ) else if (hasPhoto && !isUploading) Icon( - item.verificationStatus == 'SUCCESS' + item.verificationStatus == AttireVerificationStatus.success ? UiIcons.check : UiIcons.clock, - color: item.verificationStatus == 'SUCCESS' + color: + item.verificationStatus == + AttireVerificationStatus.success ? UiColors.textPrimary : UiColors.textWarning, size: 24, diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/pubspec.yaml b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/pubspec.yaml index 07a124c8..0a5ffcf0 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/pubspec.yaml +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/pubspec.yaml @@ -27,6 +27,7 @@ dependencies: path: ../../../../../design_system core_localization: path: ../../../../../core_localization + image_picker: ^1.2.1 dev_dependencies: flutter_test: diff --git a/docs/MILESTONES/M4/planning/m4-core-api-frontend-guide.md b/docs/MILESTONES/M4/planning/m4-core-api-frontend-guide.md new file mode 100644 index 00000000..64f8a5c2 --- /dev/null +++ b/docs/MILESTONES/M4/planning/m4-core-api-frontend-guide.md @@ -0,0 +1,245 @@ +# M4 Core API Frontend Guide (Dev) + +Status: Active +Last updated: 2026-02-24 +Audience: Web and mobile frontend developers + +## 1) Base URLs (dev) +1. Core API: `https://krow-core-api-e3g6witsvq-uc.a.run.app` + +## 2) Auth requirements +1. Send Firebase ID token on protected routes: +```http +Authorization: Bearer +``` +2. Health route is public: +- `GET /health` +3. All other routes require Firebase token. + +## 3) Standard error envelope +```json +{ + "code": "STRING_CODE", + "message": "Human readable message", + "details": {}, + "requestId": "uuid" +} +``` + +## 4) Core API endpoints + +## 4.1 Upload file +1. Route: `POST /core/upload-file` +2. Alias: `POST /uploadFile` +3. Content type: `multipart/form-data` +4. Form fields: +- `file` (required) +- `visibility` (optional: `public` or `private`, default `private`) +- `category` (optional) +5. Accepted file types: +- `application/pdf` +- `image/jpeg` +- `image/jpg` +- `image/png` +6. Max upload size: `10 MB` (default) +7. Current behavior: real upload to Cloud Storage (not mock) +8. Success `200` example: +```json +{ + "fileUri": "gs://krow-workforce-dev-private/uploads//173...", + "contentType": "application/pdf", + "size": 12345, + "bucket": "krow-workforce-dev-private", + "path": "uploads//173..._file.pdf", + "requestId": "uuid" +} +``` + +## 4.2 Create signed URL +1. Route: `POST /core/create-signed-url` +2. Alias: `POST /createSignedUrl` +3. Request body: +```json +{ + "fileUri": "gs://krow-workforce-dev-private/uploads//file.pdf", + "expiresInSeconds": 300 +} +``` +4. Security checks: +- bucket must be allowed (`krow-workforce-dev-public` or `krow-workforce-dev-private`) +- path must be owned by caller (`uploads//...`) +- object must exist +- `expiresInSeconds` must be `<= 900` +5. Success `200` example: +```json +{ + "signedUrl": "https://storage.googleapis.com/...", + "expiresAt": "2026-02-24T15:22:28.105Z", + "requestId": "uuid" +} +``` +6. Typical errors: +- `400 VALIDATION_ERROR` (bad payload or expiry too high) +- `403 FORBIDDEN` (path not owned by caller) +- `404 NOT_FOUND` (object does not exist) + +## 4.3 Invoke model +1. Route: `POST /core/invoke-llm` +2. Alias: `POST /invokeLLM` +3. Request body: +```json +{ + "prompt": "Return JSON with keys summary and risk.", + "responseJsonSchema": { + "type": "object", + "properties": { + "summary": { "type": "string" }, + "risk": { "type": "string" } + }, + "required": ["summary", "risk"] + }, + "fileUrls": [] +} +``` +4. Current behavior: real Vertex model call (not mock) +- model: `gemini-2.0-flash-001` +- timeout: `20 seconds` +5. Rate limit: +- per-user `20 requests/minute` (default) +- on limit: `429 RATE_LIMITED` +- includes `Retry-After` header +6. Success `200` example: +```json +{ + "result": { "summary": "text", "risk": "Low" }, + "model": "gemini-2.0-flash-001", + "latencyMs": 367, + "requestId": "uuid" +} +``` + +## 4.4 Create verification job +1. Route: `POST /core/verifications` +2. Auth: required +3. Purpose: enqueue an async verification job for an uploaded file. +4. Request body: +```json +{ + "type": "attire", + "subjectType": "worker", + "subjectId": "", + "fileUri": "gs://krow-workforce-dev-private/uploads//file.pdf", + "rules": { + "dressCode": "black shoes" + } +} +``` +5. Success `202` example: +```json +{ + "verificationId": "ver_123", + "status": "PENDING", + "type": "attire", + "requestId": "uuid" +} +``` +6. Current machine processing behavior in dev: +- `attire`: live vision check using Vertex Gemini Flash Lite model. +- `government_id`: third-party adapter path (falls back to `NEEDS_REVIEW` if provider is not configured). +- `certification`: third-party adapter path (falls back to `NEEDS_REVIEW` if provider is not configured). + +## 4.5 Get verification status +1. Route: `GET /core/verifications/{verificationId}` +2. Auth: required +3. Purpose: polling status from frontend. +4. Success `200` example: +```json +{ + "verificationId": "ver_123", + "status": "NEEDS_REVIEW", + "type": "attire", + "review": null, + "requestId": "uuid" +} +``` + +## 4.6 Review verification +1. Route: `POST /core/verifications/{verificationId}/review` +2. Auth: required +3. Purpose: final human decision for the verification. +4. Request body: +```json +{ + "decision": "APPROVED", + "note": "Manual review passed", + "reasonCode": "MANUAL_REVIEW" +} +``` +5. Success `200` example: +```json +{ + "verificationId": "ver_123", + "status": "APPROVED", + "review": { + "decision": "APPROVED", + "reviewedBy": "" + }, + "requestId": "uuid" +} +``` + +## 4.7 Retry verification +1. Route: `POST /core/verifications/{verificationId}/retry` +2. Auth: required +3. Purpose: requeue verification to run again. +4. Success `202` example: status resets to `PENDING`. + +## 5) Frontend fetch examples (web) + +## 5.1 Signed URL request +```ts +const token = await firebaseAuth.currentUser?.getIdToken(); +const res = await fetch('https://krow-core-api-e3g6witsvq-uc.a.run.app/core/create-signed-url', { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + fileUri: 'gs://krow-workforce-dev-private/uploads//file.pdf', + expiresInSeconds: 300, + }), +}); +const data = await res.json(); +``` + +## 5.2 Model request +```ts +const token = await firebaseAuth.currentUser?.getIdToken(); +const res = await fetch('https://krow-core-api-e3g6witsvq-uc.a.run.app/core/invoke-llm', { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + prompt: 'Return JSON with status.', + responseJsonSchema: { + type: 'object', + properties: { status: { type: 'string' } }, + required: ['status'], + }, + }), +}); +const data = await res.json(); +``` + +## 6) Notes for frontend team +1. Use canonical `/core/*` routes for new work. +2. Aliases exist only for migration compatibility. +3. `requestId` in responses should be logged client-side for debugging. +4. For 429 on model route, retry with exponential backoff and respect `Retry-After`. +5. Verification routes are now available in dev under `/core/verifications*`. +6. Current verification processing is async and returns machine statuses first (`PENDING`, `PROCESSING`, `NEEDS_REVIEW`, etc.). +7. Full verification design and policy details: + `docs/MILESTONES/M4/planning/m4-verification-architecture-contract.md`.