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: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 'domain/repositories/attire_repository.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 'presentation/blocs/attire_cubit.dart';
import 'presentation/pages/attire_page.dart';
class StaffAttireModule extends Module {
@@ -19,9 +20,10 @@ class StaffAttireModule extends Module {
i.addLazySingleton(GetAttireOptionsUseCase.new);
i.addLazySingleton(SaveAttireUseCase.new);
i.addLazySingleton(UploadAttirePhotoUseCase.new);
// BLoC
i.addLazySingleton(AttireCubit.new);
i.add(AttireCaptureCubit.new);
}
@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 }
class AttireState extends Equatable {
const AttireState({
this.status = AttireStatus.initial,
this.options = const <AttireItem>[],
this.selectedIds = const <String>[],
this.photoUrls = const <String, String>{},
this.uploadingStatus = const <String, bool>{},
this.attestationChecked = false,
this.errorMessage,
});
final AttireStatus status;
final List<AttireItem> options;
final List<String> selectedIds;
final Map<String, String> photoUrls;
final Map<String, bool> uploadingStatus;
final bool attestationChecked;
final String? errorMessage;
bool get uploading => uploadingStatus.values.any((bool u) => u);
/// Helper to check if item is mandatory
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
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));
}
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));
}
bool get canSave => allMandatorySelected && allMandatoryHavePhotos && attestationChecked && !uploading;
bool get canSave => allMandatorySelected && allMandatoryHavePhotos;
AttireState copyWith({
AttireStatus? status,
List<AttireItem>? options,
List<String>? selectedIds,
Map<String, String>? photoUrls,
Map<String, bool>? uploadingStatus,
bool? attestationChecked,
String? errorMessage,
}) {
return AttireState(
@@ -56,20 +56,16 @@ class AttireState extends Equatable {
options: options ?? this.options,
selectedIds: selectedIds ?? this.selectedIds,
photoUrls: photoUrls ?? this.photoUrls,
uploadingStatus: uploadingStatus ?? this.uploadingStatus,
attestationChecked: attestationChecked ?? this.attestationChecked,
errorMessage: errorMessage,
);
}
@override
List<Object?> get props => <Object?>[
status,
options,
selectedIds,
photoUrls,
uploadingStatus,
attestationChecked,
errorMessage
];
status,
options,
selectedIds,
photoUrls,
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:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:krow_domain/krow_domain.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/attire_capture_page/attire_image_preview.dart';
import '../widgets/attire_capture_page/attire_upload_buttons.dart';
import '../widgets/attire_capture_page/attire_verification_status_card.dart';
class AttireCapturePage extends StatefulWidget {
const AttireCapturePage({super.key, required this.item});
const AttireCapturePage({
super.key,
required this.item,
this.initialPhotoUrl,
});
final AttireItem item;
final String? initialPhotoUrl;
@override
State<AttireCapturePage> createState() => _AttireCapturePageState();
}
class _AttireCapturePageState extends State<AttireCapturePage> {
bool _isAttested = false;
void _onUpload(BuildContext context) {
if (!_isAttested) {
final AttireCaptureCubit cubit = BlocProvider.of<AttireCaptureCubit>(
context,
);
if (!cubit.state.isAttested) {
UiSnackbar.show(
context,
message: 'Please attest that you own this item.',
@@ -35,100 +41,106 @@ class _AttireCapturePageState extends State<AttireCapturePage> {
return;
}
// Call the upload via cubit
final AttireCubit cubit = Modular.get<AttireCubit>();
cubit.uploadPhoto(widget.item.id);
}
@override
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(
backgroundColor: UiColors.background,
appBar: UiAppBar(title: widget.item.label, showBackButton: true),
body: BlocConsumer<AttireCubit, AttireState>(
bloc: cubit,
listener: (BuildContext context, AttireState state) {
if (state.status == AttireStatus.failure) {
UiSnackbar.show(
context,
message: translateErrorKey(state.errorMessage ?? 'Error'),
type: UiSnackbarType.error,
);
}
},
builder: (BuildContext context, AttireState state) {
final bool isUploading =
state.uploadingStatus[widget.item.id] ?? false;
final bool hasPhoto = state.photoUrls.containsKey(widget.item.id);
final String statusText = hasPhoto
? 'Pending Verification'
: 'Not Uploaded';
final Color statusColor = hasPhoto
? UiColors.textWarning
: UiColors.textInactive;
return Scaffold(
backgroundColor: UiColors.background,
appBar: UiAppBar(title: widget.item.label, showBackButton: true),
body: BlocConsumer<AttireCaptureCubit, AttireCaptureState>(
bloc: cubit,
listener: (BuildContext context, AttireCaptureState state) {
if (state.status == AttireCaptureStatus.failure) {
UiSnackbar.show(
context,
message: translateErrorKey(state.errorMessage ?? 'Error'),
type: UiSnackbarType.error,
);
}
},
builder: (BuildContext context, AttireCaptureState state) {
final bool isUploading =
state.status == AttireCaptureStatus.uploading;
final bool hasPhoto =
state.photoUrl != null || widget.initialPhotoUrl != null;
final String statusText = hasPhoto
? 'Pending Verification'
: 'Not Uploaded';
final Color statusColor = hasPhoto
? UiColors.textWarning
: UiColors.textInactive;
return Column(
children: <Widget>[
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.all(UiConstants.space5),
child: Column(
children: <Widget>[
// Image Preview
// Image Preview
AttireImagePreview(imageUrl: widget.item.imageUrl),
const SizedBox(height: UiConstants.space6),
return Column(
children: <Widget>[
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.all(UiConstants.space5),
child: Column(
children: <Widget>[
// Image Preview
AttireImagePreview(imageUrl: widget.item.imageUrl),
const SizedBox(height: UiConstants.space6),
Text(
widget.item.description ?? '',
style: UiTypography.body1r.textSecondary,
textAlign: TextAlign.center,
),
const SizedBox(height: UiConstants.space6),
Text(
widget.item.description ?? '',
style: UiTypography.body1r.textSecondary,
textAlign: TextAlign.center,
),
const SizedBox(height: UiConstants.space6),
// Verification info
AttireVerificationStatusCard(
statusText: statusText,
statusColor: statusColor,
),
const SizedBox(height: UiConstants.space6),
// Verification info
AttireVerificationStatusCard(
statusText: statusText,
statusColor: statusColor,
),
const SizedBox(height: UiConstants.space6),
AttestationCheckbox(
isChecked: _isAttested,
onChanged: (bool? val) {
setState(() {
_isAttested = val ?? false;
});
},
),
const SizedBox(height: UiConstants.space6),
AttestationCheckbox(
isChecked: state.isAttested,
onChanged: (bool? val) {
cubit.toggleAttestation(val ?? false);
},
),
const SizedBox(height: UiConstants.space6),
if (isUploading)
const Center(child: CircularProgressIndicator())
else if (!hasPhoto ||
true) // Show options even if has photo (allows re-upload)
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 (isUploading)
const Center(child: CircularProgressIndicator())
else if (!hasPhoto ||
true) // Show options even if has photo (allows re-upload)
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(state.photoUrl);
},
),
),
),
),
],
);
},
),
);
},
),

View File

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