feat: Implement attire photo capture, update AttireItem entity, and streamline the photo upload and state management flow.

This commit is contained in:
Achintha Isuru
2026-02-25 13:56:35 -05:00
parent 0ad70a4a42
commit 9c9cdaca78
19 changed files with 475 additions and 54 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,
];
}

View File

@@ -298,7 +298,7 @@ class _AttireCapturePageState extends State<AttireCapturePage> {
fullWidth: true,
text: 'Submit Image',
onPressed: () {
Modular.to.pop(currentPhotoUrl);
Modular.to.pop(state.updatedItem);
},
),
],

View File

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

View File

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

View File

@@ -27,6 +27,7 @@ dependencies:
path: ../../../../../design_system
core_localization:
path: ../../../../../core_localization
image_picker: ^1.2.1
dev_dependencies:
flutter_test: