feat: Extract attire photo capture logic into AttireCaptureCubit and reorganize existing attire BLoC into a dedicated subdirectory.

This commit is contained in:
Achintha Isuru
2026-02-24 16:19:59 -05:00
parent bb27e3f8fe
commit 9bc4778cc1
8 changed files with 325 additions and 286 deletions

View File

@@ -1,12 +1,13 @@
import 'package:flutter_modular/flutter_modular.dart'; import 'package:flutter_modular/flutter_modular.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_capture/attire_capture_cubit.dart';
import 'data/repositories_impl/attire_repository_impl.dart'; import 'data/repositories_impl/attire_repository_impl.dart';
import 'domain/repositories/attire_repository.dart'; import 'domain/repositories/attire_repository.dart';
import 'domain/usecases/get_attire_options_usecase.dart'; import 'domain/usecases/get_attire_options_usecase.dart';
import 'domain/usecases/save_attire_usecase.dart'; import 'domain/usecases/save_attire_usecase.dart';
import 'domain/usecases/upload_attire_photo_usecase.dart'; import 'domain/usecases/upload_attire_photo_usecase.dart';
import 'presentation/blocs/attire_cubit.dart';
import 'presentation/pages/attire_page.dart'; import 'presentation/pages/attire_page.dart';
class StaffAttireModule extends Module { class StaffAttireModule extends Module {
@@ -22,6 +23,7 @@ class StaffAttireModule extends Module {
// BLoC // BLoC
i.addLazySingleton(AttireCubit.new); i.addLazySingleton(AttireCubit.new);
i.add(AttireCaptureCubit.new);
} }
@override @override

View File

@@ -0,0 +1,103 @@
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/save_attire_arguments.dart';
import 'package:staff_attire/src/domain/usecases/get_attire_options_usecase.dart';
import 'package:staff_attire/src/domain/usecases/save_attire_usecase.dart';
import 'attire_state.dart';
class AttireCubit extends Cubit<AttireState>
with BlocErrorHandler<AttireState> {
AttireCubit(this._getAttireOptionsUseCase, this._saveAttireUseCase)
: super(const AttireState()) {
loadOptions();
}
final GetAttireOptionsUseCase _getAttireOptionsUseCase;
final SaveAttireUseCase _saveAttireUseCase;
Future<void> loadOptions() async {
emit(state.copyWith(status: AttireStatus.loading));
await handleError(
emit: emit,
action: () async {
final List<AttireItem> options = await _getAttireOptionsUseCase();
// Auto-select mandatory items initially as per prototype
final List<String> mandatoryIds = options
.where((AttireItem e) => e.isMandatory)
.map((AttireItem e) => e.id)
.toList();
final List<String> initialSelection = List<String>.from(
state.selectedIds,
);
for (final String id in mandatoryIds) {
if (!initialSelection.contains(id)) {
initialSelection.add(id);
}
}
emit(
state.copyWith(
status: AttireStatus.success,
options: options,
selectedIds: initialSelection,
),
);
},
onError: (String errorKey) =>
state.copyWith(status: AttireStatus.failure, errorMessage: errorKey),
);
}
void toggleSelection(String id) {
// Prevent unselecting mandatory items
if (state.isMandatory(id)) return;
final List<String> currentSelection = List<String>.from(state.selectedIds);
if (currentSelection.contains(id)) {
currentSelection.remove(id);
} else {
currentSelection.add(id);
}
emit(state.copyWith(selectedIds: currentSelection));
}
void syncCapturedPhoto(String itemId, String url) {
final Map<String, String> currentPhotos = Map<String, String>.from(
state.photoUrls,
);
currentPhotos[itemId] = url;
// Auto-select item on upload success if not selected
final List<String> currentSelection = List<String>.from(state.selectedIds);
if (!currentSelection.contains(itemId)) {
currentSelection.add(itemId);
}
emit(
state.copyWith(photoUrls: currentPhotos, selectedIds: currentSelection),
);
}
Future<void> save() async {
if (!state.canSave) return;
emit(state.copyWith(status: AttireStatus.saving));
await handleError(
emit: emit,
action: () async {
await _saveAttireUseCase(
SaveAttireArguments(
selectedItemIds: state.selectedIds,
photoUrls: state.photoUrls,
),
);
emit(state.copyWith(status: AttireStatus.saved));
},
onError: (String errorKey) =>
state.copyWith(status: AttireStatus.failure, errorMessage: errorKey),
);
}
}

View File

@@ -4,51 +4,51 @@ import 'package:krow_domain/krow_domain.dart';
enum AttireStatus { initial, loading, success, failure, saving, saved } enum AttireStatus { initial, loading, success, failure, saving, saved }
class AttireState extends Equatable { class AttireState extends Equatable {
const AttireState({ const AttireState({
this.status = AttireStatus.initial, this.status = AttireStatus.initial,
this.options = const <AttireItem>[], this.options = const <AttireItem>[],
this.selectedIds = const <String>[], this.selectedIds = const <String>[],
this.photoUrls = const <String, String>{}, this.photoUrls = const <String, String>{},
this.uploadingStatus = const <String, bool>{},
this.attestationChecked = false,
this.errorMessage, this.errorMessage,
}); });
final AttireStatus status; final AttireStatus status;
final List<AttireItem> options; final List<AttireItem> options;
final List<String> selectedIds; final List<String> selectedIds;
final Map<String, String> photoUrls; final Map<String, String> photoUrls;
final Map<String, bool> uploadingStatus;
final bool attestationChecked;
final String? errorMessage; final String? errorMessage;
bool get uploading => uploadingStatus.values.any((bool u) => u);
/// Helper to check if item is mandatory /// Helper to check if item is mandatory
bool isMandatory(String id) { bool isMandatory(String id) {
return options.firstWhere((AttireItem e) => e.id == id, orElse: () => const AttireItem(id: '', label: '')).isMandatory; return options
.firstWhere(
(AttireItem e) => e.id == id,
orElse: () => const AttireItem(id: '', label: ''),
)
.isMandatory;
} }
/// Validation logic /// Validation logic
bool get allMandatorySelected { bool get allMandatorySelected {
final Iterable<String> mandatoryIds = options.where((AttireItem e) => e.isMandatory).map((AttireItem e) => e.id); final Iterable<String> mandatoryIds = options
.where((AttireItem e) => e.isMandatory)
.map((AttireItem e) => e.id);
return mandatoryIds.every((String id) => selectedIds.contains(id)); return mandatoryIds.every((String id) => selectedIds.contains(id));
} }
bool get allMandatoryHavePhotos { bool get allMandatoryHavePhotos {
final Iterable<String> mandatoryIds = options.where((AttireItem e) => e.isMandatory).map((AttireItem e) => e.id); final Iterable<String> mandatoryIds = options
.where((AttireItem e) => e.isMandatory)
.map((AttireItem e) => e.id);
return mandatoryIds.every((String id) => photoUrls.containsKey(id)); return mandatoryIds.every((String id) => photoUrls.containsKey(id));
} }
bool get canSave => allMandatorySelected && allMandatoryHavePhotos && attestationChecked && !uploading; bool get canSave => allMandatorySelected && allMandatoryHavePhotos;
AttireState copyWith({ AttireState copyWith({
AttireStatus? status, AttireStatus? status,
List<AttireItem>? options, List<AttireItem>? options,
List<String>? selectedIds, List<String>? selectedIds,
Map<String, String>? photoUrls, Map<String, String>? photoUrls,
Map<String, bool>? uploadingStatus,
bool? attestationChecked,
String? errorMessage, String? errorMessage,
}) { }) {
return AttireState( return AttireState(
@@ -56,20 +56,16 @@ class AttireState extends Equatable {
options: options ?? this.options, options: options ?? this.options,
selectedIds: selectedIds ?? this.selectedIds, selectedIds: selectedIds ?? this.selectedIds,
photoUrls: photoUrls ?? this.photoUrls, photoUrls: photoUrls ?? this.photoUrls,
uploadingStatus: uploadingStatus ?? this.uploadingStatus,
attestationChecked: attestationChecked ?? this.attestationChecked,
errorMessage: errorMessage, errorMessage: errorMessage,
); );
} }
@override @override
List<Object?> get props => <Object?>[ List<Object?> get props => <Object?>[
status, status,
options, options,
selectedIds, selectedIds,
photoUrls, photoUrls,
uploadingStatus, errorMessage,
attestationChecked, ];
errorMessage
];
} }

View File

@@ -0,0 +1,39 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:krow_core/core.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 'attire_capture_state.dart';
class AttireCaptureCubit extends Cubit<AttireCaptureState>
with BlocErrorHandler<AttireCaptureState> {
AttireCaptureCubit(this._uploadAttirePhotoUseCase)
: super(const AttireCaptureState());
final UploadAttirePhotoUseCase _uploadAttirePhotoUseCase;
void toggleAttestation(bool value) {
emit(state.copyWith(isAttested: value));
}
Future<void> uploadPhoto(String itemId) async {
emit(state.copyWith(status: AttireCaptureStatus.uploading));
await handleError(
emit: emit,
action: () async {
final String url = await _uploadAttirePhotoUseCase(
UploadAttirePhotoArguments(itemId: itemId),
);
emit(
state.copyWith(status: AttireCaptureStatus.success, photoUrl: url),
);
},
onError: (String errorKey) => state.copyWith(
status: AttireCaptureStatus.failure,
errorMessage: errorKey,
),
);
}
}

View File

@@ -0,0 +1,39 @@
import 'package:equatable/equatable.dart';
enum AttireCaptureStatus { initial, uploading, success, failure }
class AttireCaptureState extends Equatable {
const AttireCaptureState({
this.status = AttireCaptureStatus.initial,
this.isAttested = false,
this.photoUrl,
this.errorMessage,
});
final AttireCaptureStatus status;
final bool isAttested;
final String? photoUrl;
final String? errorMessage;
AttireCaptureState copyWith({
AttireCaptureStatus? status,
bool? isAttested,
String? photoUrl,
String? errorMessage,
}) {
return AttireCaptureState(
status: status ?? this.status,
isAttested: isAttested ?? this.isAttested,
photoUrl: photoUrl ?? this.photoUrl,
errorMessage: errorMessage,
);
}
@override
List<Object?> get props => <Object?>[
status,
isAttested,
photoUrl,
errorMessage,
];
}

View File

@@ -1,160 +0,0 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import '../../domain/arguments/save_attire_arguments.dart';
import '../../domain/arguments/upload_attire_photo_arguments.dart';
import '../../domain/usecases/get_attire_options_usecase.dart';
import '../../domain/usecases/save_attire_usecase.dart';
import '../../domain/usecases/upload_attire_photo_usecase.dart';
import 'attire_state.dart';
class AttireCubit extends Cubit<AttireState>
with BlocErrorHandler<AttireState> {
AttireCubit(
this._getAttireOptionsUseCase,
this._saveAttireUseCase,
this._uploadAttirePhotoUseCase,
) : super(const AttireState()) {
loadOptions();
}
final GetAttireOptionsUseCase _getAttireOptionsUseCase;
final SaveAttireUseCase _saveAttireUseCase;
final UploadAttirePhotoUseCase _uploadAttirePhotoUseCase;
Future<void> loadOptions() async {
emit(state.copyWith(status: AttireStatus.loading));
await handleError(
emit: emit,
action: () async {
final List<AttireItem> options = await _getAttireOptionsUseCase();
// Auto-select mandatory items initially as per prototype
final List<String> mandatoryIds =
options
.where((AttireItem e) => e.isMandatory)
.map((AttireItem e) => e.id)
.toList();
final List<String> initialSelection = List<String>.from(
state.selectedIds,
);
for (final String id in mandatoryIds) {
if (!initialSelection.contains(id)) {
initialSelection.add(id);
}
}
emit(
state.copyWith(
status: AttireStatus.success,
options: options,
selectedIds: initialSelection,
),
);
},
onError:
(String errorKey) => state.copyWith(
status: AttireStatus.failure,
errorMessage: errorKey,
),
);
}
void toggleSelection(String id) {
// Prevent unselecting mandatory items
if (state.isMandatory(id)) return;
final List<String> currentSelection = List<String>.from(state.selectedIds);
if (currentSelection.contains(id)) {
currentSelection.remove(id);
} else {
currentSelection.add(id);
}
emit(state.copyWith(selectedIds: currentSelection));
}
void toggleAttestation(bool value) {
emit(state.copyWith(attestationChecked: value));
}
Future<void> uploadPhoto(String itemId) async {
final Map<String, bool> currentUploading = Map<String, bool>.from(
state.uploadingStatus,
);
currentUploading[itemId] = true;
emit(state.copyWith(uploadingStatus: currentUploading));
await handleError(
emit: emit,
action: () async {
final String url = await _uploadAttirePhotoUseCase(
UploadAttirePhotoArguments(itemId: itemId),
);
final Map<String, String> currentPhotos = Map<String, String>.from(
state.photoUrls,
);
currentPhotos[itemId] = url;
// Auto-select item on upload success if not selected
final List<String> currentSelection = List<String>.from(
state.selectedIds,
);
if (!currentSelection.contains(itemId)) {
currentSelection.add(itemId);
}
final Map<String, bool> updatedUploading = Map<String, bool>.from(
state.uploadingStatus,
);
updatedUploading[itemId] = false;
emit(
state.copyWith(
uploadingStatus: updatedUploading,
photoUrls: currentPhotos,
selectedIds: currentSelection,
),
);
},
onError: (String errorKey) {
final Map<String, bool> updatedUploading = Map<String, bool>.from(
state.uploadingStatus,
);
updatedUploading[itemId] = false;
// Could handle error specifically via snackbar event
// For now, attaching the error message but keeping state generally usable
return state.copyWith(
uploadingStatus: updatedUploading,
errorMessage: errorKey,
);
},
);
}
Future<void> save() async {
if (!state.canSave) return;
emit(state.copyWith(status: AttireStatus.saving));
await handleError(
emit: emit,
action: () async {
await _saveAttireUseCase(
SaveAttireArguments(
selectedItemIds: state.selectedIds,
photoUrls: state.photoUrls,
),
);
emit(state.copyWith(status: AttireStatus.saved));
},
onError:
(String errorKey) => state.copyWith(
status: AttireStatus.failure,
errorMessage: errorKey,
),
);
}
}

View File

@@ -1,31 +1,37 @@
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart'; import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:krow_domain/krow_domain.dart';
import 'package:flutter_modular/flutter_modular.dart'; import 'package:flutter_modular/flutter_modular.dart';
import 'package:core_localization/core_localization.dart'; import 'package:krow_domain/krow_domain.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_state.dart';
import '../blocs/attire_cubit.dart';
import '../blocs/attire_state.dart';
import '../widgets/attestation_checkbox.dart'; import '../widgets/attestation_checkbox.dart';
import '../widgets/attire_capture_page/attire_image_preview.dart'; import '../widgets/attire_capture_page/attire_image_preview.dart';
import '../widgets/attire_capture_page/attire_upload_buttons.dart'; import '../widgets/attire_capture_page/attire_upload_buttons.dart';
import '../widgets/attire_capture_page/attire_verification_status_card.dart'; import '../widgets/attire_capture_page/attire_verification_status_card.dart';
class AttireCapturePage extends StatefulWidget { class AttireCapturePage extends StatefulWidget {
const AttireCapturePage({super.key, required this.item}); const AttireCapturePage({
super.key,
required this.item,
this.initialPhotoUrl,
});
final AttireItem item; final AttireItem item;
final String? initialPhotoUrl;
@override @override
State<AttireCapturePage> createState() => _AttireCapturePageState(); State<AttireCapturePage> createState() => _AttireCapturePageState();
} }
class _AttireCapturePageState extends State<AttireCapturePage> { class _AttireCapturePageState extends State<AttireCapturePage> {
bool _isAttested = false;
void _onUpload(BuildContext context) { void _onUpload(BuildContext context) {
if (!_isAttested) { final AttireCaptureCubit cubit = BlocProvider.of<AttireCaptureCubit>(
context,
);
if (!cubit.state.isAttested) {
UiSnackbar.show( UiSnackbar.show(
context, context,
message: 'Please attest that you own this item.', message: 'Please attest that you own this item.',
@@ -35,100 +41,106 @@ class _AttireCapturePageState extends State<AttireCapturePage> {
return; return;
} }
// Call the upload via cubit // Call the upload via cubit
final AttireCubit cubit = Modular.get<AttireCubit>();
cubit.uploadPhoto(widget.item.id); cubit.uploadPhoto(widget.item.id);
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final AttireCubit cubit = Modular.get<AttireCubit>(); return BlocProvider<AttireCaptureCubit>(
create: (_) => Modular.get<AttireCaptureCubit>(),
child: Builder(
builder: (BuildContext context) {
final AttireCaptureCubit cubit = BlocProvider.of<AttireCaptureCubit>(
context,
);
return Scaffold( return Scaffold(
backgroundColor: UiColors.background, backgroundColor: UiColors.background,
appBar: UiAppBar(title: widget.item.label, showBackButton: true), appBar: UiAppBar(title: widget.item.label, showBackButton: true),
body: BlocConsumer<AttireCubit, AttireState>( body: BlocConsumer<AttireCaptureCubit, AttireCaptureState>(
bloc: cubit, bloc: cubit,
listener: (BuildContext context, AttireState state) { listener: (BuildContext context, AttireCaptureState state) {
if (state.status == AttireStatus.failure) { if (state.status == AttireCaptureStatus.failure) {
UiSnackbar.show( UiSnackbar.show(
context, context,
message: translateErrorKey(state.errorMessage ?? 'Error'), message: translateErrorKey(state.errorMessage ?? 'Error'),
type: UiSnackbarType.error, type: UiSnackbarType.error,
); );
} }
}, },
builder: (BuildContext context, AttireState state) { builder: (BuildContext context, AttireCaptureState state) {
final bool isUploading = final bool isUploading =
state.uploadingStatus[widget.item.id] ?? false; state.status == AttireCaptureStatus.uploading;
final bool hasPhoto = state.photoUrls.containsKey(widget.item.id); final bool hasPhoto =
final String statusText = hasPhoto state.photoUrl != null || widget.initialPhotoUrl != null;
? 'Pending Verification' final String statusText = hasPhoto
: 'Not Uploaded'; ? 'Pending Verification'
final Color statusColor = hasPhoto : 'Not Uploaded';
? UiColors.textWarning final Color statusColor = hasPhoto
: UiColors.textInactive; ? UiColors.textWarning
: UiColors.textInactive;
return Column( return Column(
children: <Widget>[ children: <Widget>[
Expanded( Expanded(
child: SingleChildScrollView( child: SingleChildScrollView(
padding: const EdgeInsets.all(UiConstants.space5), padding: const EdgeInsets.all(UiConstants.space5),
child: Column( child: Column(
children: <Widget>[ children: <Widget>[
// Image Preview // Image Preview
// Image Preview AttireImagePreview(imageUrl: widget.item.imageUrl),
AttireImagePreview(imageUrl: widget.item.imageUrl), const SizedBox(height: UiConstants.space6),
const SizedBox(height: UiConstants.space6),
Text( Text(
widget.item.description ?? '', widget.item.description ?? '',
style: UiTypography.body1r.textSecondary, style: UiTypography.body1r.textSecondary,
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
const SizedBox(height: UiConstants.space6), const SizedBox(height: UiConstants.space6),
// Verification info // Verification info
AttireVerificationStatusCard( AttireVerificationStatusCard(
statusText: statusText, statusText: statusText,
statusColor: statusColor, statusColor: statusColor,
), ),
const SizedBox(height: UiConstants.space6), const SizedBox(height: UiConstants.space6),
AttestationCheckbox( AttestationCheckbox(
isChecked: _isAttested, isChecked: state.isAttested,
onChanged: (bool? val) { onChanged: (bool? val) {
setState(() { cubit.toggleAttestation(val ?? false);
_isAttested = val ?? false; },
}); ),
}, const SizedBox(height: UiConstants.space6),
),
const SizedBox(height: UiConstants.space6),
if (isUploading) if (isUploading)
const Center(child: CircularProgressIndicator()) const Center(child: CircularProgressIndicator())
else if (!hasPhoto || else if (!hasPhoto ||
true) // Show options even if has photo (allows re-upload) true) // Show options even if has photo (allows re-upload)
AttireUploadButtons(onUpload: _onUpload), AttireUploadButtons(onUpload: _onUpload),
], ],
), ),
),
),
if (hasPhoto)
SafeArea(
child: Padding(
padding: const EdgeInsets.all(UiConstants.space5),
child: SizedBox(
width: double.infinity,
child: UiButton.primary(
text: 'Submit Image',
onPressed: () {
Modular.to.pop();
},
), ),
), ),
), if (hasPhoto)
), SafeArea(
], child: Padding(
padding: const EdgeInsets.all(UiConstants.space5),
child: SizedBox(
width: double.infinity,
child: UiButton.primary(
text: 'Submit Image',
onPressed: () {
Modular.to.pop(state.photoUrl);
},
),
),
),
),
],
);
},
),
); );
}, },
), ),

View File

@@ -4,9 +4,9 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_modular/flutter_modular.dart'; import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_domain/krow_domain.dart'; import 'package:krow_domain/krow_domain.dart';
import 'package:staff_attire/src/presentation/blocs/attire/attire_cubit.dart';
import 'package:staff_attire/src/presentation/blocs/attire/attire_state.dart';
import '../blocs/attire_cubit.dart';
import '../blocs/attire_state.dart';
import '../widgets/attire_filter_chips.dart'; import '../widgets/attire_filter_chips.dart';
import '../widgets/attire_info_card.dart'; import '../widgets/attire_info_card.dart';
import '../widgets/attire_item_card.dart'; import '../widgets/attire_item_card.dart';
@@ -87,17 +87,25 @@ class _AttirePageState extends State<AttirePage> {
), ),
child: AttireItemCard( child: AttireItemCard(
item: item, item: item,
isUploading: isUploading: false,
state.uploadingStatus[item.id] ?? false,
uploadedPhotoUrl: state.photoUrls[item.id], uploadedPhotoUrl: state.photoUrls[item.id],
onTap: () { onTap: () async {
Navigator.push<void>( final String? resultUrl =
context, await Navigator.push<String?>(
MaterialPageRoute<void>( context,
builder: (BuildContext ctx) => MaterialPageRoute<String?>(
AttireCapturePage(item: item), builder: (BuildContext ctx) =>
), AttireCapturePage(
); item: item,
initialPhotoUrl:
state.photoUrls[item.id],
),
),
);
if (resultUrl != null && mounted) {
cubit.syncCapturedPhoto(item.id, resultUrl);
}
}, },
), ),
); );