feat: Implement staff attire management including fetching options, user attire status, and upserting attire details.

This commit is contained in:
Achintha Isuru
2026-02-24 17:16:52 -05:00
parent cb180af7cf
commit 616f23fec9
12 changed files with 310 additions and 165 deletions

View File

@@ -1,4 +1,3 @@
import 'package:firebase_data_connect/firebase_data_connect.dart';
import 'package:krow_data_connect/krow_data_connect.dart';
import 'package:krow_domain/krow_domain.dart';
@@ -6,34 +5,19 @@ import '../../domain/repositories/attire_repository.dart';
/// Implementation of [AttireRepository].
///
/// Delegates data access to [DataConnectService].
/// Delegates data access to [StaffConnectorRepository].
class AttireRepositoryImpl implements AttireRepository {
/// Creates an [AttireRepositoryImpl].
AttireRepositoryImpl({DataConnectService? service})
: _service = service ?? DataConnectService.instance;
AttireRepositoryImpl({StaffConnectorRepository? connector})
: _connector =
connector ?? DataConnectService.instance.getStaffRepository();
/// The Data Connect service.
final DataConnectService _service;
/// The Staff Connector repository.
final StaffConnectorRepository _connector;
@override
Future<List<AttireItem>> getAttireOptions() async {
return _service.run(() async {
final QueryResult<ListAttireOptionsData, void> result = await _service
.connector
.listAttireOptions()
.execute();
return result.data.attireOptions
.map(
(ListAttireOptionsAttireOptions e) => AttireItem(
id: e.itemId,
label: e.label,
description: e.description,
imageUrl: e.imageUrl,
isMandatory: e.isMandatory ?? false,
),
)
.toList();
});
return _connector.getAttireOptions();
}
@override
@@ -41,16 +25,22 @@ class AttireRepositoryImpl implements AttireRepository {
required List<String> selectedItemIds,
required Map<String, String> photoUrls,
}) async {
// TODO: Connect to actual backend mutation when available.
// For now, simulate network delay as per prototype behavior.
await Future<void>.delayed(const Duration(seconds: 1));
// We already upsert photos in uploadPhoto (to follow the new flow).
// This could save selections if there was a separate "SelectedAttire" table.
// For now, it's a no-op as the source of truth is the StaffAttire table.
}
@override
Future<String> uploadPhoto(String itemId) async {
// TODO: Connect to actual storage service/mutation when available.
// For now, simulate upload delay and return mock URL.
await Future<void>.delayed(const Duration(seconds: 1));
return 'mock_url_for_$itemId';
// 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,
);
return mockUrl;
}
}

View File

@@ -23,18 +23,17 @@ class AttireCubit extends Cubit<AttireState>
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();
// Extract photo URLs and selection status from backend data
final Map<String, String> photoUrls = <String, String>{};
final List<String> selectedIds = <String>[];
final List<String> initialSelection = List<String>.from(
state.selectedIds,
);
for (final String id in mandatoryIds) {
if (!initialSelection.contains(id)) {
initialSelection.add(id);
for (final AttireItem item in options) {
if (item.photoUrl != null) {
photoUrls[item.id] = item.photoUrl!;
}
// If mandatory or has photo, consider it selected initially
if (item.isMandatory || item.photoUrl != null) {
selectedIds.add(item.id);
}
}
@@ -42,7 +41,8 @@ class AttireCubit extends Cubit<AttireState>
state.copyWith(
status: AttireStatus.success,
options: options,
selectedIds: initialSelection,
selectedIds: selectedIds,
photoUrls: photoUrls,
),
);
},
@@ -65,20 +65,8 @@ class AttireCubit extends Cubit<AttireState>
}
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),
);
// When a photo is captured, we refresh the options to get the updated status from backend
loadOptions();
}
Future<void> save() async {

View File

@@ -70,14 +70,22 @@ class _AttireCapturePageState extends State<AttireCapturePage> {
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;
final String? currentPhotoUrl =
state.photoUrl ?? widget.initialPhotoUrl;
final bool hasUploadedPhoto = currentPhotoUrl != null;
final String statusText =
widget.item.verificationStatus ??
(hasUploadedPhoto
? 'Pending Verification'
: 'Not Uploaded');
final Color statusColor =
widget.item.verificationStatus == 'SUCCESS'
? UiColors.textPrimary
: (hasUploadedPhoto
? UiColors.textWarning
: UiColors.textInactive);
return Column(
children: <Widget>[
@@ -86,21 +94,54 @@ class _AttireCapturePageState extends State<AttireCapturePage> {
padding: const EdgeInsets.all(UiConstants.space5),
child: Column(
children: <Widget>[
// Image Preview
AttireImagePreview(imageUrl: widget.item.imageUrl),
const SizedBox(height: UiConstants.space6),
// Image Preview (Toggle between example and uploaded)
if (hasUploadedPhoto) ...<Widget>[
Text(
'Your Uploaded Photo',
style: UiTypography.body1b.textPrimary,
),
const SizedBox(height: UiConstants.space2),
AttireImagePreview(imageUrl: currentPhotoUrl),
const SizedBox(height: UiConstants.space4),
Text(
'Reference Example',
style: UiTypography.body2b.textSecondary,
),
const SizedBox(height: UiConstants.space1),
Center(
child: ClipRRect(
borderRadius: BorderRadius.circular(
UiConstants.radiusBase,
),
child: Image.network(
widget.item.imageUrl ?? '',
height: 120,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) =>
const SizedBox.shrink(),
),
),
),
] else ...<Widget>[
AttireImagePreview(
imageUrl: widget.item.imageUrl,
),
const SizedBox(height: UiConstants.space4),
Text(
'Example of the item that you need to upload.',
style: UiTypography.body1b.textSecondary,
textAlign: TextAlign.center,
),
],
Text(
'Example of the item that you need to upload.',
style: UiTypography.body1b.textSecondary,
textAlign: TextAlign.center,
),
Text(
widget.item.description ?? '',
style: UiTypography.body1r.textSecondary,
textAlign: TextAlign.center,
),
const SizedBox(height: UiConstants.space6),
if (widget.item.description != null)
Text(
widget.item.description!,
style: UiTypography.body1r.textSecondary,
textAlign: TextAlign.center,
),
const SizedBox(height: UiConstants.space8),
// Verification info
AttireVerificationStatusCard(
@@ -118,15 +159,19 @@ class _AttireCapturePageState extends State<AttireCapturePage> {
const SizedBox(height: UiConstants.space6),
if (isUploading)
const Center(child: CircularProgressIndicator())
else if (!hasPhoto ||
true) // Show options even if has photo (allows re-upload)
const Center(
child: Padding(
padding: EdgeInsets.all(UiConstants.space8),
child: CircularProgressIndicator(),
),
)
else
AttireUploadButtons(onUpload: _onUpload),
],
),
),
),
if (hasPhoto)
if (hasUploadedPhoto)
SafeArea(
child: Padding(
padding: const EdgeInsets.all(UiConstants.space5),
@@ -135,7 +180,7 @@ class _AttireCapturePageState extends State<AttireCapturePage> {
child: UiButton.primary(
text: 'Submit Image',
onPressed: () {
Modular.to.pop(state.photoUrl);
Modular.to.pop(currentPhotoUrl);
},
),
),

View File

@@ -80,36 +80,59 @@ class _AttirePageState extends State<AttirePage> {
const SizedBox(height: UiConstants.space6),
// Item List
...filteredOptions.map((AttireItem item) {
return Padding(
padding: const EdgeInsets.only(
bottom: UiConstants.space3,
if (filteredOptions.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(
vertical: UiConstants.space10,
),
child: AttireItemCard(
item: item,
isUploading: false,
uploadedPhotoUrl: state.photoUrls[item.id],
onTap: () async {
final String? resultUrl =
await Navigator.push<String?>(
context,
MaterialPageRoute<String?>(
builder: (BuildContext ctx) =>
AttireCapturePage(
item: item,
initialPhotoUrl:
state.photoUrls[item.id],
),
),
);
child: Center(
child: Column(
children: <Widget>[
const Icon(
UiIcons.shirt,
size: 48,
color: UiColors.iconInactive,
),
const SizedBox(height: UiConstants.space4),
Text(
'No items found for this filter.',
style: UiTypography.body1m.textSecondary,
),
],
),
),
)
else
...filteredOptions.map((AttireItem item) {
return Padding(
padding: const EdgeInsets.only(
bottom: UiConstants.space3,
),
child: AttireItemCard(
item: item,
isUploading: false,
uploadedPhotoUrl: state.photoUrls[item.id],
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);
}
},
),
);
}),
if (resultUrl != null && mounted) {
cubit.syncCapturedPhoto(item.id, resultUrl);
}
},
),
);
}),
const SizedBox(height: UiConstants.space20),
],
),

View File

@@ -18,9 +18,8 @@ class AttireItemCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
final bool hasPhoto = uploadedPhotoUrl != null;
final String statusText = hasPhoto ? 'Pending' : 'Not Uploaded';
final bool hasPhoto = item.photoUrl != null;
final String statusText = item.verificationStatus ?? 'Not Uploaded';
return GestureDetector(
onTap: onTap,
@@ -85,7 +84,9 @@ class AttireItemCard extends StatelessWidget {
UiChip(
label: statusText,
size: UiChipSize.xSmall,
variant: UiChipVariant.secondary,
variant: item.verificationStatus == 'SUCCESS'
? UiChipVariant.primary
: UiChipVariant.secondary,
),
],
),
@@ -105,9 +106,13 @@ class AttireItemCard extends StatelessWidget {
size: 24,
)
else if (hasPhoto && !isUploading)
const Icon(
UiIcons.check,
color: UiColors.textWarning,
Icon(
item.verificationStatus == 'SUCCESS'
? UiIcons.check
: UiIcons.clock,
color: item.verificationStatus == 'SUCCESS'
? UiColors.textPrimary
: UiColors.textWarning,
size: 24,
),
],