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

@@ -11,9 +11,8 @@ class StaffConnectorRepositoryImpl implements StaffConnectorRepository {
/// Creates a new [StaffConnectorRepositoryImpl].
///
/// Requires a [DataConnectService] instance for backend communication.
StaffConnectorRepositoryImpl({
DataConnectService? service,
}) : _service = service ?? DataConnectService.instance;
StaffConnectorRepositoryImpl({DataConnectService? service})
: _service = service ?? DataConnectService.instance;
final DataConnectService _service;
@@ -22,15 +21,17 @@ class StaffConnectorRepositoryImpl implements StaffConnectorRepository {
return _service.run(() async {
final String staffId = await _service.getStaffId();
final QueryResult<GetStaffProfileCompletionData,
GetStaffProfileCompletionVariables> response =
await _service.connector
final QueryResult<
GetStaffProfileCompletionData,
GetStaffProfileCompletionVariables
>
response = await _service.connector
.getStaffProfileCompletion(id: staffId)
.execute();
final GetStaffProfileCompletionStaff? staff = response.data.staff;
final List<GetStaffProfileCompletionEmergencyContacts>
emergencyContacts = response.data.emergencyContacts;
final List<GetStaffProfileCompletionEmergencyContacts> emergencyContacts =
response.data.emergencyContacts;
final List<GetStaffProfileCompletionTaxForms> taxForms =
response.data.taxForms;
@@ -43,9 +44,11 @@ class StaffConnectorRepositoryImpl implements StaffConnectorRepository {
return _service.run(() async {
final String staffId = await _service.getStaffId();
final QueryResult<GetStaffPersonalInfoCompletionData,
GetStaffPersonalInfoCompletionVariables> response =
await _service.connector
final QueryResult<
GetStaffPersonalInfoCompletionData,
GetStaffPersonalInfoCompletionVariables
>
response = await _service.connector
.getStaffPersonalInfoCompletion(id: staffId)
.execute();
@@ -60,9 +63,11 @@ class StaffConnectorRepositoryImpl implements StaffConnectorRepository {
return _service.run(() async {
final String staffId = await _service.getStaffId();
final QueryResult<GetStaffEmergencyProfileCompletionData,
GetStaffEmergencyProfileCompletionVariables> response =
await _service.connector
final QueryResult<
GetStaffEmergencyProfileCompletionData,
GetStaffEmergencyProfileCompletionVariables
>
response = await _service.connector
.getStaffEmergencyProfileCompletion(id: staffId)
.execute();
@@ -75,9 +80,11 @@ class StaffConnectorRepositoryImpl implements StaffConnectorRepository {
return _service.run(() async {
final String staffId = await _service.getStaffId();
final QueryResult<GetStaffExperienceProfileCompletionData,
GetStaffExperienceProfileCompletionVariables> response =
await _service.connector
final QueryResult<
GetStaffExperienceProfileCompletionData,
GetStaffExperienceProfileCompletionVariables
>
response = await _service.connector
.getStaffExperienceProfileCompletion(id: staffId)
.execute();
@@ -93,9 +100,11 @@ class StaffConnectorRepositoryImpl implements StaffConnectorRepository {
return _service.run(() async {
final String staffId = await _service.getStaffId();
final QueryResult<GetStaffTaxFormsProfileCompletionData,
GetStaffTaxFormsProfileCompletionVariables> response =
await _service.connector
final QueryResult<
GetStaffTaxFormsProfileCompletionData,
GetStaffTaxFormsProfileCompletionVariables
>
response = await _service.connector
.getStaffTaxFormsProfileCompletion(id: staffId)
.execute();
@@ -135,9 +144,7 @@ class StaffConnectorRepositoryImpl implements StaffConnectorRepository {
final bool hasExperience =
(skills is List && skills.isNotEmpty) ||
(industries is List && industries.isNotEmpty);
return emergencyContacts.isNotEmpty &&
taxForms.isNotEmpty &&
hasExperience;
return emergencyContacts.isNotEmpty && taxForms.isNotEmpty && hasExperience;
}
@override
@@ -146,14 +153,10 @@ class StaffConnectorRepositoryImpl implements StaffConnectorRepository {
final String staffId = await _service.getStaffId();
final QueryResult<GetStaffByIdData, GetStaffByIdVariables> response =
await _service.connector
.getStaffById(id: staffId)
.execute();
await _service.connector.getStaffById(id: staffId).execute();
if (response.data.staff == null) {
throw const ServerException(
technicalMessage: 'Staff not found',
);
throw const ServerException(technicalMessage: 'Staff not found');
}
final GetStaffByIdStaff rawStaff = response.data.staff!;
@@ -183,9 +186,11 @@ class StaffConnectorRepositoryImpl implements StaffConnectorRepository {
return _service.run(() async {
final String staffId = await _service.getStaffId();
final QueryResult<ListBenefitsDataByStaffIdData,
ListBenefitsDataByStaffIdVariables> response =
await _service.connector
final QueryResult<
ListBenefitsDataByStaffIdData,
ListBenefitsDataByStaffIdVariables
>
response = await _service.connector
.listBenefitsDataByStaffId(staffId: staffId)
.execute();
@@ -200,6 +205,56 @@ class StaffConnectorRepositoryImpl implements StaffConnectorRepository {
});
}
@override
Future<List<AttireItem>> getAttireOptions() async {
return _service.run(() async {
final String staffId = await _service.getStaffId();
// Fetch all options
final QueryResult<ListAttireOptionsData, void> optionsResponse =
await _service.connector.listAttireOptions().execute();
// Fetch user's attire status
final QueryResult<GetStaffAttireData, GetStaffAttireVariables>
attiresResponse = await _service.connector
.getStaffAttire(staffId: staffId)
.execute();
final Map<String, GetStaffAttireStaffAttires> attireMap = {
for (final item in attiresResponse.data.staffAttires)
item.attireOptionId: item,
};
return optionsResponse.data.attireOptions.map((e) {
final GetStaffAttireStaffAttires? userAttire = attireMap[e.id];
return AttireItem(
id: e.itemId,
label: e.label,
description: e.description,
imageUrl: e.imageUrl,
isMandatory: e.isMandatory ?? false,
verificationStatus: userAttire?.verificationStatus?.stringValue,
photoUrl: userAttire?.verificationPhotoUrl,
);
}).toList();
});
}
@override
Future<void> upsertStaffAttire({
required String attireOptionId,
required String photoUrl,
}) async {
await _service.run(() async {
final String staffId = await _service.getStaffId();
await _service.connector
.upsertStaffAttire(staffId: staffId, attireOptionId: attireOptionId)
.verificationPhotoUrl(photoUrl)
.execute();
});
}
@override
Future<void> signOut() async {
try {
@@ -210,4 +265,3 @@ class StaffConnectorRepositoryImpl implements StaffConnectorRepository {
}
}
}

View File

@@ -45,6 +45,17 @@ abstract interface class StaffConnectorRepository {
/// Returns a list of [Benefit] entities.
Future<List<Benefit>> getBenefits();
/// Fetches the attire options for the current authenticated user.
///
/// Returns a list of [AttireItem] entities.
Future<List<AttireItem>> getAttireOptions();
/// Upserts staff attire photo information.
Future<void> upsertStaffAttire({
required String attireOptionId,
required String photoUrl,
});
/// Signs out the current user.
///
/// Clears the user's session and authentication state.

View File

@@ -11,6 +11,8 @@ class AttireItem extends Equatable {
this.description,
this.imageUrl,
this.isMandatory = false,
this.verificationStatus,
this.photoUrl,
});
/// Unique identifier of the attire item.
@@ -28,6 +30,12 @@ class AttireItem extends Equatable {
/// Whether this item is mandatory for onboarding.
final bool isMandatory;
/// The current verification status of the uploaded photo.
final String? verificationStatus;
/// The URL of the photo uploaded by the staff member.
final String? photoUrl;
@override
List<Object?> get props => <Object?>[
id,
@@ -35,5 +43,7 @@ class AttireItem extends Equatable {
description,
imageUrl,
isMandatory,
verificationStatus,
photoUrl,
];
}

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
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 = hasPhoto
: 'Not Uploaded');
final Color statusColor =
widget.item.verificationStatus == 'SUCCESS'
? UiColors.textPrimary
: (hasUploadedPhoto
? UiColors.textWarning
: UiColors.textInactive;
: 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,
),
],
const SizedBox(height: UiConstants.space6),
if (widget.item.description != null)
Text(
widget.item.description ?? '',
widget.item.description!,
style: UiTypography.body1r.textSecondary,
textAlign: TextAlign.center,
),
const SizedBox(height: UiConstants.space6),
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,6 +80,29 @@ class _AttirePageState extends State<AttirePage> {
const SizedBox(height: UiConstants.space6),
// Item List
if (filteredOptions.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(
vertical: UiConstants.space10,
),
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(

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,
),
],

View File

@@ -0,0 +1,14 @@
mutation upsertStaffAttire(
$staffId: UUID!
$attireOptionId: UUID!
$verificationPhotoUrl: String
) @auth(level: USER) {
staffAttire_upsert(
data: {
staffId: $staffId
attireOptionId: $attireOptionId
verificationPhotoUrl: $verificationPhotoUrl
verificationStatus: PENDING
}
)
}

View File

@@ -0,0 +1,7 @@
query getStaffAttire($staffId: UUID!) @auth(level: USER) {
staffAttires(where: { staffId: { eq: $staffId } }) {
attireOptionId
verificationStatus
verificationPhotoUrl
}
}

View File

@@ -1771,7 +1771,6 @@ mutation seedAll @transaction {
}
)
mutation seedAttireOptions @transaction {
# Attire Options (Required)
attire_1: attireOption_insert(
data: {
@@ -1930,5 +1929,4 @@ mutation seedAll @transaction {
}
)
}
}
#v.3

View File

@@ -12,7 +12,7 @@ type StaffAttire @table(name: "staff_attires", key: ["staffId", "attireOptionId"
attireOption: AttireOption! @ref(fields: "attireOptionId", references: "id")
# Verification Metadata
verificationStatus: AttireVerificationStatus @default(expr: "PENDING")
verificationStatus: AttireVerificationStatus @default(expr: "'PENDING'")
verifiedAt: Timestamp
verificationPhotoUrl: String # Proof of ownership