feat: Implement attire photo capture, update AttireItem entity, and streamline the photo upload and state management flow.
This commit is contained in:
@@ -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>(ImagePicker.new);
|
||||
|
||||
/// local services
|
||||
i.addLazySingleton<CameraService>(
|
||||
() => CameraService(i.get<ImagePicker>()),
|
||||
);
|
||||
|
||||
// Repository
|
||||
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_domain/krow_domain.dart';
|
||||
|
||||
@@ -31,16 +34,78 @@ class AttireRepositoryImpl implements AttireRepository {
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> 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<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(
|
||||
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<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();
|
||||
|
||||
/// 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.
|
||||
Future<void> saveAttire({
|
||||
|
||||
@@ -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<UploadAttirePhotoArguments, String> {
|
||||
extends UseCase<UploadAttirePhotoArguments, AttireItem> {
|
||||
/// Creates a [UploadAttirePhotoUseCase].
|
||||
UploadAttirePhotoUseCase(this._repository);
|
||||
final AttireRepository _repository;
|
||||
|
||||
@override
|
||||
Future<String> call(UploadAttirePhotoArguments arguments) {
|
||||
Future<AttireItem> call(UploadAttirePhotoArguments arguments) {
|
||||
return _repository.uploadPhoto(arguments.itemId, arguments.filePath);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,9 +64,21 @@ class AttireCubit extends Cubit<AttireState>
|
||||
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<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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<AttireCaptureState>
|
||||
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(
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -298,7 +298,7 @@ class _AttireCapturePageState extends State<AttireCapturePage> {
|
||||
fullWidth: true,
|
||||
text: 'Submit Image',
|
||||
onPressed: () {
|
||||
Modular.to.pop(currentPhotoUrl);
|
||||
Modular.to.pop(state.updatedItem);
|
||||
},
|
||||
),
|
||||
],
|
||||
|
||||
@@ -113,10 +113,10 @@ class _AttirePageState extends State<AttirePage> {
|
||||
isUploading: false,
|
||||
uploadedPhotoUrl: state.photoUrls[item.id],
|
||||
onTap: () async {
|
||||
final String? resultUrl =
|
||||
await Navigator.push<String?>(
|
||||
final AttireItem? updatedItem =
|
||||
await Navigator.push<AttireItem?>(
|
||||
context,
|
||||
MaterialPageRoute<String?>(
|
||||
MaterialPageRoute<AttireItem?>(
|
||||
builder: (BuildContext ctx) =>
|
||||
AttireCapturePage(
|
||||
item: item,
|
||||
@@ -126,8 +126,8 @@ class _AttirePageState extends State<AttirePage> {
|
||||
),
|
||||
);
|
||||
|
||||
if (resultUrl != null && mounted) {
|
||||
cubit.syncCapturedPhoto(item.id, resultUrl);
|
||||
if (updatedItem != null && mounted) {
|
||||
cubit.syncCapturedPhoto(updatedItem);
|
||||
}
|
||||
},
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -27,6 +27,7 @@ dependencies:
|
||||
path: ../../../../../design_system
|
||||
core_localization:
|
||||
path: ../../../../../core_localization
|
||||
image_picker: ^1.2.1
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
||||
Reference in New Issue
Block a user