feat: Implement attire photo capture, update AttireItem entity, and streamline the photo upload and state management flow.
This commit is contained in:
@@ -5,23 +5,23 @@ import 'package:design_system/design_system.dart';
|
|||||||
import 'package:firebase_core/firebase_core.dart';
|
import 'package:firebase_core/firebase_core.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:marionette_flutter/marionette_flutter.dart';
|
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||||
import 'package:flutter_modular/flutter_modular.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_data_connect/krow_data_connect.dart';
|
||||||
import 'package:krowwithus_staff/firebase_options.dart';
|
import 'package:krowwithus_staff/firebase_options.dart';
|
||||||
|
import 'package:marionette_flutter/marionette_flutter.dart';
|
||||||
import 'package:staff_authentication/staff_authentication.dart'
|
import 'package:staff_authentication/staff_authentication.dart'
|
||||||
as staff_authentication;
|
as staff_authentication;
|
||||||
import 'package:staff_main/staff_main.dart' as staff_main;
|
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';
|
import 'src/widgets/session_listener.dart';
|
||||||
|
|
||||||
void main() async {
|
void main() async {
|
||||||
final bool isFlutterTest =
|
final bool isFlutterTest = !kIsWeb
|
||||||
!kIsWeb ? Platform.environment.containsKey('FLUTTER_TEST') : false;
|
? Platform.environment.containsKey('FLUTTER_TEST')
|
||||||
|
: false;
|
||||||
if (kDebugMode && !isFlutterTest) {
|
if (kDebugMode && !isFlutterTest) {
|
||||||
MarionetteBinding.ensureInitialized(
|
MarionetteBinding.ensureInitialized(
|
||||||
MarionetteConfiguration(
|
MarionetteConfiguration(
|
||||||
@@ -63,20 +63,9 @@ void main() async {
|
|||||||
|
|
||||||
/// The main application module.
|
/// The main application module.
|
||||||
class AppModule extends Module {
|
class AppModule extends Module {
|
||||||
@override
|
|
||||||
void binds(Injector i) {
|
|
||||||
i.addLazySingleton<ImagePicker>(ImagePicker.new);
|
|
||||||
i.addLazySingleton<CameraService>(
|
|
||||||
() => CameraService(i.get<ImagePicker>()),
|
|
||||||
);
|
|
||||||
i.addLazySingleton<GalleryService>(
|
|
||||||
() => GalleryService(i.get<ImagePicker>()),
|
|
||||||
);
|
|
||||||
i.addLazySingleton<FilePickerService>(FilePickerService.new);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
List<Module> get imports => <Module>[
|
List<Module> get imports => <Module>[
|
||||||
|
CoreModule(),
|
||||||
core_localization.LocalizationModule(),
|
core_localization.LocalizationModule(),
|
||||||
staff_authentication.StaffAuthenticationModule(),
|
staff_authentication.StaffAuthenticationModule(),
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
library;
|
library;
|
||||||
|
|
||||||
|
export 'src/core_module.dart';
|
||||||
|
|
||||||
export 'src/domain/arguments/usecase_argument.dart';
|
export 'src/domain/arguments/usecase_argument.dart';
|
||||||
export 'src/domain/usecases/usecase.dart';
|
export 'src/domain/usecases/usecase.dart';
|
||||||
export 'src/utils/date_time_utils.dart';
|
export 'src/utils/date_time_utils.dart';
|
||||||
|
|||||||
48
apps/mobile/packages/core/lib/src/core_module.dart
Normal file
48
apps/mobile/packages/core/lib/src/core_module.dart
Normal file
@@ -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>(() => Dio());
|
||||||
|
|
||||||
|
// 2. Register the base API service
|
||||||
|
i.addSingleton<BaseApiService>(() => ApiService(i.get<Dio>()));
|
||||||
|
|
||||||
|
// 3. Register Core API Services (Orchestrators)
|
||||||
|
i.addSingleton<FileUploadService>(
|
||||||
|
() => FileUploadService(i.get<BaseApiService>()),
|
||||||
|
);
|
||||||
|
i.addSingleton<SignedUrlService>(
|
||||||
|
() => SignedUrlService(i.get<BaseApiService>()),
|
||||||
|
);
|
||||||
|
i.addSingleton<VerificationService>(
|
||||||
|
() => VerificationService(i.get<BaseApiService>()),
|
||||||
|
);
|
||||||
|
i.addSingleton<LlmService>(() => LlmService(i.get<BaseApiService>()));
|
||||||
|
|
||||||
|
// 4. Register Device dependency
|
||||||
|
i.addSingleton<ImagePicker>(ImagePicker.new);
|
||||||
|
|
||||||
|
// 5. Register Device Services
|
||||||
|
i.addSingleton<CameraService>(() => CameraService(i.get<ImagePicker>()));
|
||||||
|
i.addSingleton<GalleryService>(() => GalleryService(i.get<ImagePicker>()));
|
||||||
|
i.addSingleton<FilePickerService>(FilePickerService.new);
|
||||||
|
i.addSingleton<DeviceFileUploadService>(
|
||||||
|
() => DeviceFileUploadService(
|
||||||
|
cameraService: i.get<CameraService>(),
|
||||||
|
galleryService: i.get<GalleryService>(),
|
||||||
|
apiUploadService: i.get<FileUploadService>(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,24 +8,26 @@ class CoreApiEndpoints {
|
|||||||
static const String baseUrl = AppConfig.coreApiBaseUrl;
|
static const String baseUrl = AppConfig.coreApiBaseUrl;
|
||||||
|
|
||||||
/// Upload a file.
|
/// 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.
|
/// 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.
|
/// Invoke a Large Language Model.
|
||||||
static const String invokeLlm = '/core/invoke-llm';
|
static const String invokeLlm = '$baseUrl/core/invoke-llm';
|
||||||
|
|
||||||
/// Root for verification operations.
|
/// Root for verification operations.
|
||||||
static const String verifications = '/core/verifications';
|
static const String verifications = '$baseUrl/core/verifications';
|
||||||
|
|
||||||
/// Get status of a verification job.
|
/// 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.
|
/// Review a verification decision.
|
||||||
static String verificationReview(String id) =>
|
static String verificationReview(String id) =>
|
||||||
'/core/verifications/$id/review';
|
'$baseUrl/core/verifications/$id/review';
|
||||||
|
|
||||||
/// Retry a verification job.
|
/// Retry a verification job.
|
||||||
static String verificationRetry(String id) => '/core/verifications/$id/retry';
|
static String verificationRetry(String id) =>
|
||||||
|
'$baseUrl/core/verifications/$id/retry';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -229,7 +229,8 @@ class StaffConnectorRepositoryImpl implements StaffConnectorRepository {
|
|||||||
return optionsResponse.data.attireOptions.map((e) {
|
return optionsResponse.data.attireOptions.map((e) {
|
||||||
final GetStaffAttireStaffAttires? userAttire = attireMap[e.id];
|
final GetStaffAttireStaffAttires? userAttire = attireMap[e.id];
|
||||||
return AttireItem(
|
return AttireItem(
|
||||||
id: e.itemId,
|
id: e.id,
|
||||||
|
code: e.itemId,
|
||||||
label: e.label,
|
label: e.label,
|
||||||
description: e.description,
|
description: e.description,
|
||||||
imageUrl: e.imageUrl,
|
imageUrl: e.imageUrl,
|
||||||
@@ -238,6 +239,7 @@ class StaffConnectorRepositoryImpl implements StaffConnectorRepository {
|
|||||||
userAttire?.verificationStatus?.stringValue,
|
userAttire?.verificationStatus?.stringValue,
|
||||||
),
|
),
|
||||||
photoUrl: userAttire?.verificationPhotoUrl,
|
photoUrl: userAttire?.verificationPhotoUrl,
|
||||||
|
verificationId: userAttire?.verificationId,
|
||||||
);
|
);
|
||||||
}).toList();
|
}).toList();
|
||||||
});
|
});
|
||||||
@@ -263,7 +265,7 @@ class StaffConnectorRepositoryImpl implements StaffConnectorRepository {
|
|||||||
await _service.connector
|
await _service.connector
|
||||||
.upsertStaffAttire(staffId: staffId, attireOptionId: attireOptionId)
|
.upsertStaffAttire(staffId: staffId, attireOptionId: attireOptionId)
|
||||||
.verificationPhotoUrl(photoUrl)
|
.verificationPhotoUrl(photoUrl)
|
||||||
// .verificationId(verificationId) // Uncomment after SDK regeneration
|
.verificationId(verificationId)
|
||||||
.execute();
|
.execute();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ class AttireItem extends Equatable {
|
|||||||
/// Creates an [AttireItem].
|
/// Creates an [AttireItem].
|
||||||
const AttireItem({
|
const AttireItem({
|
||||||
required this.id,
|
required this.id,
|
||||||
|
required this.code,
|
||||||
required this.label,
|
required this.label,
|
||||||
this.description,
|
this.description,
|
||||||
this.imageUrl,
|
this.imageUrl,
|
||||||
@@ -18,9 +19,12 @@ class AttireItem extends Equatable {
|
|||||||
this.verificationId,
|
this.verificationId,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Unique identifier of the attire item.
|
/// Unique identifier of the attire item (UUID).
|
||||||
final String id;
|
final String id;
|
||||||
|
|
||||||
|
/// String code for the attire item (e.g. BLACK_TSHIRT).
|
||||||
|
final String code;
|
||||||
|
|
||||||
/// Display name of the item.
|
/// Display name of the item.
|
||||||
final String label;
|
final String label;
|
||||||
|
|
||||||
@@ -45,6 +49,7 @@ class AttireItem extends Equatable {
|
|||||||
@override
|
@override
|
||||||
List<Object?> get props => <Object?>[
|
List<Object?> get props => <Object?>[
|
||||||
id,
|
id,
|
||||||
|
code,
|
||||||
label,
|
label,
|
||||||
description,
|
description,
|
||||||
imageUrl,
|
imageUrl,
|
||||||
@@ -53,4 +58,29 @@ class AttireItem extends Equatable {
|
|||||||
photoUrl,
|
photoUrl,
|
||||||
verificationId,
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:flutter_modular/flutter_modular.dart';
|
import 'package:flutter_modular/flutter_modular.dart';
|
||||||
|
import 'package:image_picker/image_picker.dart';
|
||||||
import 'package:krow_core/core.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/attire_cubit.dart';
|
||||||
import 'package:staff_attire/src/presentation/blocs/attire_capture/attire_capture_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 {
|
class StaffAttireModule extends Module {
|
||||||
@override
|
@override
|
||||||
void binds(Injector i) {
|
void binds(Injector i) {
|
||||||
|
/// third party services
|
||||||
|
i.addLazySingleton<ImagePicker>(ImagePicker.new);
|
||||||
|
|
||||||
|
/// local services
|
||||||
|
i.addLazySingleton<CameraService>(
|
||||||
|
() => CameraService(i.get<ImagePicker>()),
|
||||||
|
);
|
||||||
|
|
||||||
// Repository
|
// Repository
|
||||||
i.addLazySingleton<AttireRepository>(AttireRepositoryImpl.new);
|
i.addLazySingleton<AttireRepository>(AttireRepositoryImpl.new);
|
||||||
|
|
||||||
|
|||||||
@@ -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_data_connect/krow_data_connect.dart';
|
||||||
import 'package:krow_domain/krow_domain.dart';
|
import 'package:krow_domain/krow_domain.dart';
|
||||||
|
|
||||||
@@ -31,16 +34,78 @@ class AttireRepositoryImpl implements AttireRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<String> uploadPhoto(String itemId, String filePath) async {
|
Future<AttireItem> uploadPhoto(String itemId, String filePath) async {
|
||||||
// In a real app, this would upload to Firebase Storage first.
|
// 1. Upload file to Core API
|
||||||
// Since the prototype returns a mock URL, we'll use that to upsert our record.
|
final FileUploadService uploadService = Modular.get<FileUploadService>();
|
||||||
final String mockUrl = 'mock_url_for_$itemId';
|
final ApiResponse uploadRes = await uploadService.uploadFile(
|
||||||
|
filePath: filePath,
|
||||||
await _connector.upsertStaffAttire(
|
fileName: filePath.split('/').last,
|
||||||
attireOptionId: itemId,
|
|
||||||
photoUrl: mockUrl,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
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<SignedUrlService>();
|
||||||
|
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<VerificationService>();
|
||||||
|
final Staff staff = await _connector.getStaffProfile();
|
||||||
|
|
||||||
|
// Get item details for verification rules
|
||||||
|
final List<AttireItem> 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: <String, dynamic>{'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<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') {
|
||||||
|
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<AttireItem> finalOptions = await _connector.getAttireOptions();
|
||||||
|
return finalOptions.firstWhere((AttireItem e) => e.id == itemId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ abstract interface class AttireRepository {
|
|||||||
Future<List<AttireItem>> getAttireOptions();
|
Future<List<AttireItem>> getAttireOptions();
|
||||||
|
|
||||||
/// Uploads a photo for a specific attire item.
|
/// Uploads a photo for a specific attire item.
|
||||||
Future<String> uploadPhoto(String itemId, String filePath);
|
Future<AttireItem> uploadPhoto(String itemId, String filePath);
|
||||||
|
|
||||||
/// Saves the user's attire selection and attestations.
|
/// Saves the user's attire selection and attestations.
|
||||||
Future<void> saveAttire({
|
Future<void> saveAttire({
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
import 'package:krow_core/core.dart';
|
import 'package:krow_core/core.dart';
|
||||||
|
import 'package:krow_domain/krow_domain.dart';
|
||||||
import '../arguments/upload_attire_photo_arguments.dart';
|
import '../arguments/upload_attire_photo_arguments.dart';
|
||||||
import '../repositories/attire_repository.dart';
|
import '../repositories/attire_repository.dart';
|
||||||
|
|
||||||
/// Use case to upload a photo for an attire item.
|
/// Use case to upload a photo for an attire item.
|
||||||
class UploadAttirePhotoUseCase
|
class UploadAttirePhotoUseCase
|
||||||
extends UseCase<UploadAttirePhotoArguments, String> {
|
extends UseCase<UploadAttirePhotoArguments, AttireItem> {
|
||||||
/// Creates a [UploadAttirePhotoUseCase].
|
/// Creates a [UploadAttirePhotoUseCase].
|
||||||
UploadAttirePhotoUseCase(this._repository);
|
UploadAttirePhotoUseCase(this._repository);
|
||||||
final AttireRepository _repository;
|
final AttireRepository _repository;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<String> call(UploadAttirePhotoArguments arguments) {
|
Future<AttireItem> call(UploadAttirePhotoArguments arguments) {
|
||||||
return _repository.uploadPhoto(arguments.itemId, arguments.filePath);
|
return _repository.uploadPhoto(arguments.itemId, arguments.filePath);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,9 +64,21 @@ class AttireCubit extends Cubit<AttireState>
|
|||||||
emit(state.copyWith(selectedIds: currentSelection));
|
emit(state.copyWith(selectedIds: currentSelection));
|
||||||
}
|
}
|
||||||
|
|
||||||
void syncCapturedPhoto(String itemId, String url) {
|
void syncCapturedPhoto(AttireItem item) {
|
||||||
// When a photo is captured, we refresh the options to get the updated status from backend
|
// Update the options list with the new item data
|
||||||
loadOptions();
|
final List<AttireItem> updatedOptions = state.options
|
||||||
|
.map((AttireItem e) => e.id == item.id ? item : e)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
// Update the photo URLs map
|
||||||
|
final Map<String, String> updatedPhotos = Map<String, String>.from(
|
||||||
|
state.photoUrls,
|
||||||
|
);
|
||||||
|
if (item.photoUrl != null) {
|
||||||
|
updatedPhotos[item.id] = item.photoUrl!;
|
||||||
|
}
|
||||||
|
|
||||||
|
emit(state.copyWith(options: updatedOptions, photoUrls: updatedPhotos));
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> save() async {
|
Future<void> save() async {
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ class AttireState extends Equatable {
|
|||||||
return options
|
return options
|
||||||
.firstWhere(
|
.firstWhere(
|
||||||
(AttireItem e) => e.id == id,
|
(AttireItem e) => e.id == id,
|
||||||
orElse: () => const AttireItem(id: '', label: ''),
|
orElse: () => const AttireItem(id: '', code: '', label: ''),
|
||||||
)
|
)
|
||||||
.isMandatory;
|
.isMandatory;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:krow_core/core.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/arguments/upload_attire_photo_arguments.dart';
|
||||||
import 'package:staff_attire/src/domain/usecases/upload_attire_photo_usecase.dart';
|
import 'package:staff_attire/src/domain/usecases/upload_attire_photo_usecase.dart';
|
||||||
|
|
||||||
@@ -22,12 +23,16 @@ class AttireCaptureCubit extends Cubit<AttireCaptureState>
|
|||||||
await handleError(
|
await handleError(
|
||||||
emit: emit,
|
emit: emit,
|
||||||
action: () async {
|
action: () async {
|
||||||
final String url = await _uploadAttirePhotoUseCase(
|
final AttireItem item = await _uploadAttirePhotoUseCase(
|
||||||
UploadAttirePhotoArguments(itemId: itemId, filePath: filePath),
|
UploadAttirePhotoArguments(itemId: itemId, filePath: filePath),
|
||||||
);
|
);
|
||||||
|
|
||||||
emit(
|
emit(
|
||||||
state.copyWith(status: AttireCaptureStatus.success, photoUrl: url),
|
state.copyWith(
|
||||||
|
status: AttireCaptureStatus.success,
|
||||||
|
photoUrl: item.photoUrl,
|
||||||
|
updatedItem: item,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
onError: (String errorKey) => state.copyWith(
|
onError: (String errorKey) => state.copyWith(
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:equatable/equatable.dart';
|
import 'package:equatable/equatable.dart';
|
||||||
|
import 'package:krow_domain/krow_domain.dart';
|
||||||
|
|
||||||
enum AttireCaptureStatus { initial, uploading, success, failure }
|
enum AttireCaptureStatus { initial, uploading, success, failure }
|
||||||
|
|
||||||
@@ -7,24 +8,28 @@ class AttireCaptureState extends Equatable {
|
|||||||
this.status = AttireCaptureStatus.initial,
|
this.status = AttireCaptureStatus.initial,
|
||||||
this.isAttested = false,
|
this.isAttested = false,
|
||||||
this.photoUrl,
|
this.photoUrl,
|
||||||
|
this.updatedItem,
|
||||||
this.errorMessage,
|
this.errorMessage,
|
||||||
});
|
});
|
||||||
|
|
||||||
final AttireCaptureStatus status;
|
final AttireCaptureStatus status;
|
||||||
final bool isAttested;
|
final bool isAttested;
|
||||||
final String? photoUrl;
|
final String? photoUrl;
|
||||||
|
final AttireItem? updatedItem;
|
||||||
final String? errorMessage;
|
final String? errorMessage;
|
||||||
|
|
||||||
AttireCaptureState copyWith({
|
AttireCaptureState copyWith({
|
||||||
AttireCaptureStatus? status,
|
AttireCaptureStatus? status,
|
||||||
bool? isAttested,
|
bool? isAttested,
|
||||||
String? photoUrl,
|
String? photoUrl,
|
||||||
|
AttireItem? updatedItem,
|
||||||
String? errorMessage,
|
String? errorMessage,
|
||||||
}) {
|
}) {
|
||||||
return AttireCaptureState(
|
return AttireCaptureState(
|
||||||
status: status ?? this.status,
|
status: status ?? this.status,
|
||||||
isAttested: isAttested ?? this.isAttested,
|
isAttested: isAttested ?? this.isAttested,
|
||||||
photoUrl: photoUrl ?? this.photoUrl,
|
photoUrl: photoUrl ?? this.photoUrl,
|
||||||
|
updatedItem: updatedItem ?? this.updatedItem,
|
||||||
errorMessage: errorMessage,
|
errorMessage: errorMessage,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -34,6 +39,7 @@ class AttireCaptureState extends Equatable {
|
|||||||
status,
|
status,
|
||||||
isAttested,
|
isAttested,
|
||||||
photoUrl,
|
photoUrl,
|
||||||
|
updatedItem,
|
||||||
errorMessage,
|
errorMessage,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -298,7 +298,7 @@ class _AttireCapturePageState extends State<AttireCapturePage> {
|
|||||||
fullWidth: true,
|
fullWidth: true,
|
||||||
text: 'Submit Image',
|
text: 'Submit Image',
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
Modular.to.pop(currentPhotoUrl);
|
Modular.to.pop(state.updatedItem);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -113,10 +113,10 @@ class _AttirePageState extends State<AttirePage> {
|
|||||||
isUploading: false,
|
isUploading: false,
|
||||||
uploadedPhotoUrl: state.photoUrls[item.id],
|
uploadedPhotoUrl: state.photoUrls[item.id],
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
final String? resultUrl =
|
final AttireItem? updatedItem =
|
||||||
await Navigator.push<String?>(
|
await Navigator.push<AttireItem?>(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute<String?>(
|
MaterialPageRoute<AttireItem?>(
|
||||||
builder: (BuildContext ctx) =>
|
builder: (BuildContext ctx) =>
|
||||||
AttireCapturePage(
|
AttireCapturePage(
|
||||||
item: item,
|
item: item,
|
||||||
@@ -126,8 +126,8 @@ class _AttirePageState extends State<AttirePage> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (resultUrl != null && mounted) {
|
if (updatedItem != null && mounted) {
|
||||||
cubit.syncCapturedPhoto(item.id, resultUrl);
|
cubit.syncCapturedPhoto(updatedItem);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -89,7 +89,9 @@ class AttireItemCard extends StatelessWidget {
|
|||||||
UiChip(
|
UiChip(
|
||||||
label: statusText,
|
label: statusText,
|
||||||
size: UiChipSize.xSmall,
|
size: UiChipSize.xSmall,
|
||||||
variant: item.verificationStatus == 'SUCCESS'
|
variant:
|
||||||
|
item.verificationStatus ==
|
||||||
|
AttireVerificationStatus.success
|
||||||
? UiChipVariant.primary
|
? UiChipVariant.primary
|
||||||
: UiChipVariant.secondary,
|
: UiChipVariant.secondary,
|
||||||
),
|
),
|
||||||
@@ -112,10 +114,12 @@ class AttireItemCard extends StatelessWidget {
|
|||||||
)
|
)
|
||||||
else if (hasPhoto && !isUploading)
|
else if (hasPhoto && !isUploading)
|
||||||
Icon(
|
Icon(
|
||||||
item.verificationStatus == 'SUCCESS'
|
item.verificationStatus == AttireVerificationStatus.success
|
||||||
? UiIcons.check
|
? UiIcons.check
|
||||||
: UiIcons.clock,
|
: UiIcons.clock,
|
||||||
color: item.verificationStatus == 'SUCCESS'
|
color:
|
||||||
|
item.verificationStatus ==
|
||||||
|
AttireVerificationStatus.success
|
||||||
? UiColors.textPrimary
|
? UiColors.textPrimary
|
||||||
: UiColors.textWarning,
|
: UiColors.textWarning,
|
||||||
size: 24,
|
size: 24,
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ dependencies:
|
|||||||
path: ../../../../../design_system
|
path: ../../../../../design_system
|
||||||
core_localization:
|
core_localization:
|
||||||
path: ../../../../../core_localization
|
path: ../../../../../core_localization
|
||||||
|
image_picker: ^1.2.1
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|||||||
245
docs/MILESTONES/M4/planning/m4-core-api-frontend-guide.md
Normal file
245
docs/MILESTONES/M4/planning/m4-core-api-frontend-guide.md
Normal file
@@ -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 <firebase-id-token>
|
||||||
|
```
|
||||||
|
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/<uid>/173...",
|
||||||
|
"contentType": "application/pdf",
|
||||||
|
"size": 12345,
|
||||||
|
"bucket": "krow-workforce-dev-private",
|
||||||
|
"path": "uploads/<uid>/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/<uid>/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/<caller_uid>/...`)
|
||||||
|
- 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": "<worker-id>",
|
||||||
|
"fileUri": "gs://krow-workforce-dev-private/uploads/<uid>/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": "<uid>"
|
||||||
|
},
|
||||||
|
"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/<uid>/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`.
|
||||||
Reference in New Issue
Block a user