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

@@ -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>(ImagePicker.new);
i.addLazySingleton<CameraService>(
() => CameraService(i.get<ImagePicker>()),
);
i.addLazySingleton<GalleryService>(
() => GalleryService(i.get<ImagePicker>()),
);
i.addLazySingleton<FilePickerService>(FilePickerService.new);
}
@override
List<Module> get imports => <Module>[
CoreModule(),
core_localization.LocalizationModule(),
staff_authentication.StaffAuthenticationModule(),
];

View File

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

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

View File

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

View File

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

View File

@@ -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<Object?> get props => <Object?>[
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,
);
}
}

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: