feat: Migrate staff profile features from Data Connect to V2 REST API
- Removed data_connect package from mobile pubspec.yaml. - Added documentation for V2 profile migration status and QA findings. - Implemented new session management with ClientSessionStore and StaffSessionStore. - Created V2SessionService for handling user sessions via the V2 API. - Developed use cases for cancelling late worker assignments and submitting worker reviews. - Added arguments and use cases for payment chart retrieval and profile completion checks. - Implemented repository interfaces and their implementations for staff main and profile features. - Ensured proper error handling and validation in use cases.
This commit is contained in:
@@ -1,137 +1,101 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_data_connect/krow_data_connect.dart';
|
||||
import 'package:krow_domain/krow_domain.dart' as domain;
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
import '../../domain/repositories/certificates_repository.dart';
|
||||
import 'package:staff_certificates/src/domain/repositories/certificates_repository.dart';
|
||||
|
||||
/// Implementation of [CertificatesRepository] using Data Connect.
|
||||
/// Implementation of [CertificatesRepository] using the V2 API for reads
|
||||
/// and core services for uploads/verification.
|
||||
///
|
||||
/// Replaces the previous Firebase Data Connect implementation.
|
||||
class CertificatesRepositoryImpl implements CertificatesRepository {
|
||||
/// Creates a [CertificatesRepositoryImpl].
|
||||
CertificatesRepositoryImpl({
|
||||
required BaseApiService apiService,
|
||||
required FileUploadService uploadService,
|
||||
required SignedUrlService signedUrlService,
|
||||
required VerificationService verificationService,
|
||||
}) : _service = DataConnectService.instance,
|
||||
_uploadService = uploadService,
|
||||
_signedUrlService = signedUrlService,
|
||||
_verificationService = verificationService;
|
||||
}) : _api = apiService,
|
||||
_uploadService = uploadService,
|
||||
_signedUrlService = signedUrlService,
|
||||
_verificationService = verificationService;
|
||||
|
||||
final DataConnectService _service;
|
||||
final BaseApiService _api;
|
||||
final FileUploadService _uploadService;
|
||||
final SignedUrlService _signedUrlService;
|
||||
final VerificationService _verificationService;
|
||||
|
||||
@override
|
||||
Future<List<domain.StaffCertificate>> getCertificates() async {
|
||||
return _service.getStaffRepository().getStaffCertificates();
|
||||
Future<List<StaffCertificate>> getCertificates() async {
|
||||
final ApiResponse response =
|
||||
await _api.get(V2ApiEndpoints.staffCertificates);
|
||||
final List<dynamic> items =
|
||||
response.data['certificates'] as List<dynamic>;
|
||||
return items
|
||||
.map((dynamic json) =>
|
||||
StaffCertificate.fromJson(json as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<domain.StaffCertificate> uploadCertificate({
|
||||
required domain.ComplianceType certificationType,
|
||||
Future<StaffCertificate> uploadCertificate({
|
||||
required String certificateType,
|
||||
required String name,
|
||||
required String filePath,
|
||||
DateTime? expiryDate,
|
||||
String? issuer,
|
||||
String? certificateNumber,
|
||||
}) async {
|
||||
return _service.run(() async {
|
||||
// Get existing certificate to check if file has changed
|
||||
final List<domain.StaffCertificate> existingCerts = await getCertificates();
|
||||
domain.StaffCertificate? existingCert;
|
||||
try {
|
||||
existingCert = existingCerts.firstWhere(
|
||||
(domain.StaffCertificate c) => c.certificationType == certificationType,
|
||||
);
|
||||
} catch (e) {
|
||||
// Certificate doesn't exist yet
|
||||
}
|
||||
// 1. Upload the file to cloud storage
|
||||
final FileUploadResponse uploadRes = await _uploadService.uploadFile(
|
||||
filePath: filePath,
|
||||
fileName:
|
||||
'staff_cert_${certificateType}_${DateTime.now().millisecondsSinceEpoch}.pdf',
|
||||
visibility: FileVisibility.private,
|
||||
);
|
||||
|
||||
String? signedUrl = existingCert?.certificateUrl;
|
||||
String? verificationId = existingCert?.verificationId;
|
||||
final bool fileChanged = existingCert == null || existingCert.certificateUrl != filePath;
|
||||
// 2. Generate a signed URL
|
||||
final SignedUrlResponse signedUrlRes =
|
||||
await _signedUrlService.createSignedUrl(fileUri: uploadRes.fileUri);
|
||||
|
||||
// Only upload and verify if file path has changed
|
||||
if (fileChanged) {
|
||||
// 1. Upload the file to cloud storage
|
||||
final FileUploadResponse uploadRes = await _uploadService.uploadFile(
|
||||
filePath: filePath,
|
||||
fileName:
|
||||
'staff_cert_${certificationType.name}_${DateTime.now().millisecondsSinceEpoch}.pdf',
|
||||
visibility: domain.FileVisibility.private,
|
||||
);
|
||||
// 3. Initiate verification
|
||||
final VerificationResponse verificationRes =
|
||||
await _verificationService.createVerification(
|
||||
fileUri: uploadRes.fileUri,
|
||||
type: 'certification',
|
||||
subjectType: 'worker',
|
||||
subjectId: certificateType,
|
||||
rules: <String, dynamic>{
|
||||
'certificateName': name,
|
||||
'certificateIssuer': issuer,
|
||||
'certificateNumber': certificateNumber,
|
||||
},
|
||||
);
|
||||
|
||||
// 2. Generate a signed URL for verification service to access the file
|
||||
final SignedUrlResponse signedUrlRes = await _signedUrlService.createSignedUrl(fileUri: uploadRes.fileUri);
|
||||
signedUrl = signedUrlRes.signedUrl;
|
||||
// 4. Save certificate via V2 API
|
||||
await _api.post(
|
||||
V2ApiEndpoints.staffCertificates,
|
||||
data: <String, dynamic>{
|
||||
'certificateType': certificateType,
|
||||
'name': name,
|
||||
'fileUri': signedUrlRes.signedUrl,
|
||||
'expiresAt': expiryDate?.toIso8601String(),
|
||||
'issuer': issuer,
|
||||
'certificateNumber': certificateNumber,
|
||||
'verificationId': verificationRes.verificationId,
|
||||
},
|
||||
);
|
||||
|
||||
// 3. Initiate verification
|
||||
final String staffId = await _service.getStaffId();
|
||||
final VerificationResponse verificationRes = await _verificationService
|
||||
.createVerification(
|
||||
fileUri: uploadRes.fileUri,
|
||||
type: 'certification',
|
||||
subjectType: 'worker',
|
||||
subjectId: staffId,
|
||||
rules: <String, dynamic>{
|
||||
'certificateName': name,
|
||||
'certificateIssuer': issuer,
|
||||
'certificateNumber': certificateNumber,
|
||||
},
|
||||
);
|
||||
verificationId = verificationRes.verificationId;
|
||||
}
|
||||
|
||||
// 4. Update/Create Certificate in Data Connect
|
||||
await _service.getStaffRepository().upsertStaffCertificate(
|
||||
certificationType: certificationType,
|
||||
name: name,
|
||||
status: existingCert?.status ?? domain.StaffCertificateStatus.pending,
|
||||
fileUrl: signedUrl,
|
||||
expiry: expiryDate,
|
||||
issuer: issuer,
|
||||
certificateNumber: certificateNumber,
|
||||
validationStatus: existingCert?.validationStatus ?? domain.StaffCertificateValidationStatus.pendingExpertReview,
|
||||
verificationId: verificationId,
|
||||
);
|
||||
|
||||
// 5. Return updated list or the specific certificate
|
||||
final List<domain.StaffCertificate> certificates =
|
||||
await getCertificates();
|
||||
return certificates.firstWhere(
|
||||
(domain.StaffCertificate c) => c.certificationType == certificationType,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> upsertCertificate({
|
||||
required domain.ComplianceType certificationType,
|
||||
required String name,
|
||||
required domain.StaffCertificateStatus status,
|
||||
String? fileUrl,
|
||||
DateTime? expiry,
|
||||
String? issuer,
|
||||
String? certificateNumber,
|
||||
domain.StaffCertificateValidationStatus? validationStatus,
|
||||
}) async {
|
||||
await _service.getStaffRepository().upsertStaffCertificate(
|
||||
certificationType: certificationType,
|
||||
name: name,
|
||||
status: status,
|
||||
fileUrl: fileUrl,
|
||||
expiry: expiry,
|
||||
issuer: issuer,
|
||||
certificateNumber: certificateNumber,
|
||||
validationStatus: validationStatus,
|
||||
// 5. Return updated list
|
||||
final List<StaffCertificate> certificates = await getCertificates();
|
||||
return certificates.firstWhere(
|
||||
(StaffCertificate c) => c.certificateType == certificateType,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deleteCertificate({
|
||||
required domain.ComplianceType certificationType,
|
||||
}) async {
|
||||
return _service.getStaffRepository().deleteStaffCertificate(
|
||||
certificationType: certificationType,
|
||||
Future<void> deleteCertificate({required String certificateId}) async {
|
||||
await _api.delete(
|
||||
V2ApiEndpoints.staffCertificateDelete(certificateId),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,17 +2,15 @@ import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
/// Interface for the certificates repository.
|
||||
///
|
||||
/// Responsible for fetching staff compliance certificates.
|
||||
/// Implementations must reside in the data layer.
|
||||
/// Responsible for fetching, uploading, and deleting staff certificates
|
||||
/// via the V2 API. Uses [StaffCertificate] from the V2 domain.
|
||||
abstract interface class CertificatesRepository {
|
||||
/// Fetches the list of compliance certificates for the current staff member.
|
||||
///
|
||||
/// Returns a list of [StaffCertificate] entities.
|
||||
/// Fetches the list of certificates for the current staff member.
|
||||
Future<List<StaffCertificate>> getCertificates();
|
||||
|
||||
/// Uploads a certificate file and saves the record.
|
||||
Future<StaffCertificate> uploadCertificate({
|
||||
required ComplianceType certificationType,
|
||||
required String certificateType,
|
||||
required String name,
|
||||
required String filePath,
|
||||
DateTime? expiryDate,
|
||||
@@ -20,18 +18,6 @@ abstract interface class CertificatesRepository {
|
||||
String? certificateNumber,
|
||||
});
|
||||
|
||||
/// Deletes a staff certificate.
|
||||
Future<void> deleteCertificate({required ComplianceType certificationType});
|
||||
|
||||
/// Upserts a certificate record (metadata only).
|
||||
Future<void> upsertCertificate({
|
||||
required ComplianceType certificationType,
|
||||
required String name,
|
||||
required StaffCertificateStatus status,
|
||||
String? fileUrl,
|
||||
DateTime? expiry,
|
||||
String? issuer,
|
||||
String? certificateNumber,
|
||||
StaffCertificateValidationStatus? validationStatus,
|
||||
});
|
||||
/// Deletes a staff certificate by its [certificateId].
|
||||
Future<void> deleteCertificate({required String certificateId});
|
||||
}
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import '../repositories/certificates_repository.dart';
|
||||
|
||||
/// Use case for deleting a staff compliance certificate.
|
||||
class DeleteCertificateUseCase extends UseCase<ComplianceType, void> {
|
||||
class DeleteCertificateUseCase extends UseCase<String, void> {
|
||||
/// Creates a [DeleteCertificateUseCase].
|
||||
DeleteCertificateUseCase(this._repository);
|
||||
final CertificatesRepository _repository;
|
||||
|
||||
@override
|
||||
Future<void> call(ComplianceType certificationType) {
|
||||
return _repository.deleteCertificate(certificationType: certificationType);
|
||||
Future<void> call(String certificateId) {
|
||||
return _repository.deleteCertificate(certificateId: certificateId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ class UploadCertificateUseCase
|
||||
@override
|
||||
Future<StaffCertificate> call(UploadCertificateParams params) {
|
||||
return _repository.uploadCertificate(
|
||||
certificationType: params.certificationType,
|
||||
certificateType: params.certificateType,
|
||||
name: params.name,
|
||||
filePath: params.filePath,
|
||||
expiryDate: params.expiryDate,
|
||||
@@ -26,7 +26,7 @@ class UploadCertificateUseCase
|
||||
class UploadCertificateParams {
|
||||
/// Creates [UploadCertificateParams].
|
||||
UploadCertificateParams({
|
||||
required this.certificationType,
|
||||
required this.certificateType,
|
||||
required this.name,
|
||||
required this.filePath,
|
||||
this.expiryDate,
|
||||
@@ -34,8 +34,8 @@ class UploadCertificateParams {
|
||||
this.certificateNumber,
|
||||
});
|
||||
|
||||
/// The type of certification.
|
||||
final ComplianceType certificationType;
|
||||
/// The type of certification (e.g. "FOOD_HYGIENE", "SIA_BADGE").
|
||||
final String certificateType;
|
||||
|
||||
/// The name of the certificate.
|
||||
final String name;
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import '../repositories/certificates_repository.dart';
|
||||
|
||||
/// Use case for upserting a staff compliance certificate.
|
||||
class UpsertCertificateUseCase extends UseCase<UpsertCertificateParams, void> {
|
||||
/// Creates an [UpsertCertificateUseCase].
|
||||
UpsertCertificateUseCase(this._repository);
|
||||
final CertificatesRepository _repository;
|
||||
|
||||
@override
|
||||
Future<void> call(UpsertCertificateParams params) {
|
||||
return _repository.upsertCertificate(
|
||||
certificationType: params.certificationType,
|
||||
name: params.name,
|
||||
status: params.status,
|
||||
fileUrl: params.fileUrl,
|
||||
expiry: params.expiry,
|
||||
issuer: params.issuer,
|
||||
certificateNumber: params.certificateNumber,
|
||||
validationStatus: params.validationStatus,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Parameters for [UpsertCertificateUseCase].
|
||||
class UpsertCertificateParams {
|
||||
/// Creates [UpsertCertificateParams].
|
||||
UpsertCertificateParams({
|
||||
required this.certificationType,
|
||||
required this.name,
|
||||
required this.status,
|
||||
this.fileUrl,
|
||||
this.expiry,
|
||||
this.issuer,
|
||||
this.certificateNumber,
|
||||
this.validationStatus,
|
||||
});
|
||||
|
||||
/// The type of certification.
|
||||
final ComplianceType certificationType;
|
||||
|
||||
/// The name of the certificate.
|
||||
final String name;
|
||||
|
||||
/// The status of the certificate.
|
||||
final StaffCertificateStatus status;
|
||||
|
||||
/// The URL of the certificate file.
|
||||
final String? fileUrl;
|
||||
|
||||
/// The expiry date of the certificate.
|
||||
final DateTime? expiry;
|
||||
|
||||
/// The issuer of the certificate.
|
||||
final String? issuer;
|
||||
|
||||
/// The certificate number.
|
||||
final String? certificateNumber;
|
||||
|
||||
/// The validation status of the certificate.
|
||||
final StaffCertificateValidationStatus? validationStatus;
|
||||
}
|
||||
@@ -23,12 +23,12 @@ class CertificateUploadCubit extends Cubit<CertificateUploadState>
|
||||
emit(state.copyWith(selectedFilePath: filePath));
|
||||
}
|
||||
|
||||
Future<void> deleteCertificate(ComplianceType type) async {
|
||||
Future<void> deleteCertificate(String certificateId) async {
|
||||
emit(state.copyWith(status: CertificateUploadStatus.uploading));
|
||||
await handleError(
|
||||
emit: emit,
|
||||
action: () async {
|
||||
await _deleteCertificateUseCase(type);
|
||||
await _deleteCertificateUseCase(certificateId);
|
||||
emit(state.copyWith(status: CertificateUploadStatus.success));
|
||||
},
|
||||
onError: (String errorKey) => state.copyWith(
|
||||
|
||||
@@ -38,12 +38,12 @@ class CertificatesCubit extends Cubit<CertificatesState>
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> deleteCertificate(ComplianceType type) async {
|
||||
Future<void> deleteCertificate(String certificateId) async {
|
||||
emit(state.copyWith(status: CertificatesStatus.loading));
|
||||
await handleError(
|
||||
emit: emit,
|
||||
action: () async {
|
||||
await _deleteCertificateUseCase(type);
|
||||
await _deleteCertificateUseCase(certificateId);
|
||||
await loadCertificates();
|
||||
},
|
||||
onError: (String errorKey) => state.copyWith(
|
||||
|
||||
@@ -33,7 +33,7 @@ class CertificatesState extends Equatable {
|
||||
int get completedCount => certificates
|
||||
.where(
|
||||
(StaffCertificate cert) =>
|
||||
cert.validationStatus == StaffCertificateValidationStatus.approved,
|
||||
cert.status == CertificateStatus.verified,
|
||||
)
|
||||
.length;
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ class _CertificateUploadPageState extends State<CertificateUploadPage> {
|
||||
final TextEditingController _numberController = TextEditingController();
|
||||
final TextEditingController _nameController = TextEditingController();
|
||||
|
||||
ComplianceType? _selectedType;
|
||||
String _selectedType = '';
|
||||
|
||||
final FilePickerService _filePicker = Modular.get<FilePickerService>();
|
||||
|
||||
@@ -44,13 +44,13 @@ class _CertificateUploadPageState extends State<CertificateUploadPage> {
|
||||
_cubit = Modular.get<CertificateUploadCubit>();
|
||||
|
||||
if (widget.certificate != null) {
|
||||
_selectedExpiryDate = widget.certificate!.expiryDate;
|
||||
_selectedExpiryDate = widget.certificate!.expiresAt;
|
||||
_issuerController.text = widget.certificate!.issuer ?? '';
|
||||
_numberController.text = widget.certificate!.certificateNumber ?? '';
|
||||
_nameController.text = widget.certificate!.name;
|
||||
_selectedType = widget.certificate!.certificationType;
|
||||
_selectedType = widget.certificate!.certificateType;
|
||||
} else {
|
||||
_selectedType = ComplianceType.other;
|
||||
_selectedType = 'OTHER';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,7 +141,7 @@ class _CertificateUploadPageState extends State<CertificateUploadPage> {
|
||||
);
|
||||
|
||||
if (confirmed == true && mounted) {
|
||||
await cubit.deleteCertificate(widget.certificate!.certificationType);
|
||||
await cubit.deleteCertificate(widget.certificate!.certificateId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,7 +149,7 @@ class _CertificateUploadPageState extends State<CertificateUploadPage> {
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider<CertificateUploadCubit>.value(
|
||||
value: _cubit..setSelectedFilePath(
|
||||
widget.certificate?.certificateUrl,
|
||||
widget.certificate?.fileUri,
|
||||
),
|
||||
child: BlocConsumer<CertificateUploadCubit, CertificateUploadState>(
|
||||
listener: (BuildContext context, CertificateUploadState state) {
|
||||
@@ -231,7 +231,7 @@ class _CertificateUploadPageState extends State<CertificateUploadPage> {
|
||||
BlocProvider.of<CertificateUploadCubit>(context)
|
||||
.uploadCertificate(
|
||||
UploadCertificateParams(
|
||||
certificationType: _selectedType!,
|
||||
certificateType: _selectedType,
|
||||
name: _nameController.text,
|
||||
filePath: state.selectedFilePath!,
|
||||
expiryDate: _selectedExpiryDate,
|
||||
|
||||
@@ -19,28 +19,19 @@ class CertificateCard extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Determine UI state from certificate
|
||||
final bool isComplete =
|
||||
certificate.validationStatus ==
|
||||
StaffCertificateValidationStatus.approved;
|
||||
final bool isExpiring =
|
||||
certificate.status == StaffCertificateStatus.expiring ||
|
||||
certificate.status == StaffCertificateStatus.expiringSoon;
|
||||
final bool isExpired = certificate.status == StaffCertificateStatus.expired;
|
||||
final bool isVerified = certificate.status == CertificateStatus.verified;
|
||||
final bool isExpired = certificate.status == CertificateStatus.expired ||
|
||||
certificate.isExpired;
|
||||
final bool isPending = certificate.status == CertificateStatus.pending;
|
||||
final bool isNotStarted = certificate.fileUri == null ||
|
||||
certificate.status == CertificateStatus.rejected;
|
||||
|
||||
// Override isComplete if expiring or expired
|
||||
final bool showComplete = isComplete && !isExpired && !isExpiring;
|
||||
|
||||
final bool isPending =
|
||||
certificate.validationStatus ==
|
||||
StaffCertificateValidationStatus.pendingExpertReview;
|
||||
final bool isNotStarted =
|
||||
certificate.status == StaffCertificateStatus.notStarted ||
|
||||
certificate.validationStatus ==
|
||||
StaffCertificateValidationStatus.rejected;
|
||||
// Show verified badge only if not expired
|
||||
final bool showComplete = isVerified && !isExpired;
|
||||
|
||||
// UI Properties helper
|
||||
final _CertificateUiProps uiProps = _getUiProps(
|
||||
certificate.certificationType,
|
||||
certificate.certificateType,
|
||||
);
|
||||
|
||||
return GestureDetector(
|
||||
@@ -55,7 +46,7 @@ class CertificateCard extends StatelessWidget {
|
||||
clipBehavior: Clip.hardEdge,
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
if (isExpiring || isExpired)
|
||||
if (isExpired)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UiConstants.space4,
|
||||
@@ -78,11 +69,7 @@ class CertificateCard extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(width: UiConstants.space2),
|
||||
Text(
|
||||
isExpired
|
||||
? t.staff_certificates.card.expired
|
||||
: t.staff_certificates.card.expires_in_days(
|
||||
days: _daysUntilExpiry(certificate.expiryDate),
|
||||
),
|
||||
t.staff_certificates.card.expired,
|
||||
style: UiTypography.body3m.textPrimary,
|
||||
),
|
||||
],
|
||||
@@ -151,7 +138,7 @@ class CertificateCard extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
certificate.description ?? '',
|
||||
certificate.certificateType,
|
||||
style: UiTypography.body3r.textSecondary,
|
||||
),
|
||||
if (showComplete) ...<Widget>[
|
||||
@@ -159,17 +146,15 @@ class CertificateCard extends StatelessWidget {
|
||||
_buildMiniStatus(
|
||||
t.staff_certificates.card.verified,
|
||||
UiColors.primary,
|
||||
certificate.expiryDate,
|
||||
certificate.expiresAt,
|
||||
),
|
||||
],
|
||||
if (isExpiring || isExpired) ...<Widget>[
|
||||
if (isExpired) ...<Widget>[
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
_buildMiniStatus(
|
||||
isExpired
|
||||
? t.staff_certificates.card.expired
|
||||
: t.staff_certificates.card.expiring_soon,
|
||||
isExpired ? UiColors.destructive : UiColors.primary,
|
||||
certificate.expiryDate,
|
||||
t.staff_certificates.card.expired,
|
||||
UiColors.destructive,
|
||||
certificate.expiresAt,
|
||||
),
|
||||
],
|
||||
if (isNotStarted) ...<Widget>[
|
||||
@@ -220,18 +205,14 @@ class CertificateCard extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
int _daysUntilExpiry(DateTime? expiry) {
|
||||
if (expiry == null) return 0;
|
||||
return expiry.difference(DateTime.now()).inDays;
|
||||
}
|
||||
|
||||
_CertificateUiProps _getUiProps(ComplianceType type) {
|
||||
switch (type) {
|
||||
case ComplianceType.backgroundCheck:
|
||||
_CertificateUiProps _getUiProps(String type) {
|
||||
switch (type.toUpperCase()) {
|
||||
case 'BACKGROUND_CHECK':
|
||||
return _CertificateUiProps(UiIcons.fileCheck, UiColors.primary);
|
||||
case ComplianceType.foodHandler:
|
||||
case 'FOOD_HYGIENE':
|
||||
case 'FOOD_HANDLER':
|
||||
return _CertificateUiProps(UiIcons.utensils, UiColors.primary);
|
||||
case ComplianceType.rbs:
|
||||
case 'RBS':
|
||||
return _CertificateUiProps(UiIcons.wine, UiColors.foreground);
|
||||
default:
|
||||
return _CertificateUiProps(UiIcons.award, UiColors.primary);
|
||||
|
||||
@@ -3,28 +3,38 @@ import 'package:flutter_modular/flutter_modular.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
import 'data/repositories_impl/certificates_repository_impl.dart';
|
||||
import 'domain/repositories/certificates_repository.dart';
|
||||
import 'domain/usecases/get_certificates_usecase.dart';
|
||||
import 'domain/usecases/delete_certificate_usecase.dart';
|
||||
import 'domain/usecases/upsert_certificate_usecase.dart';
|
||||
import 'domain/usecases/upload_certificate_usecase.dart';
|
||||
import 'presentation/blocs/certificates/certificates_cubit.dart';
|
||||
import 'presentation/blocs/certificate_upload/certificate_upload_cubit.dart';
|
||||
import 'presentation/pages/certificate_upload_page.dart';
|
||||
import 'presentation/pages/certificates_page.dart';
|
||||
import 'package:staff_certificates/src/data/repositories_impl/certificates_repository_impl.dart';
|
||||
import 'package:staff_certificates/src/domain/repositories/certificates_repository.dart';
|
||||
import 'package:staff_certificates/src/domain/usecases/get_certificates_usecase.dart';
|
||||
import 'package:staff_certificates/src/domain/usecases/delete_certificate_usecase.dart';
|
||||
import 'package:staff_certificates/src/domain/usecases/upload_certificate_usecase.dart';
|
||||
import 'package:staff_certificates/src/presentation/blocs/certificates/certificates_cubit.dart';
|
||||
import 'package:staff_certificates/src/presentation/blocs/certificate_upload/certificate_upload_cubit.dart';
|
||||
import 'package:staff_certificates/src/presentation/pages/certificate_upload_page.dart';
|
||||
import 'package:staff_certificates/src/presentation/pages/certificates_page.dart';
|
||||
|
||||
/// Module for the Staff Certificates feature.
|
||||
///
|
||||
/// Uses the V2 REST API via [BaseApiService] for backend access.
|
||||
class StaffCertificatesModule extends Module {
|
||||
@override
|
||||
List<Module> get imports => <Module>[CoreModule()];
|
||||
|
||||
@override
|
||||
void binds(Injector i) {
|
||||
i.addLazySingleton<CertificatesRepository>(CertificatesRepositoryImpl.new);
|
||||
i.addLazySingleton<CertificatesRepository>(
|
||||
() => CertificatesRepositoryImpl(
|
||||
apiService: i.get<BaseApiService>(),
|
||||
uploadService: i.get<FileUploadService>(),
|
||||
signedUrlService: i.get<SignedUrlService>(),
|
||||
verificationService: i.get<VerificationService>(),
|
||||
),
|
||||
);
|
||||
i.addLazySingleton<GetCertificatesUseCase>(GetCertificatesUseCase.new);
|
||||
i.addLazySingleton<DeleteCertificateUseCase>(DeleteCertificateUseCase.new);
|
||||
i.addLazySingleton<UpsertCertificateUseCase>(UpsertCertificateUseCase.new);
|
||||
i.addLazySingleton<UploadCertificateUseCase>(UploadCertificateUseCase.new);
|
||||
i.addLazySingleton<DeleteCertificateUseCase>(
|
||||
DeleteCertificateUseCase.new);
|
||||
i.addLazySingleton<UploadCertificateUseCase>(
|
||||
UploadCertificateUseCase.new);
|
||||
i.addLazySingleton<CertificatesCubit>(CertificatesCubit.new);
|
||||
i.add<CertificateUploadCubit>(CertificateUploadCubit.new);
|
||||
}
|
||||
|
||||
@@ -13,9 +13,8 @@ dependencies:
|
||||
flutter_bloc: ^8.1.0
|
||||
equatable: ^2.0.5
|
||||
intl: ^0.20.0
|
||||
get_it: ^7.6.0
|
||||
flutter_modular: ^6.3.0
|
||||
|
||||
|
||||
# KROW Dependencies
|
||||
design_system:
|
||||
path: ../../../../../design_system
|
||||
@@ -25,10 +24,6 @@ dependencies:
|
||||
path: ../../../../../domain
|
||||
krow_core:
|
||||
path: ../../../../../core
|
||||
krow_data_connect:
|
||||
path: ../../../../../data_connect
|
||||
firebase_auth: ^6.1.2
|
||||
firebase_data_connect: ^0.2.2
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
||||
@@ -1,105 +1,80 @@
|
||||
// ignore_for_file: always_specify_types, depend_on_referenced_packages, dead_code, dead_null_aware_expression, unused_local_variable, unused_import, sort_constructors_first, prefer_final_fields, prefer_const_constructors, deprecated_member_use, implicit_call_tearoffs, implementation_imports, unused_element, unused_field, duplicate_ignore
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_data_connect/krow_data_connect.dart';
|
||||
import 'package:krow_domain/krow_domain.dart' as domain;
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
import '../../domain/repositories/documents_repository.dart';
|
||||
import 'package:staff_documents/src/domain/repositories/documents_repository.dart';
|
||||
|
||||
/// Implementation of [DocumentsRepository] using Data Connect.
|
||||
/// Implementation of [DocumentsRepository] using the V2 API for reads
|
||||
/// and core services for uploads/verification.
|
||||
///
|
||||
/// Replaces the previous Firebase Data Connect implementation.
|
||||
class DocumentsRepositoryImpl implements DocumentsRepository {
|
||||
/// Creates a [DocumentsRepositoryImpl].
|
||||
DocumentsRepositoryImpl({
|
||||
required BaseApiService apiService,
|
||||
required FileUploadService uploadService,
|
||||
required SignedUrlService signedUrlService,
|
||||
required VerificationService verificationService,
|
||||
}) : _service = DataConnectService.instance,
|
||||
_uploadService = uploadService,
|
||||
_signedUrlService = signedUrlService,
|
||||
_verificationService = verificationService;
|
||||
}) : _api = apiService,
|
||||
_uploadService = uploadService,
|
||||
_signedUrlService = signedUrlService,
|
||||
_verificationService = verificationService;
|
||||
|
||||
final DataConnectService _service;
|
||||
final BaseApiService _api;
|
||||
final FileUploadService _uploadService;
|
||||
final SignedUrlService _signedUrlService;
|
||||
final VerificationService _verificationService;
|
||||
|
||||
@override
|
||||
Future<List<domain.StaffDocument>> getDocuments() async {
|
||||
return _service.getStaffRepository().getStaffDocuments();
|
||||
Future<List<ProfileDocument>> getDocuments() async {
|
||||
final ApiResponse response =
|
||||
await _api.get(V2ApiEndpoints.staffDocuments);
|
||||
final List<dynamic> items = response.data['documents'] as List<dynamic>;
|
||||
return items
|
||||
.map((dynamic json) =>
|
||||
ProfileDocument.fromJson(json as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<domain.StaffDocument> uploadDocument(
|
||||
Future<ProfileDocument> uploadDocument(
|
||||
String documentId,
|
||||
String filePath,
|
||||
) async {
|
||||
return _service.run(() async {
|
||||
// 1. Upload the file to cloud storage
|
||||
final FileUploadResponse uploadRes = await _uploadService.uploadFile(
|
||||
filePath: filePath,
|
||||
fileName: 'staff_document_${DateTime.now().millisecondsSinceEpoch}.pdf',
|
||||
visibility: domain.FileVisibility.private,
|
||||
);
|
||||
// 1. Upload the file to cloud storage
|
||||
final FileUploadResponse uploadRes = await _uploadService.uploadFile(
|
||||
filePath: filePath,
|
||||
fileName:
|
||||
'staff_document_${DateTime.now().millisecondsSinceEpoch}.pdf',
|
||||
visibility: FileVisibility.private,
|
||||
);
|
||||
|
||||
// 2. Generate a signed URL for verification service to access the file
|
||||
final SignedUrlResponse signedUrlRes = await _signedUrlService
|
||||
.createSignedUrl(fileUri: uploadRes.fileUri);
|
||||
// 2. Generate a signed URL
|
||||
final SignedUrlResponse signedUrlRes =
|
||||
await _signedUrlService.createSignedUrl(fileUri: uploadRes.fileUri);
|
||||
|
||||
// 3. Initiate verification
|
||||
final List<domain.StaffDocument> allDocs = await getDocuments();
|
||||
final domain.StaffDocument currentDoc = allDocs.firstWhere(
|
||||
(domain.StaffDocument d) => d.documentId == documentId,
|
||||
);
|
||||
final String description = (currentDoc.description ?? '').toLowerCase();
|
||||
// 3. Initiate verification
|
||||
final VerificationResponse verificationRes =
|
||||
await _verificationService.createVerification(
|
||||
fileUri: uploadRes.fileUri,
|
||||
type: 'government_id',
|
||||
subjectType: 'worker',
|
||||
subjectId: documentId,
|
||||
rules: <String, dynamic>{'documentId': documentId},
|
||||
);
|
||||
|
||||
final String staffId = await _service.getStaffId();
|
||||
final VerificationResponse verificationRes = await _verificationService
|
||||
.createVerification(
|
||||
fileUri: uploadRes.fileUri,
|
||||
type: 'government_id',
|
||||
subjectType: 'worker',
|
||||
subjectId: staffId,
|
||||
rules: <String, dynamic>{
|
||||
'documentDescription': currentDoc.description,
|
||||
},
|
||||
);
|
||||
// 4. Submit upload result to V2 API
|
||||
await _api.put(
|
||||
V2ApiEndpoints.staffDocumentUpload(documentId),
|
||||
data: <String, dynamic>{
|
||||
'fileUri': signedUrlRes.signedUrl,
|
||||
'verificationId': verificationRes.verificationId,
|
||||
},
|
||||
);
|
||||
|
||||
// 4. Update/Create StaffDocument in Data Connect
|
||||
await _service.getStaffRepository().upsertStaffDocument(
|
||||
documentId: documentId,
|
||||
documentUrl: signedUrlRes.signedUrl,
|
||||
status: domain.DocumentStatus.pending,
|
||||
verificationId: verificationRes.verificationId,
|
||||
);
|
||||
|
||||
// 5. Return the updated document state
|
||||
final List<domain.StaffDocument> documents = await getDocuments();
|
||||
return documents.firstWhere(
|
||||
(domain.StaffDocument d) => d.documentId == documentId,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
domain.DocumentStatus _mapStatus(EnumValue<DocumentStatus> status) {
|
||||
if (status is Known<DocumentStatus>) {
|
||||
switch (status.value) {
|
||||
case DocumentStatus.VERIFIED:
|
||||
case DocumentStatus.AUTO_PASS:
|
||||
case DocumentStatus.APPROVED:
|
||||
return domain.DocumentStatus.verified;
|
||||
case DocumentStatus.PENDING:
|
||||
case DocumentStatus.UPLOADED:
|
||||
case DocumentStatus.PROCESSING:
|
||||
case DocumentStatus.NEEDS_REVIEW:
|
||||
case DocumentStatus.EXPIRING:
|
||||
return domain.DocumentStatus.pending;
|
||||
case DocumentStatus.MISSING:
|
||||
return domain.DocumentStatus.missing;
|
||||
case DocumentStatus.AUTO_FAIL:
|
||||
case DocumentStatus.REJECTED:
|
||||
case DocumentStatus.ERROR:
|
||||
return domain.DocumentStatus.rejected;
|
||||
}
|
||||
}
|
||||
// Default to pending for Unknown or unhandled cases
|
||||
return domain.DocumentStatus.pending;
|
||||
// 5. Return the updated document
|
||||
final List<ProfileDocument> documents = await getDocuments();
|
||||
return documents.firstWhere(
|
||||
(ProfileDocument d) => d.documentId == documentId,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,11 +2,12 @@ import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
/// Interface for the documents repository.
|
||||
///
|
||||
/// Responsible for fetching staff compliance documents.
|
||||
/// Responsible for fetching and uploading staff compliance documents
|
||||
/// via the V2 API. Uses [ProfileDocument] from the V2 domain.
|
||||
abstract interface class DocumentsRepository {
|
||||
/// Fetches the list of compliance documents for the current staff member.
|
||||
Future<List<StaffDocument>> getDocuments();
|
||||
Future<List<ProfileDocument>> getDocuments();
|
||||
|
||||
/// Uploads a document for the current staff member.
|
||||
Future<StaffDocument> uploadDocument(String documentId, String filePath);
|
||||
/// Uploads a document file for the given [documentId].
|
||||
Future<ProfileDocument> uploadDocument(String documentId, String filePath);
|
||||
}
|
||||
|
||||
@@ -5,13 +5,13 @@ import '../repositories/documents_repository.dart';
|
||||
/// Use case for fetching staff compliance documents.
|
||||
///
|
||||
/// Delegates to [DocumentsRepository].
|
||||
class GetDocumentsUseCase implements NoInputUseCase<List<StaffDocument>> {
|
||||
class GetDocumentsUseCase implements NoInputUseCase<List<ProfileDocument>> {
|
||||
|
||||
GetDocumentsUseCase(this._repository);
|
||||
final DocumentsRepository _repository;
|
||||
|
||||
@override
|
||||
Future<List<StaffDocument>> call() {
|
||||
Future<List<ProfileDocument>> call() {
|
||||
return _repository.getDocuments();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,12 +3,12 @@ import 'package:krow_domain/krow_domain.dart';
|
||||
import '../repositories/documents_repository.dart';
|
||||
|
||||
class UploadDocumentUseCase
|
||||
extends UseCase<UploadDocumentArguments, StaffDocument> {
|
||||
extends UseCase<UploadDocumentArguments, ProfileDocument> {
|
||||
UploadDocumentUseCase(this._repository);
|
||||
final DocumentsRepository _repository;
|
||||
|
||||
@override
|
||||
Future<StaffDocument> call(UploadDocumentArguments arguments) {
|
||||
Future<ProfileDocument> call(UploadDocumentArguments arguments) {
|
||||
return _repository.uploadDocument(arguments.documentId, arguments.filePath);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ class DocumentUploadCubit extends Cubit<DocumentUploadState> {
|
||||
emit(state.copyWith(status: DocumentUploadStatus.uploading));
|
||||
|
||||
try {
|
||||
final StaffDocument updatedDoc = await _uploadDocumentUseCase(
|
||||
final ProfileDocument updatedDoc = await _uploadDocumentUseCase(
|
||||
UploadDocumentArguments(documentId: documentId, filePath: filePath),
|
||||
);
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ class DocumentUploadState extends Equatable {
|
||||
final bool isAttested;
|
||||
final String? selectedFilePath;
|
||||
final String? documentUrl;
|
||||
final StaffDocument? updatedDocument;
|
||||
final ProfileDocument? updatedDocument;
|
||||
final String? errorMessage;
|
||||
|
||||
DocumentUploadState copyWith({
|
||||
@@ -25,7 +25,7 @@ class DocumentUploadState extends Equatable {
|
||||
bool? isAttested,
|
||||
String? selectedFilePath,
|
||||
String? documentUrl,
|
||||
StaffDocument? updatedDocument,
|
||||
ProfileDocument? updatedDocument,
|
||||
String? errorMessage,
|
||||
}) {
|
||||
return DocumentUploadState(
|
||||
|
||||
@@ -15,7 +15,7 @@ class DocumentsCubit extends Cubit<DocumentsState>
|
||||
await handleError(
|
||||
emit: emit,
|
||||
action: () async {
|
||||
final List<StaffDocument> documents = await _getDocumentsUseCase();
|
||||
final List<ProfileDocument> documents = await _getDocumentsUseCase();
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: DocumentsStatus.success,
|
||||
|
||||
@@ -7,16 +7,16 @@ class DocumentsState extends Equatable {
|
||||
|
||||
const DocumentsState({
|
||||
this.status = DocumentsStatus.initial,
|
||||
List<StaffDocument>? documents,
|
||||
List<ProfileDocument>? documents,
|
||||
this.errorMessage,
|
||||
}) : documents = documents ?? const <StaffDocument>[];
|
||||
}) : documents = documents ?? const <ProfileDocument>[];
|
||||
final DocumentsStatus status;
|
||||
final List<StaffDocument> documents;
|
||||
final List<ProfileDocument> documents;
|
||||
final String? errorMessage;
|
||||
|
||||
DocumentsState copyWith({
|
||||
DocumentsStatus? status,
|
||||
List<StaffDocument>? documents,
|
||||
List<ProfileDocument>? documents,
|
||||
String? errorMessage,
|
||||
}) {
|
||||
return DocumentsState(
|
||||
@@ -27,7 +27,7 @@ class DocumentsState extends Equatable {
|
||||
}
|
||||
|
||||
int get completedCount =>
|
||||
documents.where((StaffDocument d) => d.status == DocumentStatus.verified).length;
|
||||
documents.where((ProfileDocument d) => d.status == ProfileDocumentStatus.verified).length;
|
||||
|
||||
int get totalCount => documents.length;
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ class DocumentUploadPage extends StatelessWidget {
|
||||
});
|
||||
|
||||
/// The staff document descriptor for the item being uploaded.
|
||||
final StaffDocument document;
|
||||
final ProfileDocument document;
|
||||
|
||||
/// Optional URL of an already-uploaded document.
|
||||
final String? initialUrl;
|
||||
@@ -62,7 +62,6 @@ class DocumentUploadPage extends StatelessWidget {
|
||||
return Scaffold(
|
||||
appBar: UiAppBar(
|
||||
title: document.name,
|
||||
subtitle: document.description,
|
||||
onLeadingPressed: () => Modular.to.toDocuments(),
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
|
||||
@@ -77,11 +77,11 @@ class DocumentsPage extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
...state.documents.map(
|
||||
(StaffDocument doc) => DocumentCard(
|
||||
(ProfileDocument doc) => DocumentCard(
|
||||
document: doc,
|
||||
onTap: () => Modular.to.toDocumentUpload(
|
||||
document: doc,
|
||||
initialUrl: doc.documentUrl,
|
||||
initialUrl: doc.fileUri,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -11,7 +11,7 @@ class DocumentCard extends StatelessWidget {
|
||||
required this.document,
|
||||
this.onTap,
|
||||
});
|
||||
final StaffDocument document;
|
||||
final ProfileDocument document;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
@override
|
||||
@@ -57,12 +57,6 @@ class DocumentCard extends StatelessWidget {
|
||||
_getStatusIcon(document.status),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: UiConstants.space1 / 2),
|
||||
if (document.description != null)
|
||||
Text(
|
||||
document.description!,
|
||||
style: UiTypography.body2r.textSecondary,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space3),
|
||||
Row(
|
||||
children: <Widget>[
|
||||
@@ -79,15 +73,15 @@ class DocumentCard extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _getStatusIcon(DocumentStatus status) {
|
||||
Widget _getStatusIcon(ProfileDocumentStatus status) {
|
||||
switch (status) {
|
||||
case DocumentStatus.verified:
|
||||
case ProfileDocumentStatus.verified:
|
||||
return const Icon(
|
||||
UiIcons.check,
|
||||
color: UiColors.iconSuccess,
|
||||
size: 20,
|
||||
);
|
||||
case DocumentStatus.pending:
|
||||
case ProfileDocumentStatus.pending:
|
||||
return const Icon(
|
||||
UiIcons.clock,
|
||||
color: UiColors.textWarning,
|
||||
@@ -102,37 +96,32 @@ class DocumentCard extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildStatusBadge(DocumentStatus status) {
|
||||
Widget _buildStatusBadge(ProfileDocumentStatus status) {
|
||||
Color bg;
|
||||
Color text;
|
||||
String label;
|
||||
|
||||
switch (status) {
|
||||
case DocumentStatus.verified:
|
||||
case ProfileDocumentStatus.verified:
|
||||
bg = UiColors.tagSuccess;
|
||||
text = UiColors.textSuccess;
|
||||
label = t.staff_documents.card.verified;
|
||||
break;
|
||||
case DocumentStatus.pending:
|
||||
case ProfileDocumentStatus.pending:
|
||||
bg = UiColors.tagPending;
|
||||
text = UiColors.textWarning;
|
||||
label = t.staff_documents.card.pending;
|
||||
break;
|
||||
case DocumentStatus.missing:
|
||||
case ProfileDocumentStatus.notUploaded:
|
||||
bg = UiColors.textError.withValues(alpha: 0.1);
|
||||
text = UiColors.textError;
|
||||
label = t.staff_documents.card.missing;
|
||||
break;
|
||||
case DocumentStatus.rejected:
|
||||
case ProfileDocumentStatus.rejected:
|
||||
bg = UiColors.textError.withValues(alpha: 0.1);
|
||||
text = UiColors.textError;
|
||||
label = t.staff_documents.card.rejected;
|
||||
break;
|
||||
case DocumentStatus.expired:
|
||||
case ProfileDocumentStatus.expired:
|
||||
bg = UiColors.textError.withValues(alpha: 0.1);
|
||||
text = UiColors.textError;
|
||||
label = t.staff_documents.card.rejected; // Or define "Expired" string
|
||||
break;
|
||||
label = t.staff_documents.card.rejected;
|
||||
}
|
||||
|
||||
return Container(
|
||||
@@ -150,8 +139,8 @@ class DocumentCard extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildActionButton(DocumentStatus status) {
|
||||
final bool isVerified = status == DocumentStatus.verified;
|
||||
Widget _buildActionButton(ProfileDocumentStatus status) {
|
||||
final bool isVerified = status == ProfileDocumentStatus.verified;
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: UiConstants.radiusSm,
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import 'data/repositories_impl/documents_repository_impl.dart';
|
||||
import 'domain/repositories/documents_repository.dart';
|
||||
import 'domain/usecases/get_documents_usecase.dart';
|
||||
import 'domain/usecases/upload_document_usecase.dart';
|
||||
import 'presentation/blocs/documents/documents_cubit.dart';
|
||||
import 'presentation/blocs/document_upload/document_upload_cubit.dart';
|
||||
import 'presentation/pages/documents_page.dart';
|
||||
import 'presentation/pages/document_upload_page.dart';
|
||||
|
||||
import 'package:staff_documents/src/data/repositories_impl/documents_repository_impl.dart';
|
||||
import 'package:staff_documents/src/domain/repositories/documents_repository.dart';
|
||||
import 'package:staff_documents/src/domain/usecases/get_documents_usecase.dart';
|
||||
import 'package:staff_documents/src/domain/usecases/upload_document_usecase.dart';
|
||||
import 'package:staff_documents/src/presentation/blocs/documents/documents_cubit.dart';
|
||||
import 'package:staff_documents/src/presentation/blocs/document_upload/document_upload_cubit.dart';
|
||||
import 'package:staff_documents/src/presentation/pages/documents_page.dart';
|
||||
import 'package:staff_documents/src/presentation/pages/document_upload_page.dart';
|
||||
|
||||
/// Module for the Staff Documents feature.
|
||||
///
|
||||
/// Uses the V2 REST API via [BaseApiService] for backend access.
|
||||
class StaffDocumentsModule extends Module {
|
||||
@override
|
||||
List<Module> get imports => <Module>[CoreModule()];
|
||||
@@ -18,6 +22,7 @@ class StaffDocumentsModule extends Module {
|
||||
void binds(Injector i) {
|
||||
i.addLazySingleton<DocumentsRepository>(
|
||||
() => DocumentsRepositoryImpl(
|
||||
apiService: i.get<BaseApiService>(),
|
||||
uploadService: i.get<FileUploadService>(),
|
||||
signedUrlService: i.get<SignedUrlService>(),
|
||||
verificationService: i.get<VerificationService>(),
|
||||
@@ -39,7 +44,7 @@ class StaffDocumentsModule extends Module {
|
||||
r.child(
|
||||
StaffPaths.childRoute(StaffPaths.documents, StaffPaths.documentUpload),
|
||||
child: (_) => DocumentUploadPage(
|
||||
document: r.args.data['document'] as StaffDocument,
|
||||
document: r.args.data['document'] as ProfileDocument,
|
||||
initialUrl: r.args.data['initialUrl'] as String?,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -15,8 +15,7 @@ dependencies:
|
||||
bloc: ^8.1.0
|
||||
flutter_modular: ^6.3.0
|
||||
equatable: ^2.0.5
|
||||
firebase_auth: ^6.1.4
|
||||
firebase_data_connect: ^0.2.2+2
|
||||
|
||||
# Architecture Packages
|
||||
design_system:
|
||||
path: ../../../../../design_system
|
||||
@@ -26,5 +25,3 @@ dependencies:
|
||||
path: ../../../../../core_localization
|
||||
krow_domain:
|
||||
path: ../../../../../domain
|
||||
krow_data_connect:
|
||||
path: ../../../../../data_connect
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
import 'package:firebase_data_connect/firebase_data_connect.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_data_connect/krow_data_connect.dart' as dc;
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
class TaxFormMapper {
|
||||
static TaxForm fromDataConnect(dc.GetTaxFormsByStaffIdTaxForms form) {
|
||||
// Construct the legacy map for the entity
|
||||
final Map<String, dynamic> formData = <String, dynamic>{
|
||||
'firstName': form.firstName,
|
||||
'lastName': form.lastName,
|
||||
'middleInitial': form.mInitial,
|
||||
'otherLastNames': form.oLastName,
|
||||
'dob': _formatDate(form.dob),
|
||||
'ssn': form.socialSN.toString(),
|
||||
'email': form.email,
|
||||
'phone': form.phone,
|
||||
'address': form.address,
|
||||
'aptNumber': form.apt,
|
||||
'city': form.city,
|
||||
'state': form.state,
|
||||
'zipCode': form.zipCode,
|
||||
|
||||
// I-9 Fields
|
||||
'citizenshipStatus': form.citizen?.stringValue,
|
||||
'uscisNumber': form.uscis,
|
||||
'passportNumber': form.passportNumber,
|
||||
'countryIssuance': form.countryIssue,
|
||||
'preparerUsed': form.prepartorOrTranslator,
|
||||
|
||||
// W-4 Fields
|
||||
'filingStatus': form.marital?.stringValue,
|
||||
'multipleJobs': form.multipleJob,
|
||||
'qualifyingChildren': form.childrens,
|
||||
'otherDependents': form.otherDeps,
|
||||
'otherIncome': form.otherInconme?.toString(),
|
||||
'deductions': form.deductions?.toString(),
|
||||
'extraWithholding': form.extraWithholding?.toString(),
|
||||
|
||||
'signature': form.signature,
|
||||
};
|
||||
|
||||
String title = '';
|
||||
String subtitle = '';
|
||||
String description = '';
|
||||
|
||||
final dc.TaxFormType formType;
|
||||
if (form.formType is dc.Known<dc.TaxFormType>) {
|
||||
formType = (form.formType as dc.Known<dc.TaxFormType>).value;
|
||||
} else {
|
||||
formType = dc.TaxFormType.W4;
|
||||
}
|
||||
|
||||
if (formType == dc.TaxFormType.I9) {
|
||||
title = 'Form I-9';
|
||||
subtitle = 'Employment Eligibility Verification';
|
||||
description = 'Required for all new hires to verify identity.';
|
||||
} else {
|
||||
title = 'Form W-4';
|
||||
subtitle = 'Employee\'s Withholding Certificate';
|
||||
description = 'Determines federal income tax withholding.';
|
||||
}
|
||||
|
||||
return TaxFormAdapter.fromPrimitives(
|
||||
id: form.id,
|
||||
type: form.formType.stringValue,
|
||||
title: title,
|
||||
subtitle: subtitle,
|
||||
description: description,
|
||||
status: form.status.stringValue,
|
||||
staffId: form.staffId,
|
||||
formData: formData,
|
||||
updatedAt: form.updatedAt == null
|
||||
? null
|
||||
: DateTimeUtils.toDeviceTime(form.updatedAt!.toDateTime()),
|
||||
);
|
||||
}
|
||||
|
||||
static String? _formatDate(Timestamp? timestamp) {
|
||||
if (timestamp == null) return null;
|
||||
|
||||
final DateTime date =
|
||||
DateTimeUtils.toDeviceTime(timestamp.toDateTime());
|
||||
|
||||
return '${date.month.toString().padLeft(2, '0')}/${date.day.toString().padLeft(2, '0')}/${date.year}';
|
||||
}
|
||||
}
|
||||
@@ -1,274 +1,44 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:firebase_data_connect/firebase_data_connect.dart';
|
||||
import 'package:krow_data_connect/krow_data_connect.dart' as dc;
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
import '../../domain/repositories/tax_forms_repository.dart';
|
||||
import '../mappers/tax_form_mapper.dart';
|
||||
import 'package:staff_tax_forms/src/domain/repositories/tax_forms_repository.dart';
|
||||
|
||||
/// Implementation of [TaxFormsRepository] using the V2 API.
|
||||
///
|
||||
/// Replaces the previous Firebase Data Connect implementation.
|
||||
class TaxFormsRepositoryImpl implements TaxFormsRepository {
|
||||
TaxFormsRepositoryImpl() : _service = dc.DataConnectService.instance;
|
||||
/// Creates a [TaxFormsRepositoryImpl].
|
||||
TaxFormsRepositoryImpl({required BaseApiService apiService})
|
||||
: _api = apiService;
|
||||
|
||||
final dc.DataConnectService _service;
|
||||
final BaseApiService _api;
|
||||
|
||||
@override
|
||||
Future<List<TaxForm>> getTaxForms() async {
|
||||
return _service.run(() async {
|
||||
final String staffId = await _service.getStaffId();
|
||||
final QueryResult<
|
||||
dc.GetTaxFormsByStaffIdData,
|
||||
dc.GetTaxFormsByStaffIdVariables
|
||||
>
|
||||
response = await _service.connector
|
||||
.getTaxFormsByStaffId(staffId: staffId)
|
||||
.execute();
|
||||
|
||||
final List<TaxForm> forms = response.data.taxForms
|
||||
.map(TaxFormMapper.fromDataConnect)
|
||||
.toList();
|
||||
|
||||
// Check if required forms exist, create if not.
|
||||
final Set<TaxFormType> typesPresent = forms
|
||||
.map((TaxForm f) => f.type)
|
||||
.toSet();
|
||||
bool createdNew = false;
|
||||
|
||||
if (!typesPresent.contains(TaxFormType.i9)) {
|
||||
await _createInitialForm(staffId, TaxFormType.i9);
|
||||
createdNew = true;
|
||||
}
|
||||
if (!typesPresent.contains(TaxFormType.w4)) {
|
||||
await _createInitialForm(staffId, TaxFormType.w4);
|
||||
createdNew = true;
|
||||
}
|
||||
|
||||
if (createdNew) {
|
||||
final QueryResult<
|
||||
dc.GetTaxFormsByStaffIdData,
|
||||
dc.GetTaxFormsByStaffIdVariables
|
||||
>
|
||||
response2 = await _service.connector
|
||||
.getTaxFormsByStaffId(staffId: staffId)
|
||||
.execute();
|
||||
return response2.data.taxForms
|
||||
.map(TaxFormMapper.fromDataConnect)
|
||||
.toList();
|
||||
}
|
||||
|
||||
return forms;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _createInitialForm(String staffId, TaxFormType type) async {
|
||||
await _service.connector
|
||||
.createTaxForm(
|
||||
staffId: staffId,
|
||||
formType: dc.TaxFormType.values.byName(
|
||||
TaxFormAdapter.typeToString(type),
|
||||
),
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
socialSN: 0,
|
||||
address: '',
|
||||
status: dc.TaxFormStatus.NOT_STARTED,
|
||||
)
|
||||
.execute();
|
||||
final ApiResponse response =
|
||||
await _api.get(V2ApiEndpoints.staffTaxForms);
|
||||
final List<dynamic> items = response.data['taxForms'] as List<dynamic>;
|
||||
return items
|
||||
.map((dynamic json) =>
|
||||
TaxForm.fromJson(json as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> updateI9Form(I9TaxForm form) async {
|
||||
return _service.run(() async {
|
||||
final Map<String, dynamic> data = form.formData;
|
||||
final dc.UpdateTaxFormVariablesBuilder builder = _service.connector
|
||||
.updateTaxForm(id: form.id);
|
||||
_mapCommonFields(builder, data);
|
||||
_mapI9Fields(builder, data);
|
||||
await builder.execute();
|
||||
});
|
||||
Future<void> updateTaxForm(TaxForm form) async {
|
||||
await _api.put(
|
||||
V2ApiEndpoints.staffTaxFormUpdate(form.formType),
|
||||
data: form.toJson(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> submitI9Form(I9TaxForm form) async {
|
||||
return _service.run(() async {
|
||||
final Map<String, dynamic> data = form.formData;
|
||||
final dc.UpdateTaxFormVariablesBuilder builder = _service.connector
|
||||
.updateTaxForm(id: form.id);
|
||||
_mapCommonFields(builder, data);
|
||||
_mapI9Fields(builder, data);
|
||||
await builder.status(dc.TaxFormStatus.SUBMITTED).execute();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> updateW4Form(W4TaxForm form) async {
|
||||
return _service.run(() async {
|
||||
final Map<String, dynamic> data = form.formData;
|
||||
final dc.UpdateTaxFormVariablesBuilder builder = _service.connector
|
||||
.updateTaxForm(id: form.id);
|
||||
_mapCommonFields(builder, data);
|
||||
_mapW4Fields(builder, data);
|
||||
await builder.execute();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> submitW4Form(W4TaxForm form) async {
|
||||
return _service.run(() async {
|
||||
final Map<String, dynamic> data = form.formData;
|
||||
final dc.UpdateTaxFormVariablesBuilder builder = _service.connector
|
||||
.updateTaxForm(id: form.id);
|
||||
_mapCommonFields(builder, data);
|
||||
_mapW4Fields(builder, data);
|
||||
await builder.status(dc.TaxFormStatus.SUBMITTED).execute();
|
||||
});
|
||||
}
|
||||
|
||||
void _mapCommonFields(
|
||||
dc.UpdateTaxFormVariablesBuilder builder,
|
||||
Map<String, dynamic> data,
|
||||
) {
|
||||
if (data.containsKey('firstName')) {
|
||||
builder.firstName(data['firstName'] as String?);
|
||||
}
|
||||
if (data.containsKey('lastName')) {
|
||||
builder.lastName(data['lastName'] as String?);
|
||||
}
|
||||
if (data.containsKey('middleInitial')) {
|
||||
builder.mInitial(data['middleInitial'] as String?);
|
||||
}
|
||||
if (data.containsKey('otherLastNames')) {
|
||||
builder.oLastName(data['otherLastNames'] as String?);
|
||||
}
|
||||
if (data.containsKey('dob')) {
|
||||
final String dob = data['dob'] as String;
|
||||
// Handle both ISO string and MM/dd/yyyy manual entry
|
||||
DateTime? date;
|
||||
try {
|
||||
date = DateTime.parse(dob);
|
||||
} catch (_) {
|
||||
try {
|
||||
// Fallback minimal parse for mm/dd/yyyy
|
||||
final List<String> parts = dob.split('/');
|
||||
if (parts.length == 3) {
|
||||
date = DateTime(
|
||||
int.parse(parts[2]),
|
||||
int.parse(parts[0]),
|
||||
int.parse(parts[1]),
|
||||
);
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
if (date != null) {
|
||||
final int ms = date.millisecondsSinceEpoch;
|
||||
final int seconds = (ms / 1000).floor();
|
||||
builder.dob(Timestamp(0, seconds));
|
||||
}
|
||||
}
|
||||
if (data.containsKey('ssn') && data['ssn']?.toString().isNotEmpty == true) {
|
||||
builder.socialSN(
|
||||
int.tryParse(data['ssn'].toString().replaceAll(RegExp(r'\D'), '')) ?? 0,
|
||||
);
|
||||
}
|
||||
if (data.containsKey('email')) builder.email(data['email'] as String?);
|
||||
if (data.containsKey('phone')) builder.phone(data['phone'] as String?);
|
||||
if (data.containsKey('address')) {
|
||||
builder.address(data['address'] as String?);
|
||||
}
|
||||
if (data.containsKey('aptNumber')) {
|
||||
builder.apt(data['aptNumber'] as String?);
|
||||
}
|
||||
if (data.containsKey('city')) builder.city(data['city'] as String?);
|
||||
if (data.containsKey('state')) builder.state(data['state'] as String?);
|
||||
if (data.containsKey('zipCode')) {
|
||||
builder.zipCode(data['zipCode'] as String?);
|
||||
}
|
||||
}
|
||||
|
||||
void _mapI9Fields(
|
||||
dc.UpdateTaxFormVariablesBuilder builder,
|
||||
Map<String, dynamic> data,
|
||||
) {
|
||||
if (data.containsKey('citizenshipStatus')) {
|
||||
final String status = data['citizenshipStatus'] as String;
|
||||
// Map string to enum if possible, or handle otherwise.
|
||||
// Generated enum: CITIZEN, NONCITIZEN_NATIONAL, PERMANENT_RESIDENT, ALIEN_AUTHORIZED
|
||||
try {
|
||||
builder.citizen(
|
||||
dc.CitizenshipStatus.values.byName(status.toUpperCase()),
|
||||
);
|
||||
} catch (_) {}
|
||||
}
|
||||
if (data.containsKey('uscisNumber')) {
|
||||
builder.uscis(data['uscisNumber'] as String?);
|
||||
}
|
||||
if (data.containsKey('passportNumber')) {
|
||||
builder.passportNumber(data['passportNumber'] as String?);
|
||||
}
|
||||
if (data.containsKey('countryIssuance')) {
|
||||
builder.countryIssue(data['countryIssuance'] as String?);
|
||||
}
|
||||
if (data.containsKey('preparerUsed')) {
|
||||
builder.prepartorOrTranslator(data['preparerUsed'] as bool?);
|
||||
}
|
||||
if (data.containsKey('signature')) {
|
||||
builder.signature(data['signature'] as String?);
|
||||
}
|
||||
// Note: admissionNumber not in builder based on file read
|
||||
}
|
||||
|
||||
void _mapW4Fields(
|
||||
dc.UpdateTaxFormVariablesBuilder builder,
|
||||
Map<String, dynamic> data,
|
||||
) {
|
||||
if (data.containsKey('cityStateZip')) {
|
||||
final String csz = data['cityStateZip'] as String;
|
||||
// Extremely basic split: City, State Zip
|
||||
final List<String> parts = csz.split(',');
|
||||
if (parts.length >= 2) {
|
||||
builder.city(parts[0].trim());
|
||||
final String stateZip = parts[1].trim();
|
||||
final List<String> szParts = stateZip.split(' ');
|
||||
if (szParts.isNotEmpty) builder.state(szParts[0]);
|
||||
if (szParts.length > 1) builder.zipCode(szParts.last);
|
||||
}
|
||||
}
|
||||
if (data.containsKey('filingStatus')) {
|
||||
// MARITIAL_STATUS_SINGLE, MARITIAL_STATUS_MARRIED, MARITIAL_STATUS_HEAD
|
||||
try {
|
||||
final String status = data['filingStatus'] as String;
|
||||
// Simple mapping assumptions:
|
||||
if (status.contains('single')) {
|
||||
builder.marital(dc.MaritalStatus.SINGLE);
|
||||
} else if (status.contains('married')) {
|
||||
builder.marital(dc.MaritalStatus.MARRIED);
|
||||
} else if (status.contains('head')) {
|
||||
builder.marital(dc.MaritalStatus.HEAD);
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
if (data.containsKey('multipleJobs')) {
|
||||
builder.multipleJob(data['multipleJobs'] as bool?);
|
||||
}
|
||||
if (data.containsKey('qualifyingChildren')) {
|
||||
builder.childrens(data['qualifyingChildren'] as int?);
|
||||
}
|
||||
if (data.containsKey('otherDependents')) {
|
||||
builder.otherDeps(data['otherDependents'] as int?);
|
||||
}
|
||||
if (data.containsKey('otherIncome')) {
|
||||
builder.otherInconme(double.tryParse(data['otherIncome'].toString()));
|
||||
}
|
||||
if (data.containsKey('deductions')) {
|
||||
builder.deductions(double.tryParse(data['deductions'].toString()));
|
||||
}
|
||||
if (data.containsKey('extraWithholding')) {
|
||||
builder.extraWithholding(
|
||||
double.tryParse(data['extraWithholding'].toString()),
|
||||
);
|
||||
}
|
||||
if (data.containsKey('signature')) {
|
||||
builder.signature(data['signature'] as String?);
|
||||
}
|
||||
Future<void> submitTaxForm(TaxForm form) async {
|
||||
await _api.post(
|
||||
V2ApiEndpoints.staffTaxFormSubmit(form.formType),
|
||||
data: form.toJson(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
/// Repository interface for tax form operations.
|
||||
///
|
||||
/// Uses [TaxForm] from the V2 domain layer.
|
||||
abstract class TaxFormsRepository {
|
||||
/// Fetches the list of tax forms for the current staff member.
|
||||
Future<List<TaxForm>> getTaxForms();
|
||||
Future<void> updateI9Form(I9TaxForm form);
|
||||
Future<void> submitI9Form(I9TaxForm form);
|
||||
Future<void> updateW4Form(W4TaxForm form);
|
||||
Future<void> submitW4Form(W4TaxForm form);
|
||||
|
||||
/// Updates a tax form's fields (partial save).
|
||||
Future<void> updateTaxForm(TaxForm form);
|
||||
|
||||
/// Submits a tax form for review.
|
||||
Future<void> submitTaxForm(TaxForm form);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ class SaveI9FormUseCase {
|
||||
SaveI9FormUseCase(this._repository);
|
||||
final TaxFormsRepository _repository;
|
||||
|
||||
Future<void> call(I9TaxForm form) async {
|
||||
return _repository.updateI9Form(form);
|
||||
Future<void> call(TaxForm form) async {
|
||||
return _repository.updateTaxForm(form);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ class SaveW4FormUseCase {
|
||||
SaveW4FormUseCase(this._repository);
|
||||
final TaxFormsRepository _repository;
|
||||
|
||||
Future<void> call(W4TaxForm form) async {
|
||||
return _repository.updateW4Form(form);
|
||||
Future<void> call(TaxForm form) async {
|
||||
return _repository.updateTaxForm(form);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ class SubmitI9FormUseCase {
|
||||
SubmitI9FormUseCase(this._repository);
|
||||
final TaxFormsRepository _repository;
|
||||
|
||||
Future<void> call(I9TaxForm form) async {
|
||||
return _repository.submitI9Form(form);
|
||||
Future<void> call(TaxForm form) async {
|
||||
return _repository.submitTaxForm(form);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ class SubmitW4FormUseCase {
|
||||
SubmitW4FormUseCase(this._repository);
|
||||
final TaxFormsRepository _repository;
|
||||
|
||||
Future<void> call(W4TaxForm form) async {
|
||||
return _repository.submitW4Form(form);
|
||||
Future<void> call(TaxForm form) async {
|
||||
return _repository.submitTaxForm(form);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../../../domain/usecases/submit_i9_form_usecase.dart';
|
||||
import 'form_i9_state.dart';
|
||||
@@ -10,16 +9,16 @@ class FormI9Cubit extends Cubit<FormI9State> with BlocErrorHandler<FormI9State>
|
||||
|
||||
FormI9Cubit(this._submitI9FormUseCase) : super(const FormI9State());
|
||||
final SubmitI9FormUseCase _submitI9FormUseCase;
|
||||
String _formId = '';
|
||||
String _documentId = '';
|
||||
|
||||
void initialize(TaxForm? form) {
|
||||
if (form == null || form.formData.isEmpty) {
|
||||
if (form == null || form.fields.isEmpty) {
|
||||
emit(const FormI9State()); // Reset to empty if no form
|
||||
return;
|
||||
}
|
||||
|
||||
final Map<String, dynamic> data = form.formData;
|
||||
_formId = form.id;
|
||||
final Map<String, dynamic> data = form.fields;
|
||||
_documentId = form.documentId;
|
||||
emit(
|
||||
FormI9State(
|
||||
firstName: data['firstName'] as String? ?? '',
|
||||
@@ -122,10 +121,11 @@ class FormI9Cubit extends Cubit<FormI9State> with BlocErrorHandler<FormI9State>
|
||||
'signature': state.signature,
|
||||
};
|
||||
|
||||
final I9TaxForm form = I9TaxForm(
|
||||
id: _formId.isNotEmpty ? _formId : const Uuid().v4(),
|
||||
title: 'Form I-9',
|
||||
formData: formData,
|
||||
final TaxForm form = TaxForm(
|
||||
documentId: _documentId,
|
||||
formType: 'I-9',
|
||||
status: TaxFormStatus.submitted,
|
||||
fields: formData,
|
||||
);
|
||||
|
||||
await _submitI9FormUseCase(form);
|
||||
@@ -139,4 +139,3 @@ class FormI9Cubit extends Cubit<FormI9State> with BlocErrorHandler<FormI9State>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../../../domain/usecases/submit_w4_form_usecase.dart';
|
||||
import 'form_w4_state.dart';
|
||||
@@ -10,16 +9,16 @@ class FormW4Cubit extends Cubit<FormW4State> with BlocErrorHandler<FormW4State>
|
||||
|
||||
FormW4Cubit(this._submitW4FormUseCase) : super(const FormW4State());
|
||||
final SubmitW4FormUseCase _submitW4FormUseCase;
|
||||
String _formId = '';
|
||||
String _documentId = '';
|
||||
|
||||
void initialize(TaxForm? form) {
|
||||
if (form == null || form.formData.isEmpty) {
|
||||
if (form == null || form.fields.isEmpty) {
|
||||
emit(const FormW4State()); // Reset
|
||||
return;
|
||||
}
|
||||
|
||||
final Map<String, dynamic> data = form.formData;
|
||||
_formId = form.id;
|
||||
final Map<String, dynamic> data = form.fields;
|
||||
_documentId = form.documentId;
|
||||
|
||||
// Combine address parts if needed, or take existing
|
||||
final String city = data['city'] as String? ?? '';
|
||||
@@ -98,7 +97,7 @@ class FormW4Cubit extends Cubit<FormW4State> with BlocErrorHandler<FormW4State>
|
||||
'ssn': state.ssn,
|
||||
'address': state.address,
|
||||
'cityStateZip':
|
||||
state.cityStateZip, // Note: Repository should split this if needed.
|
||||
state.cityStateZip,
|
||||
'filingStatus': state.filingStatus,
|
||||
'multipleJobs': state.multipleJobs,
|
||||
'qualifyingChildren': state.qualifyingChildren,
|
||||
@@ -109,10 +108,11 @@ class FormW4Cubit extends Cubit<FormW4State> with BlocErrorHandler<FormW4State>
|
||||
'signature': state.signature,
|
||||
};
|
||||
|
||||
final W4TaxForm form = W4TaxForm(
|
||||
id: _formId.isNotEmpty ? _formId : const Uuid().v4(),
|
||||
title: 'Form W-4',
|
||||
formData: formData,
|
||||
final TaxForm form = TaxForm(
|
||||
documentId: _documentId,
|
||||
formType: 'W-4',
|
||||
status: TaxFormStatus.submitted,
|
||||
fields: formData,
|
||||
);
|
||||
|
||||
await _submitW4FormUseCase(form);
|
||||
@@ -126,4 +126,3 @@ class FormW4Cubit extends Cubit<FormW4State> with BlocErrorHandler<FormW4State>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -82,22 +82,15 @@ class TaxFormsPage extends StatelessWidget {
|
||||
return TaxFormCard(
|
||||
form: form,
|
||||
onTap: () async {
|
||||
if (form is I9TaxForm) {
|
||||
final Object? result = await Modular.to.pushNamed(
|
||||
'i9',
|
||||
arguments: form,
|
||||
);
|
||||
if (result == true && context.mounted) {
|
||||
await BlocProvider.of<TaxFormsCubit>(context).loadTaxForms();
|
||||
}
|
||||
} else if (form is W4TaxForm) {
|
||||
final Object? result = await Modular.to.pushNamed(
|
||||
'w4',
|
||||
arguments: form,
|
||||
);
|
||||
if (result == true && context.mounted) {
|
||||
await BlocProvider.of<TaxFormsCubit>(context).loadTaxForms();
|
||||
}
|
||||
final bool isI9 = form.formType.toUpperCase().contains('I-9') ||
|
||||
form.formType.toUpperCase().contains('I9');
|
||||
final String route = isI9 ? 'i9' : 'w4';
|
||||
final Object? result = await Modular.to.pushNamed(
|
||||
route,
|
||||
arguments: form,
|
||||
);
|
||||
if (result == true && context.mounted) {
|
||||
await BlocProvider.of<TaxFormsCubit>(context).loadTaxForms();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -14,7 +14,8 @@ class TaxFormCard extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Helper to get icon based on type
|
||||
final String icon = form is I9TaxForm ? '🛂' : '📋';
|
||||
final bool isI9 = form.formType.toUpperCase().contains('I-9') ||
|
||||
form.formType.toUpperCase().contains('I9');
|
||||
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
@@ -35,7 +36,13 @@ class TaxFormCard extends StatelessWidget {
|
||||
color: UiColors.primary.withValues(alpha: 0.1),
|
||||
borderRadius: UiConstants.radiusLg,
|
||||
),
|
||||
child: Center(child: Text(icon, style: UiTypography.headline1m)),
|
||||
child: Center(
|
||||
child: Icon(
|
||||
isI9 ? UiIcons.fileCheck : UiIcons.file,
|
||||
color: UiColors.primary,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UiConstants.space4),
|
||||
Expanded(
|
||||
@@ -46,7 +53,7 @@ class TaxFormCard extends StatelessWidget {
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
form.title,
|
||||
'Form ${form.formType}',
|
||||
style: UiTypography.headline4m.textPrimary,
|
||||
),
|
||||
TaxFormStatusBadge(status: form.status),
|
||||
@@ -54,11 +61,9 @@ class TaxFormCard extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: UiConstants.space1),
|
||||
Text(
|
||||
form.subtitle ?? '',
|
||||
style: UiTypography.body3r.textSecondary,
|
||||
),
|
||||
Text(
|
||||
form.description ?? '',
|
||||
isI9
|
||||
? 'Employment Eligibility Verification'
|
||||
: 'Employee Withholding Certificate',
|
||||
style: UiTypography.body3r.textSecondary,
|
||||
),
|
||||
],
|
||||
|
||||
@@ -1,22 +1,33 @@
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import 'data/repositories/tax_forms_repository_impl.dart';
|
||||
import 'domain/repositories/tax_forms_repository.dart';
|
||||
import 'domain/usecases/get_tax_forms_usecase.dart';
|
||||
import 'domain/usecases/submit_i9_form_usecase.dart';
|
||||
import 'domain/usecases/submit_w4_form_usecase.dart';
|
||||
import 'presentation/blocs/i9/form_i9_cubit.dart';
|
||||
import 'presentation/blocs/tax_forms/tax_forms_cubit.dart';
|
||||
import 'presentation/blocs/w4/form_w4_cubit.dart';
|
||||
import 'presentation/pages/form_i9_page.dart';
|
||||
import 'presentation/pages/form_w4_page.dart';
|
||||
import 'presentation/pages/tax_forms_page.dart';
|
||||
|
||||
import 'package:staff_tax_forms/src/data/repositories/tax_forms_repository_impl.dart';
|
||||
import 'package:staff_tax_forms/src/domain/repositories/tax_forms_repository.dart';
|
||||
import 'package:staff_tax_forms/src/domain/usecases/get_tax_forms_usecase.dart';
|
||||
import 'package:staff_tax_forms/src/domain/usecases/submit_i9_form_usecase.dart';
|
||||
import 'package:staff_tax_forms/src/domain/usecases/submit_w4_form_usecase.dart';
|
||||
import 'package:staff_tax_forms/src/presentation/blocs/tax_forms/tax_forms_cubit.dart';
|
||||
import 'package:staff_tax_forms/src/presentation/blocs/i9/form_i9_cubit.dart';
|
||||
import 'package:staff_tax_forms/src/presentation/blocs/w4/form_w4_cubit.dart';
|
||||
import 'package:staff_tax_forms/src/presentation/pages/form_i9_page.dart';
|
||||
import 'package:staff_tax_forms/src/presentation/pages/form_w4_page.dart';
|
||||
import 'package:staff_tax_forms/src/presentation/pages/tax_forms_page.dart';
|
||||
|
||||
/// Module for the Staff Tax Forms feature.
|
||||
///
|
||||
/// Uses the V2 REST API via [BaseApiService] for backend access.
|
||||
class StaffTaxFormsModule extends Module {
|
||||
@override
|
||||
List<Module> get imports => <Module>[CoreModule()];
|
||||
|
||||
@override
|
||||
void binds(Injector i) {
|
||||
i.addLazySingleton<TaxFormsRepository>(TaxFormsRepositoryImpl.new);
|
||||
i.addLazySingleton<TaxFormsRepository>(
|
||||
() => TaxFormsRepositoryImpl(
|
||||
apiService: i.get<BaseApiService>(),
|
||||
),
|
||||
);
|
||||
|
||||
// Use Cases
|
||||
i.addLazySingleton(GetTaxFormsUseCase.new);
|
||||
|
||||
@@ -15,9 +15,7 @@ dependencies:
|
||||
bloc: ^8.1.0
|
||||
flutter_modular: ^6.3.0
|
||||
equatable: ^2.0.5
|
||||
firebase_auth: ^6.1.4
|
||||
firebase_data_connect: ^0.2.2+2
|
||||
|
||||
|
||||
# Architecture Packages
|
||||
design_system:
|
||||
path: ../../../../../design_system
|
||||
@@ -27,5 +25,3 @@ dependencies:
|
||||
path: ../../../../../core_localization
|
||||
krow_domain:
|
||||
path: ../../../../../domain
|
||||
krow_data_connect:
|
||||
path: ../../../../../data_connect
|
||||
|
||||
@@ -1,83 +1,34 @@
|
||||
import 'package:firebase_data_connect/firebase_data_connect.dart';
|
||||
import 'package:krow_data_connect/krow_data_connect.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import '../../domain/repositories/bank_account_repository.dart';
|
||||
|
||||
/// Implementation of [BankAccountRepository] that integrates with Data Connect.
|
||||
import 'package:staff_bank_account/src/domain/repositories/bank_account_repository.dart';
|
||||
|
||||
/// Implementation of [BankAccountRepository] using the V2 API.
|
||||
///
|
||||
/// Replaces the previous Firebase Data Connect implementation.
|
||||
class BankAccountRepositoryImpl implements BankAccountRepository {
|
||||
/// Creates a [BankAccountRepositoryImpl].
|
||||
BankAccountRepositoryImpl({
|
||||
DataConnectService? service,
|
||||
}) : _service = service ?? DataConnectService.instance;
|
||||
BankAccountRepositoryImpl({required BaseApiService apiService})
|
||||
: _api = apiService;
|
||||
|
||||
/// The Data Connect service.
|
||||
final DataConnectService _service;
|
||||
final BaseApiService _api;
|
||||
|
||||
@override
|
||||
Future<List<StaffBankAccount>> getAccounts() async {
|
||||
return _service.run(() async {
|
||||
final String staffId = await _service.getStaffId();
|
||||
|
||||
final QueryResult<GetAccountsByOwnerIdData, GetAccountsByOwnerIdVariables>
|
||||
result = await _service.connector
|
||||
.getAccountsByOwnerId(ownerId: staffId)
|
||||
.execute();
|
||||
|
||||
return result.data.accounts.map((GetAccountsByOwnerIdAccounts account) {
|
||||
return BankAccountAdapter.fromPrimitives(
|
||||
id: account.id,
|
||||
userId: account.ownerId,
|
||||
bankName: account.bank,
|
||||
accountNumber: account.accountNumber,
|
||||
last4: account.last4,
|
||||
sortCode: account.routeNumber,
|
||||
type: account.type is Known<AccountType>
|
||||
? (account.type as Known<AccountType>).value.name
|
||||
: null,
|
||||
isPrimary: account.isPrimary,
|
||||
);
|
||||
}).toList();
|
||||
});
|
||||
Future<List<BankAccount>> getAccounts() async {
|
||||
final ApiResponse response =
|
||||
await _api.get(V2ApiEndpoints.staffBankAccounts);
|
||||
final List<dynamic> items = response.data['accounts'] as List<dynamic>;
|
||||
return items
|
||||
.map((dynamic json) =>
|
||||
BankAccount.fromJson(json as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> addAccount(StaffBankAccount account) async {
|
||||
return _service.run(() async {
|
||||
final String staffId = await _service.getStaffId();
|
||||
|
||||
final QueryResult<GetAccountsByOwnerIdData, GetAccountsByOwnerIdVariables>
|
||||
existingAccounts = await _service.connector
|
||||
.getAccountsByOwnerId(ownerId: staffId)
|
||||
.execute();
|
||||
final bool hasAccounts = existingAccounts.data.accounts.isNotEmpty;
|
||||
final bool isPrimary = !hasAccounts;
|
||||
|
||||
await _service.connector
|
||||
.createAccount(
|
||||
bank: account.bankName,
|
||||
type: AccountType.values
|
||||
.byName(BankAccountAdapter.typeToString(account.type)),
|
||||
last4: _safeLast4(account.last4, account.accountNumber),
|
||||
ownerId: staffId,
|
||||
)
|
||||
.isPrimary(isPrimary)
|
||||
.accountNumber(account.accountNumber)
|
||||
.routeNumber(account.sortCode)
|
||||
.execute();
|
||||
});
|
||||
}
|
||||
|
||||
/// Ensures we have a last4 value, either from input or derived from account number.
|
||||
String _safeLast4(String? last4, String accountNumber) {
|
||||
if (last4 != null && last4.isNotEmpty) {
|
||||
return last4;
|
||||
}
|
||||
if (accountNumber.isEmpty) {
|
||||
return '0000';
|
||||
}
|
||||
if (accountNumber.length < 4) {
|
||||
return accountNumber.padLeft(4, '0');
|
||||
}
|
||||
return accountNumber.substring(accountNumber.length - 4);
|
||||
Future<void> addAccount(BankAccount account) async {
|
||||
await _api.post(
|
||||
V2ApiEndpoints.staffBankAccounts,
|
||||
data: account.toJson(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,11 +6,11 @@ import 'package:krow_domain/krow_domain.dart';
|
||||
class AddBankAccountParams extends UseCaseArgument with EquatableMixin {
|
||||
|
||||
const AddBankAccountParams({required this.account});
|
||||
final StaffBankAccount account;
|
||||
final BankAccount account;
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[account];
|
||||
|
||||
|
||||
@override
|
||||
bool? get stringify => true;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
/// Repository interface for managing bank accounts.
|
||||
///
|
||||
/// Uses [BankAccount] from the V2 domain layer.
|
||||
abstract class BankAccountRepository {
|
||||
/// Fetches the list of bank accounts for the current user.
|
||||
Future<List<StaffBankAccount>> getAccounts();
|
||||
/// Fetches the list of bank accounts for the current staff member.
|
||||
Future<List<BankAccount>> getAccounts();
|
||||
|
||||
/// adds a new bank account.
|
||||
Future<void> addAccount(StaffBankAccount account);
|
||||
/// Adds a new bank account.
|
||||
Future<void> addAccount(BankAccount account);
|
||||
}
|
||||
|
||||
@@ -3,13 +3,13 @@ import 'package:krow_domain/krow_domain.dart';
|
||||
import '../repositories/bank_account_repository.dart';
|
||||
|
||||
/// Use case to fetch bank accounts.
|
||||
class GetBankAccountsUseCase implements NoInputUseCase<List<StaffBankAccount>> {
|
||||
class GetBankAccountsUseCase implements NoInputUseCase<List<BankAccount>> {
|
||||
|
||||
GetBankAccountsUseCase(this._repository);
|
||||
final BankAccountRepository _repository;
|
||||
|
||||
@override
|
||||
Future<List<StaffBankAccount>> call() {
|
||||
Future<List<BankAccount>> call() {
|
||||
return _repository.getAccounts();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ class BankAccountCubit extends Cubit<BankAccountState>
|
||||
await handleError(
|
||||
emit: emit,
|
||||
action: () async {
|
||||
final List<StaffBankAccount> accounts = await _getBankAccountsUseCase();
|
||||
final List<BankAccount> accounts = await _getBankAccountsUseCase();
|
||||
emit(
|
||||
state.copyWith(status: BankAccountStatus.loaded, accounts: accounts),
|
||||
);
|
||||
@@ -48,19 +48,17 @@ class BankAccountCubit extends Cubit<BankAccountState>
|
||||
emit(state.copyWith(status: BankAccountStatus.loading));
|
||||
|
||||
// Create domain entity
|
||||
final StaffBankAccount newAccount = StaffBankAccount(
|
||||
id: '', // Generated by server usually
|
||||
userId: '', // Handled by Repo/Auth
|
||||
final BankAccount newAccount = BankAccount(
|
||||
accountId: '', // Generated by server
|
||||
bankName: bankName,
|
||||
accountNumber: accountNumber.length > 4
|
||||
providerReference: routingNumber,
|
||||
last4: accountNumber.length > 4
|
||||
? accountNumber.substring(accountNumber.length - 4)
|
||||
: accountNumber,
|
||||
accountName: '',
|
||||
sortCode: routingNumber,
|
||||
type: type == 'CHECKING'
|
||||
? StaffBankAccountType.checking
|
||||
: StaffBankAccountType.savings,
|
||||
isPrimary: false,
|
||||
accountType: type == 'CHECKING'
|
||||
? AccountType.checking
|
||||
: AccountType.savings,
|
||||
);
|
||||
|
||||
await handleError(
|
||||
|
||||
@@ -7,18 +7,18 @@ class BankAccountState extends Equatable {
|
||||
|
||||
const BankAccountState({
|
||||
this.status = BankAccountStatus.initial,
|
||||
this.accounts = const <StaffBankAccount>[],
|
||||
this.accounts = const <BankAccount>[],
|
||||
this.errorMessage,
|
||||
this.showForm = false,
|
||||
});
|
||||
final BankAccountStatus status;
|
||||
final List<StaffBankAccount> accounts;
|
||||
final List<BankAccount> accounts;
|
||||
final String? errorMessage;
|
||||
final bool showForm;
|
||||
|
||||
BankAccountState copyWith({
|
||||
BankAccountStatus? status,
|
||||
List<StaffBankAccount>? accounts,
|
||||
List<BankAccount>? accounts,
|
||||
String? errorMessage,
|
||||
bool? showForm,
|
||||
}) {
|
||||
|
||||
@@ -90,7 +90,7 @@ class BankAccountPage extends StatelessWidget {
|
||||
] else ...<Widget>[
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
...state.accounts.map<Widget>(
|
||||
(StaffBankAccount account) =>
|
||||
(BankAccount account) =>
|
||||
AccountCard(account: account, strings: strings),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -3,7 +3,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
class AccountCard extends StatelessWidget {
|
||||
final StaffBankAccount account;
|
||||
final BankAccount account;
|
||||
final dynamic strings;
|
||||
|
||||
const AccountCard({
|
||||
|
||||
@@ -1,28 +1,35 @@
|
||||
import 'package:flutter/widgets.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';
|
||||
|
||||
import 'package:staff_bank_account/src/data/repositories/bank_account_repository_impl.dart';
|
||||
import 'package:staff_bank_account/src/domain/repositories/bank_account_repository.dart';
|
||||
import 'package:staff_bank_account/src/domain/usecases/add_bank_account_usecase.dart';
|
||||
import 'package:staff_bank_account/src/domain/usecases/get_bank_accounts_usecase.dart';
|
||||
import 'package:staff_bank_account/src/presentation/blocs/bank_account_cubit.dart';
|
||||
import 'package:staff_bank_account/src/presentation/pages/bank_account_page.dart';
|
||||
|
||||
import 'domain/repositories/bank_account_repository.dart';
|
||||
import 'domain/usecases/add_bank_account_usecase.dart';
|
||||
import 'domain/usecases/get_bank_accounts_usecase.dart';
|
||||
import 'presentation/blocs/bank_account_cubit.dart';
|
||||
import 'presentation/pages/bank_account_page.dart';
|
||||
|
||||
/// Module for the Staff Bank Account feature.
|
||||
///
|
||||
/// Uses the V2 REST API via [BaseApiService] for backend access.
|
||||
class StaffBankAccountModule extends Module {
|
||||
@override
|
||||
List<Module> get imports => <Module>[DataConnectModule()];
|
||||
|
||||
List<Module> get imports => <Module>[CoreModule()];
|
||||
|
||||
@override
|
||||
void binds(Injector i) {
|
||||
// Repositories
|
||||
i.addLazySingleton<BankAccountRepository>(BankAccountRepositoryImpl.new);
|
||||
|
||||
i.addLazySingleton<BankAccountRepository>(
|
||||
() => BankAccountRepositoryImpl(
|
||||
apiService: i.get<BaseApiService>(),
|
||||
),
|
||||
);
|
||||
|
||||
// Use Cases
|
||||
i.addLazySingleton<GetBankAccountsUseCase>(GetBankAccountsUseCase.new);
|
||||
i.addLazySingleton<AddBankAccountUseCase>(AddBankAccountUseCase.new);
|
||||
|
||||
|
||||
// Blocs
|
||||
i.add<BankAccountCubit>(
|
||||
() => BankAccountCubit(
|
||||
|
||||
@@ -15,9 +15,7 @@ dependencies:
|
||||
bloc: ^8.1.0
|
||||
flutter_modular: ^6.3.0
|
||||
equatable: ^2.0.5
|
||||
firebase_auth: ^6.1.4
|
||||
firebase_data_connect: ^0.2.2+2
|
||||
|
||||
|
||||
# Architecture Packages
|
||||
design_system:
|
||||
path: ../../../../../design_system
|
||||
@@ -27,8 +25,6 @@ dependencies:
|
||||
path: ../../../../../core
|
||||
krow_domain:
|
||||
path: ../../../../../domain
|
||||
krow_data_connect:
|
||||
path: ../../../../../data_connect
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
||||
@@ -1,74 +1,31 @@
|
||||
import 'package:firebase_data_connect/firebase_data_connect.dart' as fdc;
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:krow_data_connect/krow_data_connect.dart' as dc;
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
// ignore: implementation_imports
|
||||
import 'package:krow_domain/src/adapters/financial/time_card_adapter.dart';
|
||||
import '../../domain/repositories/time_card_repository.dart';
|
||||
|
||||
/// Implementation of [TimeCardRepository] using Firebase Data Connect.
|
||||
import 'package:staff_time_card/src/domain/repositories/time_card_repository.dart';
|
||||
|
||||
/// Implementation of [TimeCardRepository] using the V2 API.
|
||||
///
|
||||
/// Replaces the previous Firebase Data Connect implementation.
|
||||
class TimeCardRepositoryImpl implements TimeCardRepository {
|
||||
|
||||
/// Creates a [TimeCardRepositoryImpl].
|
||||
TimeCardRepositoryImpl({dc.DataConnectService? service})
|
||||
: _service = service ?? dc.DataConnectService.instance;
|
||||
final dc.DataConnectService _service;
|
||||
TimeCardRepositoryImpl({required BaseApiService apiService})
|
||||
: _api = apiService;
|
||||
|
||||
final BaseApiService _api;
|
||||
|
||||
@override
|
||||
Future<List<TimeCard>> getTimeCards(DateTime month) async {
|
||||
return _service.run(() async {
|
||||
final String staffId = await _service.getStaffId();
|
||||
// Fetch applications. Limit can be adjusted, assuming 100 is safe for now.
|
||||
final fdc.QueryResult<dc.GetApplicationsByStaffIdData,
|
||||
dc.GetApplicationsByStaffIdVariables> result =
|
||||
await _service.connector
|
||||
.getApplicationsByStaffId(staffId: staffId)
|
||||
.limit(100)
|
||||
.execute();
|
||||
|
||||
return result.data.applications
|
||||
.where((dc.GetApplicationsByStaffIdApplications app) {
|
||||
final DateTime? shiftDate = _service.toDateTime(app.shift.date);
|
||||
if (shiftDate == null) return false;
|
||||
return shiftDate.year == month.year &&
|
||||
shiftDate.month == month.month;
|
||||
})
|
||||
.map((dc.GetApplicationsByStaffIdApplications app) {
|
||||
final DateTime shiftDate = _service.toDateTime(app.shift.date)!;
|
||||
final String startTime = _formatTime(app.checkInTime) ??
|
||||
_formatTime(app.shift.startTime) ??
|
||||
'';
|
||||
final String endTime = _formatTime(app.checkOutTime) ??
|
||||
_formatTime(app.shift.endTime) ??
|
||||
'';
|
||||
|
||||
// Prefer shiftRole values for pay/hours
|
||||
final double hours = app.shiftRole.hours ?? 0.0;
|
||||
final double rate = app.shiftRole.role.costPerHour;
|
||||
final double pay = app.shiftRole.totalValue ?? 0.0;
|
||||
|
||||
return TimeCardAdapter.fromPrimitives(
|
||||
id: app.id,
|
||||
shiftTitle: app.shift.title,
|
||||
clientName: app.shift.order.business.businessName,
|
||||
date: shiftDate,
|
||||
startTime: startTime,
|
||||
endTime: endTime,
|
||||
totalHours: hours,
|
||||
hourlyRate: rate,
|
||||
totalPay: pay,
|
||||
status: app.status.stringValue,
|
||||
location: app.shift.location,
|
||||
);
|
||||
})
|
||||
.toList();
|
||||
});
|
||||
}
|
||||
|
||||
String? _formatTime(fdc.Timestamp? timestamp) {
|
||||
if (timestamp == null) return null;
|
||||
final DateTime? dt = _service.toDateTime(timestamp);
|
||||
if (dt == null) return null;
|
||||
return DateFormat('HH:mm').format(dt);
|
||||
Future<List<TimeCardEntry>> getTimeCards(DateTime month) async {
|
||||
final ApiResponse response = await _api.get(
|
||||
V2ApiEndpoints.staffTimeCard,
|
||||
params: <String, dynamic>{
|
||||
'year': month.year,
|
||||
'month': month.month,
|
||||
},
|
||||
);
|
||||
final List<dynamic> items = response.data['entries'] as List<dynamic>;
|
||||
return items
|
||||
.map((dynamic json) =>
|
||||
TimeCardEntry.fromJson(json as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,11 +2,10 @@ import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
/// Repository interface for accessing time card data.
|
||||
///
|
||||
/// This repository handles fetching time cards and related financial data
|
||||
/// for the staff member.
|
||||
/// Uses [TimeCardEntry] from the V2 domain layer.
|
||||
abstract class TimeCardRepository {
|
||||
/// Retrieves a list of [TimeCard]s for a specific month.
|
||||
/// Retrieves a list of [TimeCardEntry]s for a specific month.
|
||||
///
|
||||
/// [month] is a [DateTime] representing the month to filter by.
|
||||
Future<List<TimeCard>> getTimeCards(DateTime month);
|
||||
Future<List<TimeCardEntry>> getTimeCards(DateTime month);
|
||||
}
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import '../arguments/get_time_cards_arguments.dart';
|
||||
import '../repositories/time_card_repository.dart';
|
||||
|
||||
/// UseCase to retrieve time cards for a given month.
|
||||
class GetTimeCardsUseCase extends UseCase<GetTimeCardsArguments, List<TimeCard>> {
|
||||
import 'package:staff_time_card/src/domain/arguments/get_time_cards_arguments.dart';
|
||||
import 'package:staff_time_card/src/domain/repositories/time_card_repository.dart';
|
||||
|
||||
/// UseCase to retrieve time card entries for a given month.
|
||||
///
|
||||
/// Uses [TimeCardEntry] from the V2 domain layer.
|
||||
class GetTimeCardsUseCase
|
||||
extends UseCase<GetTimeCardsArguments, List<TimeCardEntry>> {
|
||||
/// Creates a [GetTimeCardsUseCase].
|
||||
GetTimeCardsUseCase(this.repository);
|
||||
|
||||
/// The time card repository.
|
||||
final TimeCardRepository repository;
|
||||
|
||||
/// Executes the use case.
|
||||
///
|
||||
/// Returns a list of [TimeCard]s for the specified month in [arguments].
|
||||
@override
|
||||
Future<List<TimeCard>> call(GetTimeCardsArguments arguments) {
|
||||
Future<List<TimeCardEntry>> call(GetTimeCardsArguments arguments) {
|
||||
return repository.getTimeCards(arguments.month);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,20 +2,25 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import '../../domain/arguments/get_time_cards_arguments.dart';
|
||||
import '../../domain/usecases/get_time_cards_usecase.dart';
|
||||
|
||||
import 'package:staff_time_card/src/domain/arguments/get_time_cards_arguments.dart';
|
||||
import 'package:staff_time_card/src/domain/usecases/get_time_cards_usecase.dart';
|
||||
|
||||
part 'time_card_event.dart';
|
||||
part 'time_card_state.dart';
|
||||
|
||||
/// BLoC to manage Time Card state.
|
||||
///
|
||||
/// Uses V2 API [TimeCardEntry] entities.
|
||||
class TimeCardBloc extends Bloc<TimeCardEvent, TimeCardState>
|
||||
with BlocErrorHandler<TimeCardState> {
|
||||
|
||||
/// Creates a [TimeCardBloc].
|
||||
TimeCardBloc({required this.getTimeCards}) : super(TimeCardInitial()) {
|
||||
on<LoadTimeCards>(_onLoadTimeCards);
|
||||
on<ChangeMonth>(_onChangeMonth);
|
||||
}
|
||||
|
||||
/// The use case for fetching time card entries.
|
||||
final GetTimeCardsUseCase getTimeCards;
|
||||
|
||||
/// Handles fetching time cards for the requested month.
|
||||
@@ -27,17 +32,17 @@ class TimeCardBloc extends Bloc<TimeCardEvent, TimeCardState>
|
||||
await handleError(
|
||||
emit: emit.call,
|
||||
action: () async {
|
||||
final List<TimeCard> cards = await getTimeCards(
|
||||
final List<TimeCardEntry> cards = await getTimeCards(
|
||||
GetTimeCardsArguments(event.month),
|
||||
);
|
||||
|
||||
final double totalHours = cards.fold(
|
||||
0.0,
|
||||
(double sum, TimeCard t) => sum + t.totalHours,
|
||||
(double sum, TimeCardEntry t) => sum + t.minutesWorked / 60.0,
|
||||
);
|
||||
final double totalEarnings = cards.fold(
|
||||
0.0,
|
||||
(double sum, TimeCard t) => sum + t.totalPay,
|
||||
(double sum, TimeCardEntry t) => sum + t.totalPayCents / 100.0,
|
||||
);
|
||||
|
||||
emit(
|
||||
@@ -53,6 +58,7 @@ class TimeCardBloc extends Bloc<TimeCardEvent, TimeCardState>
|
||||
);
|
||||
}
|
||||
|
||||
/// Handles changing the selected month.
|
||||
Future<void> _onChangeMonth(
|
||||
ChangeMonth event,
|
||||
Emitter<TimeCardState> emit,
|
||||
@@ -60,4 +66,3 @@ class TimeCardBloc extends Bloc<TimeCardEvent, TimeCardState>
|
||||
add(LoadTimeCards(event.month));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,32 +1,54 @@
|
||||
part of 'time_card_bloc.dart';
|
||||
|
||||
/// Base class for time card states.
|
||||
abstract class TimeCardState extends Equatable {
|
||||
/// Creates a [TimeCardState].
|
||||
const TimeCardState();
|
||||
@override
|
||||
List<Object?> get props => <Object?>[];
|
||||
}
|
||||
|
||||
/// Initial state before any data is loaded.
|
||||
class TimeCardInitial extends TimeCardState {}
|
||||
class TimeCardLoading extends TimeCardState {}
|
||||
class TimeCardLoaded extends TimeCardState {
|
||||
|
||||
/// Loading state while data is being fetched.
|
||||
class TimeCardLoading extends TimeCardState {}
|
||||
|
||||
/// Loaded state with time card entries and computed totals.
|
||||
class TimeCardLoaded extends TimeCardState {
|
||||
/// Creates a [TimeCardLoaded].
|
||||
const TimeCardLoaded({
|
||||
required this.timeCards,
|
||||
required this.selectedMonth,
|
||||
required this.totalHours,
|
||||
required this.totalEarnings,
|
||||
});
|
||||
final List<TimeCard> timeCards;
|
||||
|
||||
/// The list of time card entries for the selected month.
|
||||
final List<TimeCardEntry> timeCards;
|
||||
|
||||
/// The currently selected month.
|
||||
final DateTime selectedMonth;
|
||||
|
||||
/// Total hours worked in the selected month.
|
||||
final double totalHours;
|
||||
|
||||
/// Total earnings in the selected month (in dollars).
|
||||
final double totalEarnings;
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[timeCards, selectedMonth, totalHours, totalEarnings];
|
||||
List<Object?> get props =>
|
||||
<Object?>[timeCards, selectedMonth, totalHours, totalEarnings];
|
||||
}
|
||||
|
||||
/// Error state when loading fails.
|
||||
class TimeCardError extends TimeCardState {
|
||||
/// Creates a [TimeCardError].
|
||||
const TimeCardError(this.message);
|
||||
|
||||
/// The error message.
|
||||
final String message;
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[message];
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import 'timesheet_card.dart';
|
||||
class ShiftHistoryList extends StatelessWidget {
|
||||
|
||||
const ShiftHistoryList({super.key, required this.timesheets});
|
||||
final List<TimeCard> timesheets;
|
||||
final List<TimeCardEntry> timesheets;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -39,7 +39,7 @@ class ShiftHistoryList extends StatelessWidget {
|
||||
),
|
||||
)
|
||||
else
|
||||
...timesheets.map((TimeCard ts) => TimesheetCard(timesheet: ts)),
|
||||
...timesheets.map((TimeCardEntry ts) => TimesheetCard(timesheet: ts)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,39 +8,16 @@ import 'package:krow_domain/krow_domain.dart';
|
||||
class TimesheetCard extends StatelessWidget {
|
||||
|
||||
const TimesheetCard({super.key, required this.timesheet});
|
||||
final TimeCard timesheet;
|
||||
final TimeCardEntry timesheet;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final TimeCardStatus status = timesheet.status;
|
||||
Color statusBg;
|
||||
Color statusColor;
|
||||
String statusText;
|
||||
|
||||
switch (status) {
|
||||
case TimeCardStatus.approved:
|
||||
statusBg = UiColors.tagSuccess;
|
||||
statusColor = UiColors.textSuccess;
|
||||
statusText = t.staff_time_card.status.approved;
|
||||
break;
|
||||
case TimeCardStatus.disputed:
|
||||
statusBg = UiColors.destructive.withValues(alpha: 0.12);
|
||||
statusColor = UiColors.destructive;
|
||||
statusText = t.staff_time_card.status.disputed;
|
||||
break;
|
||||
case TimeCardStatus.paid:
|
||||
statusBg = UiColors.primary.withValues(alpha: 0.12);
|
||||
statusColor = UiColors.primary;
|
||||
statusText = t.staff_time_card.status.paid;
|
||||
break;
|
||||
case TimeCardStatus.pending:
|
||||
statusBg = UiColors.tagPending;
|
||||
statusColor = UiColors.textWarning;
|
||||
statusText = t.staff_time_card.status.pending;
|
||||
break;
|
||||
}
|
||||
|
||||
final String dateStr = DateFormat('EEE, MMM d').format(timesheet.date);
|
||||
final double totalHours = timesheet.minutesWorked / 60.0;
|
||||
final double totalPay = timesheet.totalPayCents / 100.0;
|
||||
final double hourlyRate = timesheet.hourlyRateCents != null
|
||||
? timesheet.hourlyRateCents! / 100.0
|
||||
: 0.0;
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: UiConstants.space3),
|
||||
@@ -56,33 +33,20 @@ class TimesheetCard extends StatelessWidget {
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
timesheet.shiftTitle,
|
||||
style: UiTypography.body1m.textPrimary,
|
||||
),
|
||||
Text(
|
||||
timesheet.clientName,
|
||||
style: UiTypography.body2r.textSecondary,
|
||||
),
|
||||
],
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UiConstants.space2,
|
||||
vertical: UiConstants.space1,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: statusBg,
|
||||
borderRadius: UiConstants.radiusFull,
|
||||
),
|
||||
child: Text(
|
||||
statusText,
|
||||
style: UiTypography.titleUppercase4b.copyWith(
|
||||
color: statusColor,
|
||||
),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
timesheet.shiftName,
|
||||
style: UiTypography.body1m.textPrimary,
|
||||
),
|
||||
if (timesheet.location != null)
|
||||
Text(
|
||||
timesheet.location!,
|
||||
style: UiTypography.body2r.textSecondary,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -93,10 +57,11 @@ class TimesheetCard extends StatelessWidget {
|
||||
runSpacing: UiConstants.space1,
|
||||
children: <Widget>[
|
||||
_IconText(icon: UiIcons.calendar, text: dateStr),
|
||||
_IconText(
|
||||
icon: UiIcons.clock,
|
||||
text: '${_formatTime(timesheet.startTime)} - ${_formatTime(timesheet.endTime)}',
|
||||
),
|
||||
if (timesheet.clockInAt != null && timesheet.clockOutAt != null)
|
||||
_IconText(
|
||||
icon: UiIcons.clock,
|
||||
text: '${DateFormat('h:mm a').format(timesheet.clockInAt!)} - ${DateFormat('h:mm a').format(timesheet.clockOutAt!)}',
|
||||
),
|
||||
if (timesheet.location != null)
|
||||
_IconText(icon: UiIcons.mapPin, text: timesheet.location!),
|
||||
],
|
||||
@@ -111,11 +76,11 @@ class TimesheetCard extends StatelessWidget {
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
'${timesheet.totalHours.toStringAsFixed(1)} ${t.staff_time_card.hours} @ \$${timesheet.hourlyRate.toStringAsFixed(2)}${t.staff_time_card.per_hr}',
|
||||
'${totalHours.toStringAsFixed(1)} ${t.staff_time_card.hours} @ \$${hourlyRate.toStringAsFixed(2)}${t.staff_time_card.per_hr}',
|
||||
style: UiTypography.body2r.textSecondary,
|
||||
),
|
||||
Text(
|
||||
'\$${timesheet.totalPay.toStringAsFixed(2)}',
|
||||
'\$${totalPay.toStringAsFixed(2)}',
|
||||
style: UiTypography.title2b.primary,
|
||||
),
|
||||
],
|
||||
@@ -125,21 +90,6 @@ class TimesheetCard extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Helper to safely format time strings like "HH:mm"
|
||||
String _formatTime(String t) {
|
||||
if (t.isEmpty) return '--:--';
|
||||
try {
|
||||
final List<String> parts = t.split(':');
|
||||
if (parts.length >= 2) {
|
||||
final DateTime dt = DateTime(2000, 1, 1, int.parse(parts[0]), int.parse(parts[1]));
|
||||
return DateFormat('h:mm a').format(dt);
|
||||
}
|
||||
return t;
|
||||
} catch (_) {
|
||||
return t;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _IconText extends StatelessWidget {
|
||||
|
||||
@@ -3,28 +3,31 @@ library;
|
||||
import 'package:flutter/widgets.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';
|
||||
|
||||
import 'data/repositories_impl/time_card_repository_impl.dart';
|
||||
import 'domain/repositories/time_card_repository.dart';
|
||||
import 'domain/usecases/get_time_cards_usecase.dart';
|
||||
import 'presentation/blocs/time_card_bloc.dart';
|
||||
import 'presentation/pages/time_card_page.dart';
|
||||
import 'package:staff_time_card/src/data/repositories_impl/time_card_repository_impl.dart';
|
||||
import 'package:staff_time_card/src/domain/repositories/time_card_repository.dart';
|
||||
import 'package:staff_time_card/src/domain/usecases/get_time_cards_usecase.dart';
|
||||
import 'package:staff_time_card/src/presentation/blocs/time_card_bloc.dart';
|
||||
import 'package:staff_time_card/src/presentation/pages/time_card_page.dart';
|
||||
|
||||
export 'presentation/pages/time_card_page.dart';
|
||||
export 'package:staff_time_card/src/presentation/pages/time_card_page.dart';
|
||||
|
||||
/// Module for the Staff Time Card feature.
|
||||
///
|
||||
/// This module configures dependency injection for accessing time card data,
|
||||
/// including the repositories, use cases, and BLoCs.
|
||||
/// Uses the V2 REST API via [BaseApiService] for backend access.
|
||||
class StaffTimeCardModule extends Module {
|
||||
@override
|
||||
List<Module> get imports => <Module>[DataConnectModule()];
|
||||
List<Module> get imports => <Module>[CoreModule()];
|
||||
|
||||
@override
|
||||
void binds(Injector i) {
|
||||
// Repositories
|
||||
i.addLazySingleton<TimeCardRepository>(TimeCardRepositoryImpl.new);
|
||||
i.addLazySingleton<TimeCardRepository>(
|
||||
() => TimeCardRepositoryImpl(
|
||||
apiService: i.get<BaseApiService>(),
|
||||
),
|
||||
);
|
||||
|
||||
// UseCases
|
||||
i.add<GetTimeCardsUseCase>(GetTimeCardsUseCase.new);
|
||||
|
||||
@@ -23,8 +23,6 @@ dependencies:
|
||||
path: ../../../../../core
|
||||
krow_domain:
|
||||
path: ../../../../../domain
|
||||
krow_data_connect:
|
||||
path: ../../../../../data_connect
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
||||
@@ -13,16 +13,19 @@ import 'domain/usecases/upload_attire_photo_usecase.dart';
|
||||
import 'presentation/pages/attire_capture_page.dart';
|
||||
import 'presentation/pages/attire_page.dart';
|
||||
|
||||
/// Module for the Staff Attire feature.
|
||||
///
|
||||
/// Uses the V2 REST API via [BaseApiService] for backend access.
|
||||
class StaffAttireModule extends Module {
|
||||
@override
|
||||
List<Module> get imports => <Module>[CoreModule()];
|
||||
|
||||
@override
|
||||
void binds(Injector i) {
|
||||
/// third party services
|
||||
/// Third party services.
|
||||
i.addLazySingleton<ImagePicker>(ImagePicker.new);
|
||||
|
||||
/// local services
|
||||
/// Local services.
|
||||
i.addLazySingleton<CameraService>(
|
||||
() => CameraService(i.get<ImagePicker>()),
|
||||
);
|
||||
@@ -30,6 +33,7 @@ class StaffAttireModule extends Module {
|
||||
// Repository
|
||||
i.addLazySingleton<AttireRepository>(
|
||||
() => AttireRepositoryImpl(
|
||||
apiService: i.get<BaseApiService>(),
|
||||
uploadService: i.get<FileUploadService>(),
|
||||
signedUrlService: i.get<SignedUrlService>(),
|
||||
verificationService: i.get<VerificationService>(),
|
||||
@@ -55,7 +59,7 @@ class StaffAttireModule extends Module {
|
||||
r.child(
|
||||
StaffPaths.childRoute(StaffPaths.attire, StaffPaths.attireCapture),
|
||||
child: (_) => AttireCapturePage(
|
||||
item: r.args.data['item'] as AttireItem,
|
||||
item: r.args.data['item'] as AttireChecklist,
|
||||
initialPhotoUrl: r.args.data['initialPhotoUrl'] as String?,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,36 +1,38 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_data_connect/krow_data_connect.dart'
|
||||
hide AttireVerificationStatus;
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
import '../../domain/repositories/attire_repository.dart';
|
||||
import 'package:staff_attire/src/domain/repositories/attire_repository.dart';
|
||||
|
||||
/// Implementation of [AttireRepository].
|
||||
/// Implementation of [AttireRepository] using the V2 API for reads
|
||||
/// and core services for uploads.
|
||||
///
|
||||
/// Delegates data access to [StaffConnectorRepository].
|
||||
/// Replaces the previous Firebase Data Connect / StaffConnectorRepository.
|
||||
class AttireRepositoryImpl implements AttireRepository {
|
||||
/// Creates an [AttireRepositoryImpl].
|
||||
AttireRepositoryImpl({
|
||||
required BaseApiService apiService,
|
||||
required FileUploadService uploadService,
|
||||
required SignedUrlService signedUrlService,
|
||||
required VerificationService verificationService,
|
||||
StaffConnectorRepository? connector,
|
||||
}) : _connector =
|
||||
connector ?? DataConnectService.instance.getStaffRepository(),
|
||||
_uploadService = uploadService,
|
||||
_signedUrlService = signedUrlService,
|
||||
_verificationService = verificationService;
|
||||
}) : _api = apiService,
|
||||
_uploadService = uploadService,
|
||||
_signedUrlService = signedUrlService,
|
||||
_verificationService = verificationService;
|
||||
|
||||
/// The Staff Connector repository.
|
||||
final StaffConnectorRepository _connector;
|
||||
final BaseApiService _api;
|
||||
final FileUploadService _uploadService;
|
||||
final SignedUrlService _signedUrlService;
|
||||
final VerificationService _verificationService;
|
||||
|
||||
@override
|
||||
Future<List<AttireItem>> getAttireOptions() async {
|
||||
return _connector.getAttireOptions();
|
||||
Future<List<AttireChecklist>> getAttireOptions() async {
|
||||
final ApiResponse response = await _api.get(V2ApiEndpoints.staffAttire);
|
||||
final List<dynamic> items = response.data['items'] as List<dynamic>;
|
||||
return items
|
||||
.map((dynamic json) =>
|
||||
AttireChecklist.fromJson(json as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -38,13 +40,11 @@ class AttireRepositoryImpl implements AttireRepository {
|
||||
required List<String> selectedItemIds,
|
||||
required Map<String, String> photoUrls,
|
||||
}) async {
|
||||
// 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.
|
||||
// Attire selection is saved per-item via uploadPhoto; this is a no-op.
|
||||
}
|
||||
|
||||
@override
|
||||
Future<AttireItem> uploadPhoto(String itemId, String filePath) async {
|
||||
Future<AttireChecklist> uploadPhoto(String itemId, String filePath) async {
|
||||
// 1. Upload file to Core API
|
||||
final FileUploadResponse uploadRes = await _uploadService.uploadFile(
|
||||
filePath: filePath,
|
||||
@@ -53,41 +53,40 @@ class AttireRepositoryImpl implements AttireRepository {
|
||||
|
||||
final String fileUri = uploadRes.fileUri;
|
||||
|
||||
// 2. Create signed URL for the uploaded file
|
||||
final SignedUrlResponse signedUrlRes = await _signedUrlService
|
||||
.createSignedUrl(fileUri: fileUri);
|
||||
// 2. Create signed URL
|
||||
final SignedUrlResponse signedUrlRes =
|
||||
await _signedUrlService.createSignedUrl(fileUri: fileUri);
|
||||
final String photoUrl = signedUrlRes.signedUrl;
|
||||
|
||||
// 3. Initiate verification job
|
||||
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 List<AttireChecklist> options = await getAttireOptions();
|
||||
final AttireChecklist targetItem = options.firstWhere(
|
||||
(AttireChecklist e) => e.documentId == itemId,
|
||||
orElse: () => throw UnknownException(
|
||||
technicalMessage: 'Attire item $itemId not found in checklist',
|
||||
),
|
||||
);
|
||||
final String dressCode =
|
||||
'${targetItem.description ?? ''} ${targetItem.label}'.trim();
|
||||
'${targetItem.description} ${targetItem.name}'.trim();
|
||||
|
||||
final VerificationResponse verifyRes = await _verificationService
|
||||
.createVerification(
|
||||
type: 'attire',
|
||||
subjectType: 'worker',
|
||||
subjectId: staff.id,
|
||||
fileUri: fileUri,
|
||||
rules: <String, dynamic>{'dressCode': dressCode},
|
||||
);
|
||||
final String verificationId = verifyRes.verificationId;
|
||||
final VerificationResponse verifyRes =
|
||||
await _verificationService.createVerification(
|
||||
type: 'attire',
|
||||
subjectType: 'worker',
|
||||
subjectId: itemId,
|
||||
fileUri: fileUri,
|
||||
rules: <String, dynamic>{'dressCode': dressCode},
|
||||
);
|
||||
|
||||
// 4. Poll for status until finished or timeout (max 3 seconds)
|
||||
VerificationStatus currentStatus = verifyRes.status;
|
||||
|
||||
// 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 VerificationResponse statusRes = await _verificationService
|
||||
.getStatus(verificationId);
|
||||
while (!isFinished && attempts < 3) {
|
||||
await Future<void>.delayed(const Duration(seconds: 1));
|
||||
final VerificationResponse statusRes =
|
||||
await _verificationService.getStatus(verifyRes.verificationId);
|
||||
currentStatus = statusRes.status;
|
||||
if (currentStatus != VerificationStatus.pending &&
|
||||
currentStatus != VerificationStatus.processing) {
|
||||
@@ -97,40 +96,24 @@ class AttireRepositoryImpl implements AttireRepository {
|
||||
}
|
||||
} 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,
|
||||
verificationStatus: _mapToAttireStatus(currentStatus),
|
||||
// 5. Update attire item via V2 API
|
||||
await _api.put(
|
||||
V2ApiEndpoints.staffAttireUpload(itemId),
|
||||
data: <String, dynamic>{
|
||||
'photoUrl': photoUrl,
|
||||
'verificationId': verifyRes.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);
|
||||
}
|
||||
|
||||
AttireVerificationStatus _mapToAttireStatus(VerificationStatus status) {
|
||||
switch (status) {
|
||||
case VerificationStatus.pending:
|
||||
return AttireVerificationStatus.pending;
|
||||
case VerificationStatus.processing:
|
||||
return AttireVerificationStatus.processing;
|
||||
case VerificationStatus.autoPass:
|
||||
return AttireVerificationStatus.autoPass;
|
||||
case VerificationStatus.autoFail:
|
||||
return AttireVerificationStatus.autoFail;
|
||||
case VerificationStatus.needsReview:
|
||||
return AttireVerificationStatus.needsReview;
|
||||
case VerificationStatus.approved:
|
||||
return AttireVerificationStatus.approved;
|
||||
case VerificationStatus.rejected:
|
||||
return AttireVerificationStatus.rejected;
|
||||
case VerificationStatus.error:
|
||||
return AttireVerificationStatus.error;
|
||||
}
|
||||
// 6. Return updated item by re-fetching
|
||||
final List<AttireChecklist> finalOptions = await getAttireOptions();
|
||||
return finalOptions.firstWhere(
|
||||
(AttireChecklist e) => e.documentId == itemId,
|
||||
orElse: () => throw UnknownException(
|
||||
technicalMessage: 'Attire item $itemId not found after upload',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
/// Repository interface for attire operations.
|
||||
///
|
||||
/// Uses [AttireChecklist] from the V2 domain layer.
|
||||
abstract interface class AttireRepository {
|
||||
/// Fetches the list of available attire options.
|
||||
Future<List<AttireItem>> getAttireOptions();
|
||||
/// Fetches the list of available attire checklist items from the V2 API.
|
||||
Future<List<AttireChecklist>> getAttireOptions();
|
||||
|
||||
/// Uploads a photo for a specific attire item.
|
||||
Future<AttireItem> uploadPhoto(String itemId, String filePath);
|
||||
Future<AttireChecklist> uploadPhoto(String itemId, String filePath);
|
||||
|
||||
/// Saves the user's attire selection and attestations.
|
||||
Future<void> saveAttire({
|
||||
|
||||
@@ -4,14 +4,14 @@ import 'package:krow_domain/krow_domain.dart';
|
||||
import '../repositories/attire_repository.dart';
|
||||
|
||||
/// Use case to fetch available attire options.
|
||||
class GetAttireOptionsUseCase extends NoInputUseCase<List<AttireItem>> {
|
||||
class GetAttireOptionsUseCase extends NoInputUseCase<List<AttireChecklist>> {
|
||||
|
||||
/// Creates a [GetAttireOptionsUseCase].
|
||||
GetAttireOptionsUseCase(this._repository);
|
||||
final AttireRepository _repository;
|
||||
|
||||
@override
|
||||
Future<List<AttireItem>> call() {
|
||||
Future<List<AttireChecklist>> call() {
|
||||
return _repository.getAttireOptions();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,13 +5,13 @@ import '../repositories/attire_repository.dart';
|
||||
|
||||
/// Use case to upload a photo for an attire item.
|
||||
class UploadAttirePhotoUseCase
|
||||
extends UseCase<UploadAttirePhotoArguments, AttireItem> {
|
||||
extends UseCase<UploadAttirePhotoArguments, AttireChecklist> {
|
||||
/// Creates a [UploadAttirePhotoUseCase].
|
||||
UploadAttirePhotoUseCase(this._repository);
|
||||
final AttireRepository _repository;
|
||||
|
||||
@override
|
||||
Future<AttireItem> call(UploadAttirePhotoArguments arguments) {
|
||||
Future<AttireChecklist> call(UploadAttirePhotoArguments arguments) {
|
||||
return _repository.uploadPhoto(arguments.itemId, arguments.filePath);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,19 +21,19 @@ class AttireCubit extends Cubit<AttireState>
|
||||
await handleError(
|
||||
emit: emit,
|
||||
action: () async {
|
||||
final List<AttireItem> options = await _getAttireOptionsUseCase();
|
||||
final List<AttireChecklist> options = await _getAttireOptionsUseCase();
|
||||
|
||||
// Extract photo URLs and selection status from backend data
|
||||
final Map<String, String> photoUrls = <String, String>{};
|
||||
final List<String> selectedIds = <String>[];
|
||||
|
||||
for (final AttireItem item in options) {
|
||||
if (item.photoUrl != null) {
|
||||
photoUrls[item.id] = item.photoUrl!;
|
||||
for (final AttireChecklist item in options) {
|
||||
if (item.photoUri != null) {
|
||||
photoUrls[item.documentId] = item.photoUri!;
|
||||
}
|
||||
// If mandatory or has photo, consider it selected initially
|
||||
if (item.isMandatory || item.photoUrl != null) {
|
||||
selectedIds.add(item.id);
|
||||
if (item.mandatory || item.photoUri != null) {
|
||||
selectedIds.add(item.documentId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,18 +68,18 @@ class AttireCubit extends Cubit<AttireState>
|
||||
emit(state.copyWith(filter: filter));
|
||||
}
|
||||
|
||||
void syncCapturedPhoto(AttireItem item) {
|
||||
void syncCapturedPhoto(AttireChecklist 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)
|
||||
final List<AttireChecklist> updatedOptions = state.options
|
||||
.map((AttireChecklist e) => e.documentId == item.documentId ? 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!;
|
||||
if (item.photoUri != null) {
|
||||
updatedPhotos[item.documentId] = item.photoUri!;
|
||||
}
|
||||
|
||||
emit(state.copyWith(options: updatedOptions, photoUrls: updatedPhotos));
|
||||
|
||||
@@ -6,14 +6,14 @@ enum AttireStatus { initial, loading, success, failure, saving, saved }
|
||||
class AttireState extends Equatable {
|
||||
const AttireState({
|
||||
this.status = AttireStatus.initial,
|
||||
this.options = const <AttireItem>[],
|
||||
this.options = const <AttireChecklist>[],
|
||||
this.selectedIds = const <String>[],
|
||||
this.photoUrls = const <String, String>{},
|
||||
this.filter = 'All',
|
||||
this.errorMessage,
|
||||
});
|
||||
final AttireStatus status;
|
||||
final List<AttireItem> options;
|
||||
final List<AttireChecklist> options;
|
||||
final List<String> selectedIds;
|
||||
final Map<String, String> photoUrls;
|
||||
final String filter;
|
||||
@@ -23,40 +23,44 @@ class AttireState extends Equatable {
|
||||
bool isMandatory(String id) {
|
||||
return options
|
||||
.firstWhere(
|
||||
(AttireItem e) => e.id == id,
|
||||
orElse: () => const AttireItem(id: '', code: '', label: ''),
|
||||
(AttireChecklist e) => e.documentId == id,
|
||||
orElse: () => const AttireChecklist(
|
||||
documentId: '',
|
||||
name: '',
|
||||
status: AttireItemStatus.notUploaded,
|
||||
),
|
||||
)
|
||||
.isMandatory;
|
||||
.mandatory;
|
||||
}
|
||||
|
||||
/// Validation logic
|
||||
bool get allMandatorySelected {
|
||||
final Iterable<String> mandatoryIds = options
|
||||
.where((AttireItem e) => e.isMandatory)
|
||||
.map((AttireItem e) => e.id);
|
||||
.where((AttireChecklist e) => e.mandatory)
|
||||
.map((AttireChecklist e) => e.documentId);
|
||||
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);
|
||||
.where((AttireChecklist e) => e.mandatory)
|
||||
.map((AttireChecklist e) => e.documentId);
|
||||
return mandatoryIds.every((String id) => photoUrls.containsKey(id));
|
||||
}
|
||||
|
||||
bool get canSave => allMandatorySelected && allMandatoryHavePhotos;
|
||||
|
||||
List<AttireItem> get filteredOptions {
|
||||
return options.where((AttireItem item) {
|
||||
if (filter == 'Required') return item.isMandatory;
|
||||
if (filter == 'Non-Essential') return !item.isMandatory;
|
||||
List<AttireChecklist> get filteredOptions {
|
||||
return options.where((AttireChecklist item) {
|
||||
if (filter == 'Required') return item.mandatory;
|
||||
if (filter == 'Non-Essential') return !item.mandatory;
|
||||
return true;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
AttireState copyWith({
|
||||
AttireStatus? status,
|
||||
List<AttireItem>? options,
|
||||
List<AttireChecklist>? options,
|
||||
List<String>? selectedIds,
|
||||
Map<String, String>? photoUrls,
|
||||
String? filter,
|
||||
|
||||
@@ -23,14 +23,14 @@ class AttireCaptureCubit extends Cubit<AttireCaptureState>
|
||||
await handleError(
|
||||
emit: emit,
|
||||
action: () async {
|
||||
final AttireItem item = await _uploadAttirePhotoUseCase(
|
||||
final AttireChecklist item = await _uploadAttirePhotoUseCase(
|
||||
UploadAttirePhotoArguments(itemId: itemId, filePath: filePath),
|
||||
);
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: AttireCaptureStatus.success,
|
||||
photoUrl: item.photoUrl,
|
||||
photoUrl: item.photoUri,
|
||||
updatedItem: item,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -15,14 +15,14 @@ class AttireCaptureState extends Equatable {
|
||||
final AttireCaptureStatus status;
|
||||
final bool isAttested;
|
||||
final String? photoUrl;
|
||||
final AttireItem? updatedItem;
|
||||
final AttireChecklist? updatedItem;
|
||||
final String? errorMessage;
|
||||
|
||||
AttireCaptureState copyWith({
|
||||
AttireCaptureStatus? status,
|
||||
bool? isAttested,
|
||||
String? photoUrl,
|
||||
AttireItem? updatedItem,
|
||||
AttireChecklist? updatedItem,
|
||||
String? errorMessage,
|
||||
}) {
|
||||
return AttireCaptureState(
|
||||
|
||||
@@ -24,8 +24,8 @@ class AttireCapturePage extends StatefulWidget {
|
||||
this.initialPhotoUrl,
|
||||
});
|
||||
|
||||
/// The attire item being captured.
|
||||
final AttireItem item;
|
||||
/// The attire checklist item being captured.
|
||||
final AttireChecklist item;
|
||||
|
||||
/// Optional initial photo URL if it was already uploaded.
|
||||
final String? initialPhotoUrl;
|
||||
@@ -48,7 +48,7 @@ class _AttireCapturePageState extends State<AttireCapturePage> {
|
||||
|
||||
/// Whether the item is currently pending verification.
|
||||
bool get _isPending =>
|
||||
widget.item.verificationStatus == AttireVerificationStatus.pending;
|
||||
widget.item.status == AttireItemStatus.pending;
|
||||
|
||||
/// On gallery button press
|
||||
Future<void> _onGallery(BuildContext context) async {
|
||||
@@ -206,7 +206,7 @@ class _AttireCapturePageState extends State<AttireCapturePage> {
|
||||
return;
|
||||
}
|
||||
|
||||
await cubit.uploadPhoto(widget.item.id, _selectedLocalPath!);
|
||||
await cubit.uploadPhoto(widget.item.documentId, _selectedLocalPath!);
|
||||
if (context.mounted && cubit.state.status == AttireCaptureStatus.success) {
|
||||
setState(() {
|
||||
_selectedLocalPath = null;
|
||||
@@ -215,12 +215,12 @@ class _AttireCapturePageState extends State<AttireCapturePage> {
|
||||
}
|
||||
|
||||
String _getStatusText(bool hasUploadedPhoto) {
|
||||
return switch (widget.item.verificationStatus) {
|
||||
AttireVerificationStatus.approved =>
|
||||
return switch (widget.item.status) {
|
||||
AttireItemStatus.verified =>
|
||||
t.staff_profile_attire.capture.approved,
|
||||
AttireVerificationStatus.rejected =>
|
||||
AttireItemStatus.rejected =>
|
||||
t.staff_profile_attire.capture.rejected,
|
||||
AttireVerificationStatus.pending =>
|
||||
AttireItemStatus.pending =>
|
||||
t.staff_profile_attire.capture.pending_verification,
|
||||
_ =>
|
||||
hasUploadedPhoto
|
||||
@@ -230,10 +230,10 @@ class _AttireCapturePageState extends State<AttireCapturePage> {
|
||||
}
|
||||
|
||||
Color _getStatusColor(bool hasUploadedPhoto) {
|
||||
return switch (widget.item.verificationStatus) {
|
||||
AttireVerificationStatus.approved => UiColors.textSuccess,
|
||||
AttireVerificationStatus.rejected => UiColors.textError,
|
||||
AttireVerificationStatus.pending => UiColors.textWarning,
|
||||
return switch (widget.item.status) {
|
||||
AttireItemStatus.verified => UiColors.textSuccess,
|
||||
AttireItemStatus.rejected => UiColors.textError,
|
||||
AttireItemStatus.pending => UiColors.textWarning,
|
||||
_ => hasUploadedPhoto ? UiColors.textWarning : UiColors.textInactive,
|
||||
};
|
||||
}
|
||||
@@ -250,7 +250,7 @@ class _AttireCapturePageState extends State<AttireCapturePage> {
|
||||
|
||||
return Scaffold(
|
||||
appBar: UiAppBar(
|
||||
title: widget.item.label,
|
||||
title: widget.item.name,
|
||||
onLeadingPressed: () {
|
||||
Modular.to.toAttire();
|
||||
},
|
||||
@@ -296,7 +296,7 @@ class _AttireCapturePageState extends State<AttireCapturePage> {
|
||||
ImagePreviewSection(
|
||||
selectedLocalPath: _selectedLocalPath,
|
||||
currentPhotoUrl: currentPhotoUrl,
|
||||
referenceImageUrl: widget.item.imageUrl,
|
||||
referenceImageUrl: null,
|
||||
),
|
||||
InfoSection(
|
||||
description: widget.item.description,
|
||||
|
||||
@@ -53,11 +53,11 @@ class _AttirePageState extends State<AttirePage> {
|
||||
return const AttireSkeleton();
|
||||
}
|
||||
|
||||
final List<AttireItem> requiredItems = state.options
|
||||
.where((AttireItem item) => item.isMandatory)
|
||||
final List<AttireChecklist> requiredItems = state.options
|
||||
.where((AttireChecklist item) => item.mandatory)
|
||||
.toList();
|
||||
final List<AttireItem> nonEssentialItems = state.options
|
||||
.where((AttireItem item) => !item.isMandatory)
|
||||
final List<AttireChecklist> nonEssentialItems = state.options
|
||||
.where((AttireChecklist item) => !item.mandatory)
|
||||
.toList();
|
||||
|
||||
return Column(
|
||||
@@ -109,7 +109,7 @@ class _AttirePageState extends State<AttirePage> {
|
||||
.no_items_filter,
|
||||
)
|
||||
else
|
||||
...requiredItems.map((AttireItem item) {
|
||||
...requiredItems.map((AttireChecklist item) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
bottom: UiConstants.space3,
|
||||
@@ -117,11 +117,11 @@ class _AttirePageState extends State<AttirePage> {
|
||||
child: AttireItemCard(
|
||||
item: item,
|
||||
isUploading: false,
|
||||
uploadedPhotoUrl: state.photoUrls[item.id],
|
||||
uploadedPhotoUrl: state.photoUrls[item.documentId],
|
||||
onTap: () {
|
||||
Modular.to.toAttireCapture(
|
||||
item: item,
|
||||
initialPhotoUrl: state.photoUrls[item.id],
|
||||
initialPhotoUrl: state.photoUrls[item.documentId],
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -156,7 +156,7 @@ class _AttirePageState extends State<AttirePage> {
|
||||
.no_items_filter,
|
||||
)
|
||||
else
|
||||
...nonEssentialItems.map((AttireItem item) {
|
||||
...nonEssentialItems.map((AttireChecklist item) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
bottom: UiConstants.space3,
|
||||
@@ -164,11 +164,11 @@ class _AttirePageState extends State<AttirePage> {
|
||||
child: AttireItemCard(
|
||||
item: item,
|
||||
isUploading: false,
|
||||
uploadedPhotoUrl: state.photoUrls[item.id],
|
||||
uploadedPhotoUrl: state.photoUrls[item.documentId],
|
||||
onTap: () {
|
||||
Modular.to.toAttireCapture(
|
||||
item: item,
|
||||
initialPhotoUrl: state.photoUrls[item.id],
|
||||
initialPhotoUrl: state.photoUrls[item.documentId],
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
@@ -39,7 +39,7 @@ class FooterSection extends StatelessWidget {
|
||||
final bool hasUploadedPhoto;
|
||||
|
||||
/// The updated attire item, if any.
|
||||
final AttireItem? updatedItem;
|
||||
final AttireChecklist? updatedItem;
|
||||
|
||||
/// Whether to show the attestation checkbox.
|
||||
final bool showCheckbox;
|
||||
|
||||
@@ -14,7 +14,7 @@ class AttireGrid extends StatelessWidget {
|
||||
required this.onToggle,
|
||||
required this.onUpload,
|
||||
});
|
||||
final List<AttireItem> items;
|
||||
final List<AttireChecklist> items;
|
||||
final List<String> selectedIds;
|
||||
final Map<String, String> photoUrls;
|
||||
final Map<String, bool> uploadingStatus;
|
||||
@@ -34,10 +34,10 @@ class AttireGrid extends StatelessWidget {
|
||||
),
|
||||
itemCount: items.length,
|
||||
itemBuilder: (BuildContext context, int index) {
|
||||
final AttireItem item = items[index];
|
||||
final bool isSelected = selectedIds.contains(item.id);
|
||||
final bool hasPhoto = photoUrls.containsKey(item.id);
|
||||
final bool isUploading = uploadingStatus[item.id] ?? false;
|
||||
final AttireChecklist item = items[index];
|
||||
final bool isSelected = selectedIds.contains(item.documentId);
|
||||
final bool hasPhoto = photoUrls.containsKey(item.documentId);
|
||||
final bool isUploading = uploadingStatus[item.documentId] ?? false;
|
||||
|
||||
return _buildCard(item, isSelected, hasPhoto, isUploading);
|
||||
},
|
||||
@@ -45,7 +45,7 @@ class AttireGrid extends StatelessWidget {
|
||||
}
|
||||
|
||||
Widget _buildCard(
|
||||
AttireItem item,
|
||||
AttireChecklist item,
|
||||
bool isSelected,
|
||||
bool hasPhoto,
|
||||
bool isUploading,
|
||||
@@ -63,20 +63,19 @@ class AttireGrid extends StatelessWidget {
|
||||
),
|
||||
child: Stack(
|
||||
children: <Widget>[
|
||||
if (item.isMandatory)
|
||||
if (item.mandatory)
|
||||
Positioned(
|
||||
top: UiConstants.space2,
|
||||
left: UiConstants.space2,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.destructive, // Red
|
||||
color: UiColors.destructive,
|
||||
borderRadius: UiConstants.radiusSm,
|
||||
),
|
||||
child: Text(
|
||||
t.staff_profile_attire.status.required,
|
||||
style: UiTypography.body3m.copyWith(
|
||||
// 12px Medium -> Bold
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 9,
|
||||
color: UiColors.white,
|
||||
@@ -106,37 +105,23 @@ class AttireGrid extends StatelessWidget {
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: <Widget>[
|
||||
GestureDetector(
|
||||
onTap: () => onToggle(item.id),
|
||||
onTap: () => onToggle(item.documentId),
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
item.imageUrl != null
|
||||
? Container(
|
||||
height: 80,
|
||||
width: 80,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(
|
||||
UiConstants.radiusBase,
|
||||
),
|
||||
image: DecorationImage(
|
||||
image: NetworkImage(item.imageUrl!),
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
)
|
||||
: const Icon(
|
||||
UiIcons.shirt,
|
||||
size: 48,
|
||||
color: UiColors.iconSecondary,
|
||||
),
|
||||
const Icon(
|
||||
UiIcons.shirt,
|
||||
size: 48,
|
||||
color: UiColors.iconSecondary,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
Text(
|
||||
item.label,
|
||||
item.name,
|
||||
textAlign: TextAlign.center,
|
||||
style: UiTypography.body2m.textPrimary,
|
||||
),
|
||||
if (item.description != null)
|
||||
if (item.description.isNotEmpty)
|
||||
Text(
|
||||
item.description!,
|
||||
item.description,
|
||||
textAlign: TextAlign.center,
|
||||
style: UiTypography.body3r.textSecondary,
|
||||
maxLines: 2,
|
||||
@@ -147,7 +132,7 @@ class AttireGrid extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: UiConstants.space3),
|
||||
InkWell(
|
||||
onTap: () => onUpload(item.id),
|
||||
onTap: () => onUpload(item.documentId),
|
||||
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
@@ -189,7 +174,7 @@ class AttireGrid extends StatelessWidget {
|
||||
const Icon(
|
||||
UiIcons.camera,
|
||||
size: 12,
|
||||
color: UiColors.textSecondary, // Was muted
|
||||
color: UiColors.textSecondary,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
|
||||
@@ -11,18 +11,18 @@ class AttireItemCard extends StatelessWidget {
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final AttireItem item;
|
||||
final AttireChecklist item;
|
||||
final String? uploadedPhotoUrl;
|
||||
final bool isUploading;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bool hasPhoto = item.photoUrl != null;
|
||||
final String statusText = switch (item.verificationStatus) {
|
||||
AttireVerificationStatus.approved => 'Approved',
|
||||
AttireVerificationStatus.rejected => 'Rejected',
|
||||
AttireVerificationStatus.pending => 'Pending',
|
||||
final bool hasPhoto = item.photoUri != null;
|
||||
final String statusText = switch (item.status) {
|
||||
AttireItemStatus.verified => 'Approved',
|
||||
AttireItemStatus.rejected => 'Rejected',
|
||||
AttireItemStatus.pending => 'Pending',
|
||||
_ => hasPhoto ? 'Pending' : 'To Do',
|
||||
};
|
||||
|
||||
@@ -38,21 +38,29 @@ class AttireItemCard extends StatelessWidget {
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
// Image
|
||||
// Image placeholder
|
||||
Container(
|
||||
width: 64,
|
||||
height: 64,
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.background,
|
||||
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
|
||||
image: DecorationImage(
|
||||
image: NetworkImage(
|
||||
item.imageUrl ??
|
||||
'https://images.unsplash.com/photo-1549298916-b41d501d3772?auto=format&fit=crop&q=80&w=400&h=400',
|
||||
),
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
image: hasPhoto
|
||||
? DecorationImage(
|
||||
image: NetworkImage(item.photoUri!),
|
||||
fit: BoxFit.cover,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
child: hasPhoto
|
||||
? null
|
||||
: const Center(
|
||||
child: Icon(
|
||||
UiIcons.camera,
|
||||
color: UiColors.textSecondary,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UiConstants.space4),
|
||||
// details
|
||||
@@ -60,10 +68,10 @@ class AttireItemCard extends StatelessWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(item.label, style: UiTypography.body1m.textPrimary),
|
||||
if (item.description != null) ...<Widget>[
|
||||
Text(item.name, style: UiTypography.body1m.textPrimary),
|
||||
if (item.description.isNotEmpty) ...<Widget>[
|
||||
Text(
|
||||
item.description!,
|
||||
item.description,
|
||||
style: UiTypography.body2r.textSecondary,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
@@ -73,7 +81,7 @@ class AttireItemCard extends StatelessWidget {
|
||||
Row(
|
||||
spacing: UiConstants.space2,
|
||||
children: <Widget>[
|
||||
if (item.isMandatory)
|
||||
if (item.mandatory)
|
||||
const UiChip(
|
||||
label: 'Required',
|
||||
size: UiChipSize.xSmall,
|
||||
@@ -90,8 +98,7 @@ class AttireItemCard extends StatelessWidget {
|
||||
label: statusText,
|
||||
size: UiChipSize.xSmall,
|
||||
variant:
|
||||
item.verificationStatus ==
|
||||
AttireVerificationStatus.approved
|
||||
item.status == AttireItemStatus.verified
|
||||
? UiChipVariant.primary
|
||||
: UiChipVariant.secondary,
|
||||
),
|
||||
@@ -114,12 +121,11 @@ class AttireItemCard extends StatelessWidget {
|
||||
)
|
||||
else if (hasPhoto && !isUploading)
|
||||
Icon(
|
||||
item.verificationStatus == AttireVerificationStatus.approved
|
||||
item.status == AttireItemStatus.verified
|
||||
? UiIcons.check
|
||||
: UiIcons.clock,
|
||||
color:
|
||||
item.verificationStatus ==
|
||||
AttireVerificationStatus.approved
|
||||
item.status == AttireItemStatus.verified
|
||||
? UiColors.textPrimary
|
||||
: UiColors.textWarning,
|
||||
size: 24,
|
||||
|
||||
@@ -14,15 +14,12 @@ dependencies:
|
||||
flutter_bloc: ^8.1.0
|
||||
flutter_modular: ^6.0.0
|
||||
equatable: ^2.0.5
|
||||
firebase_data_connect: ^0.2.2+1
|
||||
|
||||
|
||||
# Internal packages
|
||||
krow_core:
|
||||
path: ../../../../../core
|
||||
krow_domain:
|
||||
path: ../../../../../domain
|
||||
krow_data_connect:
|
||||
path: ../../../../../data_connect
|
||||
design_system:
|
||||
path: ../../../../../design_system
|
||||
core_localization:
|
||||
|
||||
@@ -1,81 +1,38 @@
|
||||
import 'package:krow_data_connect/krow_data_connect.dart' as dc;
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import '../../domain/repositories/emergency_contact_repository_interface.dart';
|
||||
|
||||
/// Implementation of [EmergencyContactRepositoryInterface].
|
||||
import 'package:staff_emergency_contact/src/domain/repositories/emergency_contact_repository_interface.dart';
|
||||
|
||||
/// Implementation of [EmergencyContactRepositoryInterface] using the V2 API.
|
||||
///
|
||||
/// This repository delegates data operations to Firebase Data Connect.
|
||||
/// Replaces the previous Firebase Data Connect implementation.
|
||||
class EmergencyContactRepositoryImpl
|
||||
implements EmergencyContactRepositoryInterface {
|
||||
final dc.DataConnectService _service;
|
||||
|
||||
/// Creates an [EmergencyContactRepositoryImpl].
|
||||
EmergencyContactRepositoryImpl({
|
||||
dc.DataConnectService? service,
|
||||
}) : _service = service ?? dc.DataConnectService.instance;
|
||||
EmergencyContactRepositoryImpl({required BaseApiService apiService})
|
||||
: _api = apiService;
|
||||
|
||||
final BaseApiService _api;
|
||||
|
||||
@override
|
||||
Future<List<EmergencyContact>> getContacts() async {
|
||||
return _service.run(() async {
|
||||
final staffId = await _service.getStaffId();
|
||||
final result = await _service.connector
|
||||
.getEmergencyContactsByStaffId(staffId: staffId)
|
||||
.execute();
|
||||
|
||||
return result.data.emergencyContacts.map((dto) {
|
||||
return EmergencyContactAdapter.fromPrimitives(
|
||||
id: dto.id,
|
||||
name: dto.name,
|
||||
phone: dto.phone,
|
||||
relationship: dto.relationship.stringValue,
|
||||
);
|
||||
}).toList();
|
||||
});
|
||||
final ApiResponse response =
|
||||
await _api.get(V2ApiEndpoints.staffEmergencyContacts);
|
||||
final List<dynamic> items = response.data['contacts'] as List<dynamic>;
|
||||
return items
|
||||
.map((dynamic json) =>
|
||||
EmergencyContact.fromJson(json as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> saveContacts(List<EmergencyContact> contacts) async {
|
||||
return _service.run(() async {
|
||||
final staffId = await _service.getStaffId();
|
||||
|
||||
// 1. Get existing to delete
|
||||
final existingResult = await _service.connector
|
||||
.getEmergencyContactsByStaffId(staffId: staffId)
|
||||
.execute();
|
||||
final existingIds =
|
||||
existingResult.data.emergencyContacts.map((e) => e.id).toList();
|
||||
|
||||
// 2. Delete all existing
|
||||
await Future.wait(existingIds.map(
|
||||
(id) => _service.connector.deleteEmergencyContact(id: id).execute()));
|
||||
|
||||
// 3. Create new
|
||||
await Future.wait(contacts.map((contact) {
|
||||
dc.RelationshipType rel = dc.RelationshipType.OTHER;
|
||||
switch (contact.relationship) {
|
||||
case RelationshipType.family:
|
||||
rel = dc.RelationshipType.FAMILY;
|
||||
break;
|
||||
case RelationshipType.spouse:
|
||||
rel = dc.RelationshipType.SPOUSE;
|
||||
break;
|
||||
case RelationshipType.friend:
|
||||
rel = dc.RelationshipType.FRIEND;
|
||||
break;
|
||||
case RelationshipType.other:
|
||||
rel = dc.RelationshipType.OTHER;
|
||||
break;
|
||||
}
|
||||
|
||||
return _service.connector
|
||||
.createEmergencyContact(
|
||||
name: contact.name,
|
||||
phone: contact.phone,
|
||||
relationship: rel,
|
||||
staffId: staffId,
|
||||
)
|
||||
.execute();
|
||||
}));
|
||||
});
|
||||
await _api.put(
|
||||
V2ApiEndpoints.staffEmergencyContacts,
|
||||
data: <String, dynamic>{
|
||||
'contacts':
|
||||
contacts.map((EmergencyContact c) => c.toJson()).toList(),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,10 +2,10 @@ import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
/// Repository interface for managing emergency contacts.
|
||||
///
|
||||
/// This interface defines the contract for fetching and saving emergency contact information.
|
||||
/// It must be implemented by the data layer.
|
||||
/// Defines the contract for fetching and saving emergency contact information
|
||||
/// via the V2 API.
|
||||
abstract class EmergencyContactRepositoryInterface {
|
||||
/// Retrieves the list of emergency contacts.
|
||||
/// Retrieves the list of emergency contacts for the current staff member.
|
||||
Future<List<EmergencyContact>> getContacts();
|
||||
|
||||
/// Saves the list of emergency contacts.
|
||||
|
||||
@@ -28,9 +28,7 @@ class EmergencyContactState extends Equatable {
|
||||
|
||||
bool get isValid {
|
||||
if (contacts.isEmpty) return false;
|
||||
// Check if at least one contact is valid (or all?)
|
||||
// Usually all added contacts should be valid.
|
||||
return contacts.every((c) => c.name.isNotEmpty && c.phone.isNotEmpty);
|
||||
return contacts.every((c) => c.fullName.isNotEmpty && c.phone.isNotEmpty);
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -4,6 +4,14 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import '../blocs/emergency_contact_bloc.dart';
|
||||
|
||||
/// Available relationship type values.
|
||||
const List<String> _kRelationshipTypes = <String>[
|
||||
'FAMILY',
|
||||
'SPOUSE',
|
||||
'FRIEND',
|
||||
'OTHER',
|
||||
];
|
||||
|
||||
class EmergencyContactFormItem extends StatelessWidget {
|
||||
final int index;
|
||||
final EmergencyContact contact;
|
||||
@@ -33,11 +41,11 @@ class EmergencyContactFormItem extends StatelessWidget {
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
_buildLabel('Full Name'),
|
||||
_buildTextField(
|
||||
initialValue: contact.name,
|
||||
initialValue: contact.fullName,
|
||||
hint: 'Contact name',
|
||||
icon: UiIcons.user,
|
||||
onChanged: (val) => context.read<EmergencyContactBloc>().add(
|
||||
EmergencyContactUpdated(index, contact.copyWith(name: val)),
|
||||
EmergencyContactUpdated(index, contact.copyWith(fullName: val)),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
@@ -54,14 +62,14 @@ class EmergencyContactFormItem extends StatelessWidget {
|
||||
_buildLabel('Relationship'),
|
||||
_buildDropdown(
|
||||
context,
|
||||
value: contact.relationship,
|
||||
items: RelationshipType.values,
|
||||
value: contact.relationshipType,
|
||||
items: _kRelationshipTypes,
|
||||
onChanged: (val) {
|
||||
if (val != null) {
|
||||
context.read<EmergencyContactBloc>().add(
|
||||
EmergencyContactUpdated(
|
||||
index,
|
||||
contact.copyWith(relationship: val),
|
||||
contact.copyWith(relationshipType: val),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -74,9 +82,9 @@ class EmergencyContactFormItem extends StatelessWidget {
|
||||
|
||||
Widget _buildDropdown(
|
||||
BuildContext context, {
|
||||
required RelationshipType value,
|
||||
required List<RelationshipType> items,
|
||||
required ValueChanged<RelationshipType?> onChanged,
|
||||
required String value,
|
||||
required List<String> items,
|
||||
required ValueChanged<String?> onChanged,
|
||||
}) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
@@ -89,13 +97,13 @@ class EmergencyContactFormItem extends StatelessWidget {
|
||||
border: Border.all(color: UiColors.border),
|
||||
),
|
||||
child: DropdownButtonHideUnderline(
|
||||
child: DropdownButton<RelationshipType>(
|
||||
value: value,
|
||||
child: DropdownButton<String>(
|
||||
value: items.contains(value) ? value : items.first,
|
||||
isExpanded: true,
|
||||
dropdownColor: UiColors.bgPopup,
|
||||
icon: const Icon(UiIcons.chevronDown, color: UiColors.iconSecondary),
|
||||
items: items.map((type) {
|
||||
return DropdownMenuItem<RelationshipType>(
|
||||
return DropdownMenuItem<String>(
|
||||
value: type,
|
||||
child: Text(
|
||||
_formatRelationship(type),
|
||||
@@ -109,16 +117,18 @@ class EmergencyContactFormItem extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
String _formatRelationship(RelationshipType type) {
|
||||
String _formatRelationship(String type) {
|
||||
switch (type) {
|
||||
case RelationshipType.family:
|
||||
case 'FAMILY':
|
||||
return 'Family';
|
||||
case RelationshipType.spouse:
|
||||
case 'SPOUSE':
|
||||
return 'Spouse';
|
||||
case RelationshipType.friend:
|
||||
case 'FRIEND':
|
||||
return 'Friend';
|
||||
case RelationshipType.other:
|
||||
case 'OTHER':
|
||||
return 'Other';
|
||||
default:
|
||||
return type;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,26 +1,38 @@
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
import 'data/repositories/emergency_contact_repository_impl.dart';
|
||||
import 'domain/repositories/emergency_contact_repository_interface.dart';
|
||||
import 'domain/usecases/get_emergency_contacts_usecase.dart';
|
||||
import 'domain/usecases/save_emergency_contacts_usecase.dart';
|
||||
import 'presentation/blocs/emergency_contact_bloc.dart';
|
||||
import 'presentation/pages/emergency_contact_screen.dart';
|
||||
import 'package:staff_emergency_contact/src/data/repositories/emergency_contact_repository_impl.dart';
|
||||
import 'package:staff_emergency_contact/src/domain/repositories/emergency_contact_repository_interface.dart';
|
||||
import 'package:staff_emergency_contact/src/domain/usecases/get_emergency_contacts_usecase.dart';
|
||||
import 'package:staff_emergency_contact/src/domain/usecases/save_emergency_contacts_usecase.dart';
|
||||
import 'package:staff_emergency_contact/src/presentation/blocs/emergency_contact_bloc.dart';
|
||||
import 'package:staff_emergency_contact/src/presentation/pages/emergency_contact_screen.dart';
|
||||
|
||||
/// Module for the Staff Emergency Contact feature.
|
||||
///
|
||||
/// Uses the V2 REST API via [BaseApiService] for backend access.
|
||||
class StaffEmergencyContactModule extends Module {
|
||||
@override
|
||||
List<Module> get imports => <Module>[CoreModule()];
|
||||
|
||||
@override
|
||||
void binds(Injector i) {
|
||||
// Repository
|
||||
i.addLazySingleton<EmergencyContactRepositoryInterface>(
|
||||
EmergencyContactRepositoryImpl.new,
|
||||
() => EmergencyContactRepositoryImpl(
|
||||
apiService: i.get<BaseApiService>(),
|
||||
),
|
||||
);
|
||||
|
||||
// UseCases
|
||||
i.addLazySingleton<GetEmergencyContactsUseCase>(
|
||||
() => GetEmergencyContactsUseCase(i.get<EmergencyContactRepositoryInterface>()),
|
||||
() => GetEmergencyContactsUseCase(
|
||||
i.get<EmergencyContactRepositoryInterface>()),
|
||||
);
|
||||
i.addLazySingleton<SaveEmergencyContactsUseCase>(
|
||||
() => SaveEmergencyContactsUseCase(i.get<EmergencyContactRepositoryInterface>()),
|
||||
() => SaveEmergencyContactsUseCase(
|
||||
i.get<EmergencyContactRepositoryInterface>()),
|
||||
);
|
||||
|
||||
// BLoC
|
||||
|
||||
@@ -14,14 +14,12 @@ dependencies:
|
||||
flutter_bloc: ^8.1.0
|
||||
flutter_modular: ^6.3.0
|
||||
equatable: ^2.0.5
|
||||
|
||||
|
||||
# Architecture Packages
|
||||
krow_domain:
|
||||
path: ../../../../../domain
|
||||
krow_core:
|
||||
path: ../../../../../core
|
||||
krow_data_connect:
|
||||
path: ../../../../../data_connect
|
||||
design_system:
|
||||
path: ../../../../../design_system
|
||||
core_localization:
|
||||
|
||||
@@ -1,42 +1,31 @@
|
||||
import 'package:krow_data_connect/krow_data_connect.dart' as dc;
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
import '../../domain/repositories/experience_repository_interface.dart';
|
||||
import 'package:staff_profile_experience/src/domain/repositories/experience_repository_interface.dart';
|
||||
|
||||
/// Implementation of [ExperienceRepositoryInterface] that delegates to Data Connect.
|
||||
/// Implementation of [ExperienceRepositoryInterface] using the V2 API.
|
||||
///
|
||||
/// Replaces the previous Firebase Data Connect implementation.
|
||||
class ExperienceRepositoryImpl implements ExperienceRepositoryInterface {
|
||||
final dc.DataConnectService _service;
|
||||
/// Creates an [ExperienceRepositoryImpl].
|
||||
ExperienceRepositoryImpl({required BaseApiService apiService})
|
||||
: _api = apiService;
|
||||
|
||||
/// Creates a [ExperienceRepositoryImpl] using Data Connect Service.
|
||||
ExperienceRepositoryImpl({
|
||||
dc.DataConnectService? service,
|
||||
}) : _service = service ?? dc.DataConnectService.instance;
|
||||
|
||||
Future<dc.GetStaffByIdStaff> _getStaff() async {
|
||||
final staffId = await _service.getStaffId();
|
||||
|
||||
final result =
|
||||
await _service.connector.getStaffById(id: staffId).execute();
|
||||
if (result.data.staff == null) {
|
||||
throw const ServerException(technicalMessage: 'Staff profile not found');
|
||||
}
|
||||
return result.data.staff!;
|
||||
}
|
||||
final BaseApiService _api;
|
||||
|
||||
@override
|
||||
Future<List<String>> getIndustries() async {
|
||||
return _service.run(() async {
|
||||
final staff = await _getStaff();
|
||||
return staff.industries ?? [];
|
||||
});
|
||||
final ApiResponse response =
|
||||
await _api.get(V2ApiEndpoints.staffIndustries);
|
||||
final List<dynamic> items = response.data['industries'] as List<dynamic>;
|
||||
return items.map((dynamic e) => e.toString()).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<String>> getSkills() async {
|
||||
return _service.run(() async {
|
||||
final staff = await _getStaff();
|
||||
return staff.skills ?? [];
|
||||
});
|
||||
final ApiResponse response = await _api.get(V2ApiEndpoints.staffSkills);
|
||||
final List<dynamic> items = response.data['skills'] as List<dynamic>;
|
||||
return items.map((dynamic e) => e.toString()).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -44,13 +33,12 @@ class ExperienceRepositoryImpl implements ExperienceRepositoryInterface {
|
||||
List<String> industries,
|
||||
List<String> skills,
|
||||
) async {
|
||||
return _service.run(() async {
|
||||
final staff = await _getStaff();
|
||||
await _service.connector
|
||||
.updateStaff(id: staff.id)
|
||||
.industries(industries)
|
||||
.skills(skills)
|
||||
.execute();
|
||||
});
|
||||
await _api.put(
|
||||
V2ApiEndpoints.staffPersonalInfo,
|
||||
data: <String, dynamic>{
|
||||
'industries': industries,
|
||||
'skills': skills,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import '../../domain/arguments/save_experience_arguments.dart';
|
||||
import '../../domain/usecases/get_staff_industries_usecase.dart';
|
||||
import '../../domain/usecases/get_staff_skills_usecase.dart';
|
||||
@@ -18,7 +17,7 @@ abstract class ExperienceEvent extends Equatable {
|
||||
class ExperienceLoaded extends ExperienceEvent {}
|
||||
|
||||
class ExperienceIndustryToggled extends ExperienceEvent {
|
||||
final Industry industry;
|
||||
final String industry;
|
||||
const ExperienceIndustryToggled(this.industry);
|
||||
|
||||
@override
|
||||
@@ -48,10 +47,10 @@ enum ExperienceStatus { initial, loading, success, failure }
|
||||
|
||||
class ExperienceState extends Equatable {
|
||||
final ExperienceStatus status;
|
||||
final List<Industry> selectedIndustries;
|
||||
final List<String> selectedIndustries;
|
||||
final List<String> selectedSkills;
|
||||
final List<Industry> availableIndustries;
|
||||
final List<ExperienceSkill> availableSkills;
|
||||
final List<String> availableIndustries;
|
||||
final List<String> availableSkills;
|
||||
final String? errorMessage;
|
||||
|
||||
const ExperienceState({
|
||||
@@ -65,10 +64,10 @@ class ExperienceState extends Equatable {
|
||||
|
||||
ExperienceState copyWith({
|
||||
ExperienceStatus? status,
|
||||
List<Industry>? selectedIndustries,
|
||||
List<String>? selectedIndustries,
|
||||
List<String>? selectedSkills,
|
||||
List<Industry>? availableIndustries,
|
||||
List<ExperienceSkill>? availableSkills,
|
||||
List<String>? availableIndustries,
|
||||
List<String>? availableSkills,
|
||||
String? errorMessage,
|
||||
}) {
|
||||
return ExperienceState(
|
||||
@@ -92,6 +91,37 @@ class ExperienceState extends Equatable {
|
||||
];
|
||||
}
|
||||
|
||||
/// Available industry option values.
|
||||
const List<String> _kAvailableIndustries = <String>[
|
||||
'hospitality',
|
||||
'food_service',
|
||||
'warehouse',
|
||||
'events',
|
||||
'retail',
|
||||
'healthcare',
|
||||
'other',
|
||||
];
|
||||
|
||||
/// Available skill option values.
|
||||
const List<String> _kAvailableSkills = <String>[
|
||||
'food_service',
|
||||
'bartending',
|
||||
'event_setup',
|
||||
'hospitality',
|
||||
'warehouse',
|
||||
'customer_service',
|
||||
'cleaning',
|
||||
'security',
|
||||
'retail',
|
||||
'driving',
|
||||
'cooking',
|
||||
'cashier',
|
||||
'server',
|
||||
'barista',
|
||||
'host_hostess',
|
||||
'busser',
|
||||
];
|
||||
|
||||
// BLoC
|
||||
class ExperienceBloc extends Bloc<ExperienceEvent, ExperienceState>
|
||||
with BlocErrorHandler<ExperienceState> {
|
||||
@@ -105,8 +135,8 @@ class ExperienceBloc extends Bloc<ExperienceEvent, ExperienceState>
|
||||
required this.saveExperience,
|
||||
}) : super(
|
||||
const ExperienceState(
|
||||
availableIndustries: Industry.values,
|
||||
availableSkills: ExperienceSkill.values,
|
||||
availableIndustries: _kAvailableIndustries,
|
||||
availableSkills: _kAvailableSkills,
|
||||
),
|
||||
) {
|
||||
on<ExperienceLoaded>(_onLoaded);
|
||||
@@ -131,11 +161,7 @@ class ExperienceBloc extends Bloc<ExperienceEvent, ExperienceState>
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: ExperienceStatus.initial,
|
||||
selectedIndustries:
|
||||
results[0]
|
||||
.map((e) => Industry.fromString(e))
|
||||
.whereType<Industry>()
|
||||
.toList(),
|
||||
selectedIndustries: results[0],
|
||||
selectedSkills: results[1],
|
||||
),
|
||||
);
|
||||
@@ -151,7 +177,7 @@ class ExperienceBloc extends Bloc<ExperienceEvent, ExperienceState>
|
||||
ExperienceIndustryToggled event,
|
||||
Emitter<ExperienceState> emit,
|
||||
) {
|
||||
final industries = List<Industry>.from(state.selectedIndustries);
|
||||
final industries = List<String>.from(state.selectedIndustries);
|
||||
if (industries.contains(event.industry)) {
|
||||
industries.remove(event.industry);
|
||||
} else {
|
||||
@@ -193,7 +219,7 @@ class ExperienceBloc extends Bloc<ExperienceEvent, ExperienceState>
|
||||
action: () async {
|
||||
await saveExperience(
|
||||
SaveExperienceArguments(
|
||||
industries: state.selectedIndustries.map((e) => e.value).toList(),
|
||||
industries: state.selectedIndustries,
|
||||
skills: state.selectedSkills,
|
||||
),
|
||||
);
|
||||
@@ -206,4 +232,3 @@ class ExperienceBloc extends Bloc<ExperienceEvent, ExperienceState>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
import '../blocs/experience_bloc.dart';
|
||||
import '../widgets/experience_section_title.dart';
|
||||
@@ -12,59 +11,63 @@ import '../widgets/experience_section_title.dart';
|
||||
class ExperiencePage extends StatelessWidget {
|
||||
const ExperiencePage({super.key});
|
||||
|
||||
String _getIndustryLabel(dynamic node, Industry industry) {
|
||||
String _getIndustryLabel(dynamic node, String industry) {
|
||||
switch (industry) {
|
||||
case Industry.hospitality:
|
||||
case 'hospitality':
|
||||
return node.hospitality;
|
||||
case Industry.foodService:
|
||||
case 'food_service':
|
||||
return node.food_service;
|
||||
case Industry.warehouse:
|
||||
case 'warehouse':
|
||||
return node.warehouse;
|
||||
case Industry.events:
|
||||
case 'events':
|
||||
return node.events;
|
||||
case Industry.retail:
|
||||
case 'retail':
|
||||
return node.retail;
|
||||
case Industry.healthcare:
|
||||
case 'healthcare':
|
||||
return node.healthcare;
|
||||
case Industry.other:
|
||||
case 'other':
|
||||
return node.other;
|
||||
default:
|
||||
return industry;
|
||||
}
|
||||
}
|
||||
|
||||
String _getSkillLabel(dynamic node, ExperienceSkill skill) {
|
||||
String _getSkillLabel(dynamic node, String skill) {
|
||||
switch (skill) {
|
||||
case ExperienceSkill.foodService:
|
||||
case 'food_service':
|
||||
return node.food_service;
|
||||
case ExperienceSkill.bartending:
|
||||
case 'bartending':
|
||||
return node.bartending;
|
||||
case ExperienceSkill.eventSetup:
|
||||
case 'event_setup':
|
||||
return node.event_setup;
|
||||
case ExperienceSkill.hospitality:
|
||||
case 'hospitality':
|
||||
return node.hospitality;
|
||||
case ExperienceSkill.warehouse:
|
||||
case 'warehouse':
|
||||
return node.warehouse;
|
||||
case ExperienceSkill.customerService:
|
||||
case 'customer_service':
|
||||
return node.customer_service;
|
||||
case ExperienceSkill.cleaning:
|
||||
case 'cleaning':
|
||||
return node.cleaning;
|
||||
case ExperienceSkill.security:
|
||||
case 'security':
|
||||
return node.security;
|
||||
case ExperienceSkill.retail:
|
||||
case 'retail':
|
||||
return node.retail;
|
||||
case ExperienceSkill.driving:
|
||||
case 'driving':
|
||||
return node.driving;
|
||||
case ExperienceSkill.cooking:
|
||||
case 'cooking':
|
||||
return node.cooking;
|
||||
case ExperienceSkill.cashier:
|
||||
case 'cashier':
|
||||
return node.cashier;
|
||||
case ExperienceSkill.server:
|
||||
case 'server':
|
||||
return node.server;
|
||||
case ExperienceSkill.barista:
|
||||
case 'barista':
|
||||
return node.barista;
|
||||
case ExperienceSkill.hostHostess:
|
||||
case 'host_hostess':
|
||||
return node.host_hostess;
|
||||
case ExperienceSkill.busser:
|
||||
case 'busser':
|
||||
return node.busser;
|
||||
default:
|
||||
return skill;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,14 +157,12 @@ class ExperiencePage extends StatelessWidget {
|
||||
.map(
|
||||
(s) => UiChip(
|
||||
label: _getSkillLabel(i18n.skills, s),
|
||||
isSelected: state.selectedSkills.contains(
|
||||
s.value,
|
||||
),
|
||||
isSelected: state.selectedSkills.contains(s),
|
||||
onTap: () => BlocProvider.of<ExperienceBloc>(
|
||||
context,
|
||||
).add(ExperienceSkillToggled(s.value)),
|
||||
).add(ExperienceSkillToggled(s)),
|
||||
variant:
|
||||
state.selectedSkills.contains(s.value)
|
||||
state.selectedSkills.contains(s)
|
||||
? UiChipVariant.primary
|
||||
: UiChipVariant.secondary,
|
||||
),
|
||||
@@ -183,7 +184,7 @@ class ExperiencePage extends StatelessWidget {
|
||||
|
||||
Widget _buildCustomSkillsList(ExperienceState state, dynamic i18n) {
|
||||
final customSkills = state.selectedSkills
|
||||
.where((s) => !state.availableSkills.any((e) => e.value == s))
|
||||
.where((s) => !state.availableSkills.contains(s))
|
||||
.toList();
|
||||
if (customSkills.isEmpty) return const SizedBox.shrink();
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
library;
|
||||
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
import 'package:krow_data_connect/krow_data_connect.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
import 'src/data/repositories/experience_repository_impl.dart';
|
||||
import 'src/domain/repositories/experience_repository_interface.dart';
|
||||
@@ -13,20 +14,26 @@ import 'src/presentation/pages/experience_page.dart';
|
||||
|
||||
export 'src/presentation/pages/experience_page.dart';
|
||||
|
||||
/// Module for the Staff Experience feature.
|
||||
///
|
||||
/// Uses the V2 REST API via [BaseApiService] for backend access.
|
||||
class StaffProfileExperienceModule extends Module {
|
||||
@override
|
||||
List<Module> get imports => [DataConnectModule()];
|
||||
List<Module> get imports => <Module>[CoreModule()];
|
||||
|
||||
@override
|
||||
void binds(Injector i) {
|
||||
// Repository
|
||||
i.addLazySingleton<ExperienceRepositoryInterface>(
|
||||
ExperienceRepositoryImpl.new,
|
||||
() => ExperienceRepositoryImpl(
|
||||
apiService: i.get<BaseApiService>(),
|
||||
),
|
||||
);
|
||||
|
||||
// UseCases
|
||||
i.addLazySingleton<GetStaffIndustriesUseCase>(
|
||||
() => GetStaffIndustriesUseCase(i.get<ExperienceRepositoryInterface>()),
|
||||
() =>
|
||||
GetStaffIndustriesUseCase(i.get<ExperienceRepositoryInterface>()),
|
||||
);
|
||||
i.addLazySingleton<GetStaffSkillsUseCase>(
|
||||
() => GetStaffSkillsUseCase(i.get<ExperienceRepositoryInterface>()),
|
||||
|
||||
@@ -14,15 +14,12 @@ dependencies:
|
||||
flutter_bloc: ^8.1.0
|
||||
flutter_modular: ^6.3.0
|
||||
equatable: ^2.0.5
|
||||
|
||||
|
||||
# Architecture Packages
|
||||
krow_domain:
|
||||
path: ../../../../../domain
|
||||
krow_core:
|
||||
path: ../../../../../core
|
||||
krow_data_connect:
|
||||
path: ../../../../../data_connect
|
||||
firebase_auth: ^6.1.2
|
||||
design_system:
|
||||
path: ../../../../../design_system
|
||||
core_localization:
|
||||
|
||||
@@ -1,119 +1,77 @@
|
||||
import 'package:firebase_data_connect/firebase_data_connect.dart';
|
||||
import 'package:krow_data_connect/krow_data_connect.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
import '../../domain/repositories/personal_info_repository_interface.dart';
|
||||
import 'package:staff_profile_info/src/domain/repositories/personal_info_repository_interface.dart';
|
||||
|
||||
/// Implementation of [PersonalInfoRepositoryInterface] that delegates
|
||||
/// to Firebase Data Connect for all data operations.
|
||||
/// to the V2 REST API for all data operations.
|
||||
///
|
||||
/// This implementation follows Clean Architecture by:
|
||||
/// - Implementing the domain's repository interface
|
||||
/// - Delegating all data access to the data_connect layer
|
||||
/// - Mapping between data_connect DTOs and domain entities
|
||||
/// - Containing no business logic
|
||||
class PersonalInfoRepositoryImpl
|
||||
implements PersonalInfoRepositoryInterface {
|
||||
/// Replaces the previous Firebase Data Connect implementation.
|
||||
class PersonalInfoRepositoryImpl implements PersonalInfoRepositoryInterface {
|
||||
/// Creates a [PersonalInfoRepositoryImpl].
|
||||
///
|
||||
/// Requires the Firebase Data Connect service.
|
||||
/// Requires the V2 [BaseApiService] for HTTP communication,
|
||||
/// [FileUploadService] for uploading files to cloud storage, and
|
||||
/// [SignedUrlService] for generating signed download URLs.
|
||||
PersonalInfoRepositoryImpl({
|
||||
DataConnectService? service,
|
||||
}) : _service = service ?? DataConnectService.instance;
|
||||
required BaseApiService apiService,
|
||||
required FileUploadService uploadService,
|
||||
required SignedUrlService signedUrlService,
|
||||
}) : _api = apiService,
|
||||
_uploadService = uploadService,
|
||||
_signedUrlService = signedUrlService;
|
||||
|
||||
final DataConnectService _service;
|
||||
final BaseApiService _api;
|
||||
final FileUploadService _uploadService;
|
||||
final SignedUrlService _signedUrlService;
|
||||
|
||||
@override
|
||||
Future<Staff> getStaffProfile() async {
|
||||
return _service.run(() async {
|
||||
final String uid = _service.auth.currentUser!.uid;
|
||||
|
||||
// Query staff data from Firebase Data Connect
|
||||
final QueryResult<GetStaffByUserIdData, GetStaffByUserIdVariables> result =
|
||||
await _service.connector.getStaffByUserId(userId: uid).execute();
|
||||
|
||||
if (result.data.staffs.isEmpty) {
|
||||
throw const ServerException(technicalMessage: 'Staff profile not found');
|
||||
}
|
||||
|
||||
final GetStaffByUserIdStaffs rawStaff = result.data.staffs.first;
|
||||
|
||||
// Map from data_connect DTO to domain entity
|
||||
return _mapToStaffEntity(rawStaff);
|
||||
});
|
||||
Future<StaffPersonalInfo> getStaffProfile() async {
|
||||
final ApiResponse response =
|
||||
await _api.get(V2ApiEndpoints.staffPersonalInfo);
|
||||
final Map<String, dynamic> json =
|
||||
response.data as Map<String, dynamic>;
|
||||
return StaffPersonalInfo.fromJson(json);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Staff> updateStaffProfile(
|
||||
{required String staffId, required Map<String, dynamic> data}) async {
|
||||
return _service.run(() async {
|
||||
// Start building the update mutation
|
||||
UpdateStaffVariablesBuilder updateBuilder =
|
||||
_service.connector.updateStaff(id: staffId);
|
||||
|
||||
// Apply updates from map if present
|
||||
if (data.containsKey('name')) {
|
||||
updateBuilder = updateBuilder.fullName(data['name'] as String);
|
||||
}
|
||||
if (data.containsKey('email')) {
|
||||
updateBuilder = updateBuilder.email(data['email'] as String);
|
||||
}
|
||||
if (data.containsKey('phone')) {
|
||||
updateBuilder = updateBuilder.phone(data['phone'] as String?);
|
||||
}
|
||||
if (data.containsKey('avatar')) {
|
||||
updateBuilder = updateBuilder.photoUrl(data['avatar'] as String?);
|
||||
}
|
||||
if (data.containsKey('preferredLocations')) {
|
||||
// After schema update and SDK regeneration, preferredLocations accepts List<String>
|
||||
updateBuilder = updateBuilder.preferredLocations(
|
||||
data['preferredLocations'] as List<String>);
|
||||
}
|
||||
|
||||
// Execute the update
|
||||
final OperationResult<UpdateStaffData, UpdateStaffVariables> result =
|
||||
await updateBuilder.execute();
|
||||
|
||||
if (result.data.staff_update == null) {
|
||||
throw const ServerException(
|
||||
technicalMessage: 'Failed to update staff profile');
|
||||
}
|
||||
|
||||
// Fetch the updated staff profile to return complete entity
|
||||
return getStaffProfile();
|
||||
});
|
||||
Future<StaffPersonalInfo> updateStaffProfile({
|
||||
required String staffId,
|
||||
required Map<String, dynamic> data,
|
||||
}) async {
|
||||
final ApiResponse response = await _api.put(
|
||||
V2ApiEndpoints.staffPersonalInfo,
|
||||
data: data,
|
||||
);
|
||||
final Map<String, dynamic> json =
|
||||
response.data as Map<String, dynamic>;
|
||||
return StaffPersonalInfo.fromJson(json);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> uploadProfilePhoto(String filePath) async {
|
||||
// TODO: Implement photo upload to Firebase Storage
|
||||
// This will be implemented when Firebase Storage integration is ready
|
||||
throw UnimplementedError(
|
||||
'Photo upload not yet implemented. Will integrate with Firebase Storage.',
|
||||
// 1. Upload the file to cloud storage.
|
||||
final FileUploadResponse uploadRes = await _uploadService.uploadFile(
|
||||
filePath: filePath,
|
||||
fileName:
|
||||
'staff_profile_photo_${DateTime.now().millisecondsSinceEpoch}.jpg',
|
||||
visibility: FileVisibility.public,
|
||||
);
|
||||
}
|
||||
|
||||
/// Maps a data_connect Staff DTO to a domain Staff entity.
|
||||
///
|
||||
/// This mapping isolates the domain from data layer implementation details.
|
||||
Staff _mapToStaffEntity(GetStaffByUserIdStaffs dto) {
|
||||
return Staff(
|
||||
id: dto.id,
|
||||
authProviderId: dto.userId,
|
||||
name: dto.fullName,
|
||||
email: dto.email ?? '',
|
||||
phone: dto.phone,
|
||||
avatar: dto.photoUrl,
|
||||
status: StaffStatus.active,
|
||||
address: dto.addres,
|
||||
totalShifts: dto.totalShifts,
|
||||
averageRating: dto.averageRating,
|
||||
onTimeRate: dto.onTimeRate,
|
||||
noShowCount: dto.noShowCount,
|
||||
cancellationCount: dto.cancellationCount,
|
||||
reliabilityScore: dto.reliabilityScore,
|
||||
// After schema update and SDK regeneration, preferredLocations is List<String>?
|
||||
preferredLocations: dto.preferredLocations,
|
||||
// 2. Generate a signed URL for the uploaded file.
|
||||
final SignedUrlResponse signedUrlRes =
|
||||
await _signedUrlService.createSignedUrl(fileUri: uploadRes.fileUri);
|
||||
final String photoUrl = signedUrlRes.signedUrl;
|
||||
|
||||
// 3. Submit the photo URL to the V2 API.
|
||||
await _api.post(
|
||||
V2ApiEndpoints.staffProfilePhoto,
|
||||
data: <String, dynamic>{
|
||||
'fileUri': uploadRes.fileUri,
|
||||
'photoUrl': photoUrl,
|
||||
},
|
||||
);
|
||||
|
||||
return photoUrl;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,24 +4,23 @@ import 'package:krow_domain/krow_domain.dart';
|
||||
///
|
||||
/// This repository defines the contract for loading and updating
|
||||
/// staff profile information during onboarding or profile editing.
|
||||
///
|
||||
/// Implementations must delegate all data operations through
|
||||
/// the data_connect layer, following Clean Architecture principles.
|
||||
abstract interface class PersonalInfoRepositoryInterface {
|
||||
/// Retrieves the staff profile for the current authenticated user.
|
||||
/// Retrieves the personal info for the current authenticated staff member.
|
||||
///
|
||||
/// Returns the complete [Staff] entity with all profile information.
|
||||
Future<Staff> getStaffProfile();
|
||||
/// Returns the [StaffPersonalInfo] entity with name, contact, and location data.
|
||||
Future<StaffPersonalInfo> getStaffProfile();
|
||||
|
||||
/// Updates the staff profile information.
|
||||
/// Updates the staff personal information.
|
||||
///
|
||||
/// Takes a [Staff] entity ID and updated fields map and persists changes
|
||||
/// through the data layer. Returns the updated [Staff] entity.
|
||||
Future<Staff> updateStaffProfile({required String staffId, required Map<String, dynamic> data});
|
||||
/// Takes the staff member's [staffId] and updated [data] map.
|
||||
/// Returns the updated [StaffPersonalInfo] entity.
|
||||
Future<StaffPersonalInfo> updateStaffProfile({
|
||||
required String staffId,
|
||||
required Map<String, dynamic> data,
|
||||
});
|
||||
|
||||
/// Uploads a profile photo and returns the URL.
|
||||
///
|
||||
/// Takes the file path of the photo to upload.
|
||||
/// Returns the URL where the photo is stored.
|
||||
Future<String> uploadProfilePhoto(String filePath);
|
||||
}
|
||||
|
||||
@@ -1,22 +1,19 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import '../repositories/personal_info_repository_interface.dart';
|
||||
|
||||
/// Use case for retrieving staff profile information.
|
||||
import 'package:staff_profile_info/src/domain/repositories/personal_info_repository_interface.dart';
|
||||
|
||||
/// Use case for retrieving staff personal information.
|
||||
///
|
||||
/// This use case fetches the complete staff profile from the repository,
|
||||
/// which delegates to the data_connect layer for data access.
|
||||
class GetPersonalInfoUseCase
|
||||
implements NoInputUseCase<Staff> {
|
||||
|
||||
/// Fetches the personal info from the V2 API via the repository.
|
||||
class GetPersonalInfoUseCase implements NoInputUseCase<StaffPersonalInfo> {
|
||||
/// Creates a [GetPersonalInfoUseCase].
|
||||
///
|
||||
/// Requires a [PersonalInfoRepositoryInterface] to fetch data.
|
||||
GetPersonalInfoUseCase(this._repository);
|
||||
|
||||
final PersonalInfoRepositoryInterface _repository;
|
||||
|
||||
@override
|
||||
Future<Staff> call() {
|
||||
Future<StaffPersonalInfo> call() {
|
||||
return _repository.getStaffProfile();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import '../repositories/personal_info_repository_interface.dart';
|
||||
|
||||
/// Arguments for updating staff profile information.
|
||||
import 'package:staff_profile_info/src/domain/repositories/personal_info_repository_interface.dart';
|
||||
|
||||
/// Arguments for updating staff personal information.
|
||||
class UpdatePersonalInfoParams extends UseCaseArgument {
|
||||
|
||||
/// Creates [UpdatePersonalInfoParams].
|
||||
const UpdatePersonalInfoParams({
|
||||
required this.staffId,
|
||||
required this.data,
|
||||
});
|
||||
|
||||
/// The staff member's ID.
|
||||
final String staffId;
|
||||
|
||||
@@ -19,21 +21,16 @@ class UpdatePersonalInfoParams extends UseCaseArgument {
|
||||
List<Object?> get props => <Object?>[staffId, data];
|
||||
}
|
||||
|
||||
/// Use case for updating staff profile information.
|
||||
///
|
||||
/// This use case updates the staff profile information
|
||||
/// through the repository, which delegates to the data_connect layer.
|
||||
/// Use case for updating staff personal information via the V2 API.
|
||||
class UpdatePersonalInfoUseCase
|
||||
implements UseCase<UpdatePersonalInfoParams, Staff> {
|
||||
|
||||
implements UseCase<UpdatePersonalInfoParams, StaffPersonalInfo> {
|
||||
/// Creates an [UpdatePersonalInfoUseCase].
|
||||
///
|
||||
/// Requires a [PersonalInfoRepositoryInterface] to update data.
|
||||
UpdatePersonalInfoUseCase(this._repository);
|
||||
|
||||
final PersonalInfoRepositoryInterface _repository;
|
||||
|
||||
@override
|
||||
Future<Staff> call(UpdatePersonalInfoParams params) {
|
||||
Future<StaffPersonalInfo> call(UpdatePersonalInfoParams params) {
|
||||
return _repository.updateStaffProfile(
|
||||
staffId: params.staffId,
|
||||
data: params.data,
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
|
||||
import 'package:staff_profile_info/src/domain/repositories/personal_info_repository_interface.dart';
|
||||
|
||||
/// Use case for uploading a staff profile photo via the V2 API.
|
||||
///
|
||||
/// Accepts the local file path and returns the public URL of the
|
||||
/// uploaded photo after it has been stored and registered.
|
||||
class UploadProfilePhotoUseCase implements UseCase<String, String> {
|
||||
/// Creates an [UploadProfilePhotoUseCase].
|
||||
UploadProfilePhotoUseCase(this._repository);
|
||||
|
||||
final PersonalInfoRepositoryInterface _repository;
|
||||
|
||||
@override
|
||||
Future<String> call(String filePath) {
|
||||
return _repository.uploadProfilePhoto(filePath);
|
||||
}
|
||||
}
|
||||
@@ -1,44 +1,48 @@
|
||||
// ignore_for_file: always_specify_types, depend_on_referenced_packages, dead_code, dead_null_aware_expression, unused_local_variable, unused_import, sort_constructors_first, prefer_final_fields, prefer_const_constructors, deprecated_member_use, implicit_call_tearoffs
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
import '../../domain/usecases/get_personal_info_usecase.dart';
|
||||
import '../../domain/usecases/update_personal_info_usecase.dart';
|
||||
import 'personal_info_event.dart';
|
||||
import 'personal_info_state.dart';
|
||||
import 'package:staff_profile_info/src/domain/usecases/get_personal_info_usecase.dart';
|
||||
import 'package:staff_profile_info/src/domain/usecases/update_personal_info_usecase.dart';
|
||||
import 'package:staff_profile_info/src/domain/usecases/upload_profile_photo_usecase.dart';
|
||||
import 'package:staff_profile_info/src/presentation/blocs/personal_info_event.dart';
|
||||
import 'package:staff_profile_info/src/presentation/blocs/personal_info_state.dart';
|
||||
|
||||
/// BLoC responsible for managing staff profile information state.
|
||||
/// BLoC responsible for managing staff personal information state.
|
||||
///
|
||||
/// This BLoC handles loading, updating, and saving staff profile information
|
||||
/// during onboarding or profile editing. It delegates business logic to
|
||||
/// use cases following Clean Architecture principles.
|
||||
/// Handles loading, updating, and saving personal information
|
||||
/// via V2 API use cases following Clean Architecture.
|
||||
class PersonalInfoBloc extends Bloc<PersonalInfoEvent, PersonalInfoState>
|
||||
with BlocErrorHandler<PersonalInfoState>, SafeBloc<PersonalInfoEvent, PersonalInfoState>
|
||||
with
|
||||
BlocErrorHandler<PersonalInfoState>,
|
||||
SafeBloc<PersonalInfoEvent, PersonalInfoState>
|
||||
implements Disposable {
|
||||
/// Creates a [PersonalInfoBloc].
|
||||
///
|
||||
/// Requires the use cases to load and update the profile.
|
||||
PersonalInfoBloc({
|
||||
required GetPersonalInfoUseCase getPersonalInfoUseCase,
|
||||
required UpdatePersonalInfoUseCase updatePersonalInfoUseCase,
|
||||
}) : _getPersonalInfoUseCase = getPersonalInfoUseCase,
|
||||
_updatePersonalInfoUseCase = updatePersonalInfoUseCase,
|
||||
super(const PersonalInfoState.initial()) {
|
||||
required UploadProfilePhotoUseCase uploadProfilePhotoUseCase,
|
||||
}) : _getPersonalInfoUseCase = getPersonalInfoUseCase,
|
||||
_updatePersonalInfoUseCase = updatePersonalInfoUseCase,
|
||||
_uploadProfilePhotoUseCase = uploadProfilePhotoUseCase,
|
||||
super(const PersonalInfoState.initial()) {
|
||||
on<PersonalInfoLoadRequested>(_onLoadRequested);
|
||||
on<PersonalInfoFieldChanged>(_onFieldChanged);
|
||||
on<PersonalInfoAddressSelected>(_onAddressSelected);
|
||||
on<PersonalInfoFormSubmitted>(_onSubmitted);
|
||||
on<PersonalInfoLocationAdded>(_onLocationAdded);
|
||||
on<PersonalInfoLocationRemoved>(_onLocationRemoved);
|
||||
on<PersonalInfoPhotoUploadRequested>(_onPhotoUploadRequested);
|
||||
|
||||
add(const PersonalInfoLoadRequested());
|
||||
}
|
||||
|
||||
final GetPersonalInfoUseCase _getPersonalInfoUseCase;
|
||||
final UpdatePersonalInfoUseCase _updatePersonalInfoUseCase;
|
||||
final UploadProfilePhotoUseCase _uploadProfilePhotoUseCase;
|
||||
|
||||
/// Handles loading staff profile information.
|
||||
/// Handles loading staff personal information.
|
||||
Future<void> _onLoadRequested(
|
||||
PersonalInfoLoadRequested event,
|
||||
Emitter<PersonalInfoState> emit,
|
||||
@@ -47,25 +51,23 @@ class PersonalInfoBloc extends Bloc<PersonalInfoEvent, PersonalInfoState>
|
||||
await handleError(
|
||||
emit: emit.call,
|
||||
action: () async {
|
||||
final Staff staff = await _getPersonalInfoUseCase();
|
||||
final StaffPersonalInfo info = await _getPersonalInfoUseCase();
|
||||
|
||||
// Initialize form values from staff entity
|
||||
// Note: Staff entity currently stores address as a string, but we want to map it to 'preferredLocations'
|
||||
final Map<String, dynamic> initialValues = <String, dynamic>{
|
||||
'name': staff.name,
|
||||
'email': staff.email,
|
||||
'phone': staff.phone,
|
||||
'firstName': info.firstName ?? '',
|
||||
'lastName': info.lastName ?? '',
|
||||
'email': info.email ?? '',
|
||||
'phone': info.phone ?? '',
|
||||
'bio': info.bio ?? '',
|
||||
'preferredLocations':
|
||||
staff.preferredLocations != null
|
||||
? List<String>.from(staff.preferredLocations!)
|
||||
: <String>[],
|
||||
'avatar': staff.avatar,
|
||||
List<String>.from(info.preferredLocations),
|
||||
'maxDistanceMiles': info.maxDistanceMiles,
|
||||
};
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: PersonalInfoStatus.loaded,
|
||||
staff: staff,
|
||||
personalInfo: info,
|
||||
formValues: initialValues,
|
||||
),
|
||||
);
|
||||
@@ -77,50 +79,50 @@ class PersonalInfoBloc extends Bloc<PersonalInfoEvent, PersonalInfoState>
|
||||
);
|
||||
}
|
||||
|
||||
/// Handles updating a field value in the current staff profile.
|
||||
/// Handles updating a field value in the current form.
|
||||
void _onFieldChanged(
|
||||
PersonalInfoFieldChanged event,
|
||||
Emitter<PersonalInfoState> emit,
|
||||
) {
|
||||
final Map<String, dynamic> updatedValues = Map.from(state.formValues);
|
||||
final Map<String, dynamic> updatedValues =
|
||||
Map<String, dynamic>.from(state.formValues);
|
||||
updatedValues[event.field] = event.value;
|
||||
emit(state.copyWith(formValues: updatedValues));
|
||||
}
|
||||
|
||||
/// Handles saving staff profile information.
|
||||
/// Handles saving staff personal information.
|
||||
Future<void> _onSubmitted(
|
||||
PersonalInfoFormSubmitted event,
|
||||
Emitter<PersonalInfoState> emit,
|
||||
) async {
|
||||
if (state.staff == null) return;
|
||||
if (state.personalInfo == null) return;
|
||||
|
||||
emit(state.copyWith(status: PersonalInfoStatus.saving));
|
||||
await handleError(
|
||||
emit: emit.call,
|
||||
action: () async {
|
||||
final Staff updatedStaff = await _updatePersonalInfoUseCase(
|
||||
final StaffPersonalInfo updated = await _updatePersonalInfoUseCase(
|
||||
UpdatePersonalInfoParams(
|
||||
staffId: state.staff!.id,
|
||||
staffId: state.personalInfo!.staffId,
|
||||
data: state.formValues,
|
||||
),
|
||||
);
|
||||
|
||||
// Update local state with the returned staff and keep form values in sync
|
||||
final Map<String, dynamic> newValues = <String, dynamic>{
|
||||
'name': updatedStaff.name,
|
||||
'email': updatedStaff.email,
|
||||
'phone': updatedStaff.phone,
|
||||
'firstName': updated.firstName ?? '',
|
||||
'lastName': updated.lastName ?? '',
|
||||
'email': updated.email ?? '',
|
||||
'phone': updated.phone ?? '',
|
||||
'bio': updated.bio ?? '',
|
||||
'preferredLocations':
|
||||
updatedStaff.preferredLocations != null
|
||||
? List<String>.from(updatedStaff.preferredLocations!)
|
||||
: <String>[],
|
||||
'avatar': updatedStaff.avatar,
|
||||
List<String>.from(updated.preferredLocations),
|
||||
'maxDistanceMiles': updated.maxDistanceMiles,
|
||||
};
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: PersonalInfoStatus.saved,
|
||||
staff: updatedStaff,
|
||||
personalInfo: updated,
|
||||
formValues: newValues,
|
||||
),
|
||||
);
|
||||
@@ -132,11 +134,12 @@ class PersonalInfoBloc extends Bloc<PersonalInfoEvent, PersonalInfoState>
|
||||
);
|
||||
}
|
||||
|
||||
/// Legacy address selected no-op.
|
||||
void _onAddressSelected(
|
||||
PersonalInfoAddressSelected event,
|
||||
Emitter<PersonalInfoState> emit,
|
||||
) {
|
||||
// Legacy address selected no-op; use PersonalInfoLocationAdded instead.
|
||||
// No-op; use PersonalInfoLocationAdded instead.
|
||||
}
|
||||
|
||||
/// Adds a location to the preferredLocations list (max 5, no duplicates).
|
||||
@@ -144,15 +147,18 @@ class PersonalInfoBloc extends Bloc<PersonalInfoEvent, PersonalInfoState>
|
||||
PersonalInfoLocationAdded event,
|
||||
Emitter<PersonalInfoState> emit,
|
||||
) {
|
||||
final dynamic raw = state.formValues['preferredLocations'];
|
||||
final List<String> current = _toStringList(raw);
|
||||
final List<String> current = _toStringList(
|
||||
state.formValues['preferredLocations'],
|
||||
);
|
||||
|
||||
if (current.length >= 5) return; // max guard
|
||||
if (current.contains(event.location)) return; // no duplicates
|
||||
if (current.length >= 5) return;
|
||||
if (current.contains(event.location)) return;
|
||||
|
||||
final List<String> updated = List<String>.from(current)..add(event.location);
|
||||
final Map<String, dynamic> updatedValues = Map<String, dynamic>.from(state.formValues)
|
||||
..['preferredLocations'] = updated;
|
||||
final List<String> updated = List<String>.from(current)
|
||||
..add(event.location);
|
||||
final Map<String, dynamic> updatedValues =
|
||||
Map<String, dynamic>.from(state.formValues)
|
||||
..['preferredLocations'] = updated;
|
||||
|
||||
emit(state.copyWith(formValues: updatedValues));
|
||||
}
|
||||
@@ -162,17 +168,62 @@ class PersonalInfoBloc extends Bloc<PersonalInfoEvent, PersonalInfoState>
|
||||
PersonalInfoLocationRemoved event,
|
||||
Emitter<PersonalInfoState> emit,
|
||||
) {
|
||||
final dynamic raw = state.formValues['preferredLocations'];
|
||||
final List<String> current = _toStringList(raw);
|
||||
final List<String> current = _toStringList(
|
||||
state.formValues['preferredLocations'],
|
||||
);
|
||||
|
||||
final List<String> updated = List<String>.from(current)
|
||||
..remove(event.location);
|
||||
final Map<String, dynamic> updatedValues = Map<String, dynamic>.from(state.formValues)
|
||||
..['preferredLocations'] = updated;
|
||||
final Map<String, dynamic> updatedValues =
|
||||
Map<String, dynamic>.from(state.formValues)
|
||||
..['preferredLocations'] = updated;
|
||||
|
||||
emit(state.copyWith(formValues: updatedValues));
|
||||
}
|
||||
|
||||
/// Handles uploading a profile photo via the V2 API.
|
||||
Future<void> _onPhotoUploadRequested(
|
||||
PersonalInfoPhotoUploadRequested event,
|
||||
Emitter<PersonalInfoState> emit,
|
||||
) async {
|
||||
emit(state.copyWith(status: PersonalInfoStatus.uploadingPhoto));
|
||||
await handleError(
|
||||
emit: emit.call,
|
||||
action: () async {
|
||||
final String photoUrl =
|
||||
await _uploadProfilePhotoUseCase(event.filePath);
|
||||
|
||||
// Update the personalInfo entity with the new photo URL.
|
||||
final StaffPersonalInfo? currentInfo = state.personalInfo;
|
||||
final StaffPersonalInfo updatedInfo = StaffPersonalInfo(
|
||||
staffId: currentInfo?.staffId ?? '',
|
||||
firstName: currentInfo?.firstName,
|
||||
lastName: currentInfo?.lastName,
|
||||
bio: currentInfo?.bio,
|
||||
preferredLocations: currentInfo?.preferredLocations ?? const <String>[],
|
||||
maxDistanceMiles: currentInfo?.maxDistanceMiles,
|
||||
industries: currentInfo?.industries ?? const <String>[],
|
||||
skills: currentInfo?.skills ?? const <String>[],
|
||||
email: currentInfo?.email,
|
||||
phone: currentInfo?.phone,
|
||||
photoUrl: photoUrl,
|
||||
);
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: PersonalInfoStatus.photoUploaded,
|
||||
personalInfo: updatedInfo,
|
||||
),
|
||||
);
|
||||
},
|
||||
onError: (String errorKey) => state.copyWith(
|
||||
status: PersonalInfoStatus.error,
|
||||
errorMessage: errorKey,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Safely converts a dynamic value to a string list.
|
||||
List<String> _toStringList(dynamic raw) {
|
||||
if (raw is List<String>) return raw;
|
||||
if (raw is List) return raw.map((dynamic e) => e.toString()).toList();
|
||||
@@ -184,5 +235,3 @@ class PersonalInfoBloc extends Bloc<PersonalInfoEvent, PersonalInfoState>
|
||||
close();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -52,9 +52,24 @@ class PersonalInfoLocationAdded extends PersonalInfoEvent {
|
||||
|
||||
/// Event to remove a preferred location.
|
||||
class PersonalInfoLocationRemoved extends PersonalInfoEvent {
|
||||
/// Creates a [PersonalInfoLocationRemoved].
|
||||
const PersonalInfoLocationRemoved({required this.location});
|
||||
|
||||
/// The location to remove.
|
||||
final String location;
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[location];
|
||||
}
|
||||
|
||||
/// Event to upload a profile photo from the given file path.
|
||||
class PersonalInfoPhotoUploadRequested extends PersonalInfoEvent {
|
||||
/// Creates a [PersonalInfoPhotoUploadRequested].
|
||||
const PersonalInfoPhotoUploadRequested({required this.filePath});
|
||||
|
||||
/// The local file path of the selected photo.
|
||||
final String filePath;
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[filePath];
|
||||
}
|
||||
|
||||
@@ -21,19 +21,21 @@ enum PersonalInfoStatus {
|
||||
/// Uploading photo.
|
||||
uploadingPhoto,
|
||||
|
||||
/// Photo uploaded successfully.
|
||||
photoUploaded,
|
||||
|
||||
/// An error occurred.
|
||||
error,
|
||||
}
|
||||
|
||||
/// State for the Personal Info BLoC.
|
||||
///
|
||||
/// Uses the shared [Staff] entity from the domain layer.
|
||||
/// Uses [StaffPersonalInfo] from the V2 domain layer.
|
||||
class PersonalInfoState extends Equatable {
|
||||
|
||||
/// Creates a [PersonalInfoState].
|
||||
const PersonalInfoState({
|
||||
this.status = PersonalInfoStatus.initial,
|
||||
this.staff,
|
||||
this.personalInfo,
|
||||
this.formValues = const <String, dynamic>{},
|
||||
this.errorMessage,
|
||||
});
|
||||
@@ -41,14 +43,15 @@ class PersonalInfoState extends Equatable {
|
||||
/// Initial state.
|
||||
const PersonalInfoState.initial()
|
||||
: status = PersonalInfoStatus.initial,
|
||||
staff = null,
|
||||
personalInfo = null,
|
||||
formValues = const <String, dynamic>{},
|
||||
errorMessage = null;
|
||||
|
||||
/// The current status of the operation.
|
||||
final PersonalInfoStatus status;
|
||||
|
||||
/// The staff profile information.
|
||||
final Staff? staff;
|
||||
/// The staff personal information.
|
||||
final StaffPersonalInfo? personalInfo;
|
||||
|
||||
/// The form values being edited.
|
||||
final Map<String, dynamic> formValues;
|
||||
@@ -59,18 +62,19 @@ class PersonalInfoState extends Equatable {
|
||||
/// Creates a copy of this state with the given fields replaced.
|
||||
PersonalInfoState copyWith({
|
||||
PersonalInfoStatus? status,
|
||||
Staff? staff,
|
||||
StaffPersonalInfo? personalInfo,
|
||||
Map<String, dynamic>? formValues,
|
||||
String? errorMessage,
|
||||
}) {
|
||||
return PersonalInfoState(
|
||||
status: status ?? this.status,
|
||||
staff: staff ?? this.staff,
|
||||
personalInfo: personalInfo ?? this.personalInfo,
|
||||
formValues: formValues ?? this.formValues,
|
||||
errorMessage: errorMessage,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[status, staff, formValues, errorMessage];
|
||||
List<Object?> get props =>
|
||||
<Object?>[status, personalInfo, formValues, errorMessage];
|
||||
}
|
||||
|
||||
@@ -9,14 +9,10 @@ import 'package:staff_profile_info/src/presentation/blocs/personal_info_state.da
|
||||
import 'package:staff_profile_info/src/presentation/widgets/personal_info_page/personal_info_content.dart';
|
||||
import 'package:staff_profile_info/src/presentation/widgets/personal_info_skeleton/personal_info_skeleton.dart';
|
||||
|
||||
|
||||
/// The Personal Info page for staff onboarding.
|
||||
///
|
||||
/// This page allows staff members to view and edit their personal information
|
||||
/// including phone number and address. Full name and email are read-only as they come from authentication.
|
||||
///
|
||||
/// This page is a StatelessWidget that uses BLoC for state management,
|
||||
/// following Clean Architecture and the design system guidelines.
|
||||
/// Allows staff members to view and edit their personal information
|
||||
/// including phone number and address. Uses V2 API via BLoC.
|
||||
class PersonalInfoPage extends StatelessWidget {
|
||||
/// Creates a [PersonalInfoPage].
|
||||
const PersonalInfoPage({super.key});
|
||||
@@ -37,6 +33,12 @@ class PersonalInfoPage extends StatelessWidget {
|
||||
type: UiSnackbarType.success,
|
||||
);
|
||||
Modular.to.popSafe();
|
||||
} else if (state.status == PersonalInfoStatus.photoUploaded) {
|
||||
UiSnackbar.show(
|
||||
context,
|
||||
message: i18n.photo_upload_success,
|
||||
type: UiSnackbarType.success,
|
||||
);
|
||||
} else if (state.status == PersonalInfoStatus.error) {
|
||||
UiSnackbar.show(
|
||||
context,
|
||||
@@ -60,7 +62,7 @@ class PersonalInfoPage extends StatelessWidget {
|
||||
return const PersonalInfoSkeleton();
|
||||
}
|
||||
|
||||
if (state.staff == null) {
|
||||
if (state.personalInfo == null) {
|
||||
return Center(
|
||||
child: Text(
|
||||
'Failed to load personal information',
|
||||
@@ -69,7 +71,9 @@ class PersonalInfoPage extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
return PersonalInfoContent(staff: state.staff!);
|
||||
return PersonalInfoContent(
|
||||
personalInfo: state.personalInfo!,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import 'package:staff_profile_info/src/presentation/blocs/personal_info_bloc.dart';
|
||||
import 'package:staff_profile_info/src/presentation/blocs/personal_info_event.dart';
|
||||
@@ -12,18 +14,16 @@ import 'package:staff_profile_info/src/presentation/widgets/save_button.dart';
|
||||
|
||||
/// Content widget that displays and manages the staff profile form.
|
||||
///
|
||||
/// This widget is extracted from the page to handle form state separately,
|
||||
/// following Clean Architecture's separation of concerns principle and the design system guidelines.
|
||||
/// Works with the shared [Staff] entity from the domain layer.
|
||||
/// Works with [StaffPersonalInfo] from the V2 domain layer.
|
||||
class PersonalInfoContent extends StatefulWidget {
|
||||
|
||||
/// Creates a [PersonalInfoContent].
|
||||
const PersonalInfoContent({
|
||||
super.key,
|
||||
required this.staff,
|
||||
required this.personalInfo,
|
||||
});
|
||||
/// The staff profile to display and edit.
|
||||
final Staff staff;
|
||||
|
||||
/// The staff personal info to display and edit.
|
||||
final StaffPersonalInfo personalInfo;
|
||||
|
||||
@override
|
||||
State<PersonalInfoContent> createState() => _PersonalInfoContentState();
|
||||
@@ -36,10 +36,13 @@ class _PersonalInfoContentState extends State<PersonalInfoContent> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_emailController = TextEditingController(text: widget.staff.email);
|
||||
_phoneController = TextEditingController(text: widget.staff.phone ?? '');
|
||||
_emailController = TextEditingController(
|
||||
text: widget.personalInfo.email ?? '',
|
||||
);
|
||||
_phoneController = TextEditingController(
|
||||
text: widget.personalInfo.phone ?? '',
|
||||
);
|
||||
|
||||
// Listen to changes and update BLoC
|
||||
_emailController.addListener(_onEmailChanged);
|
||||
_phoneController.addListener(_onPhoneChanged);
|
||||
}
|
||||
@@ -51,42 +54,120 @@ class _PersonalInfoContentState extends State<PersonalInfoContent> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
|
||||
void _onEmailChanged() {
|
||||
context.read<PersonalInfoBloc>().add(
|
||||
PersonalInfoFieldChanged(
|
||||
field: 'email',
|
||||
value: _emailController.text,
|
||||
),
|
||||
);
|
||||
ReadContext(context).read<PersonalInfoBloc>().add(
|
||||
PersonalInfoFieldChanged(
|
||||
field: 'email',
|
||||
value: _emailController.text,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _onPhoneChanged() {
|
||||
context.read<PersonalInfoBloc>().add(
|
||||
PersonalInfoFieldChanged(
|
||||
field: 'phone',
|
||||
value: _phoneController.text,
|
||||
),
|
||||
);
|
||||
ReadContext(context).read<PersonalInfoBloc>().add(
|
||||
PersonalInfoFieldChanged(
|
||||
field: 'phone',
|
||||
value: _phoneController.text,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _handleSave() {
|
||||
context.read<PersonalInfoBloc>().add(const PersonalInfoFormSubmitted());
|
||||
ReadContext(context).read<PersonalInfoBloc>().add(const PersonalInfoFormSubmitted());
|
||||
}
|
||||
|
||||
void _handlePhotoTap() {
|
||||
// TODO: Implement photo picker
|
||||
// context.read<PersonalInfoBloc>().add(
|
||||
// PersonalInfoPhotoUploadRequested(filePath: pickedFilePath),
|
||||
// );
|
||||
/// Shows a bottom sheet to choose between camera and gallery, then
|
||||
/// dispatches the upload event to the BLoC.
|
||||
Future<void> _handlePhotoTap() async {
|
||||
final TranslationsStaffOnboardingPersonalInfoEn i18n =
|
||||
t.staff.onboarding.personal_info;
|
||||
final TranslationsCommonEn common = t.common;
|
||||
|
||||
final String? source = await showModalBottomSheet<String>(
|
||||
context: context,
|
||||
builder: (BuildContext ctx) {
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: UiConstants.space4,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
bottom: UiConstants.space4,
|
||||
),
|
||||
child: Text(
|
||||
i18n.choose_photo_source,
|
||||
style: UiTypography.body1b.textPrimary,
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(
|
||||
UiIcons.camera,
|
||||
color: UiColors.primary,
|
||||
),
|
||||
title: Text(
|
||||
common.camera,
|
||||
style: UiTypography.body1r.textPrimary,
|
||||
),
|
||||
onTap: () => Navigator.pop(ctx, 'camera'),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(
|
||||
UiIcons.gallery,
|
||||
color: UiColors.primary,
|
||||
),
|
||||
title: Text(
|
||||
common.gallery,
|
||||
style: UiTypography.body1r.textPrimary,
|
||||
),
|
||||
onTap: () => Navigator.pop(ctx, 'gallery'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
if (source == null || !mounted) return;
|
||||
|
||||
String? filePath;
|
||||
if (source == 'camera') {
|
||||
final CameraService cameraService = Modular.get<CameraService>();
|
||||
filePath = await cameraService.takePhoto();
|
||||
} else {
|
||||
final GalleryService galleryService = Modular.get<GalleryService>();
|
||||
filePath = await galleryService.pickImage();
|
||||
}
|
||||
|
||||
if (filePath == null || !mounted) return;
|
||||
|
||||
ReadContext(context).read<PersonalInfoBloc>().add(
|
||||
PersonalInfoPhotoUploadRequested(filePath: filePath),
|
||||
);
|
||||
}
|
||||
|
||||
/// Computes the display name from personal info first/last name.
|
||||
String get _displayName {
|
||||
final String first = widget.personalInfo.firstName ?? '';
|
||||
final String last = widget.personalInfo.lastName ?? '';
|
||||
final String name = '$first $last'.trim();
|
||||
return name.isNotEmpty ? name : 'Staff';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final TranslationsStaffOnboardingPersonalInfoEn i18n = t.staff.onboarding.personal_info;
|
||||
final TranslationsStaffOnboardingPersonalInfoEn i18n =
|
||||
t.staff.onboarding.personal_info;
|
||||
return BlocBuilder<PersonalInfoBloc, PersonalInfoState>(
|
||||
builder: (BuildContext context, PersonalInfoState state) {
|
||||
final bool isSaving = state.status == PersonalInfoStatus.saving;
|
||||
final bool isUploadingPhoto =
|
||||
state.status == PersonalInfoStatus.uploadingPhoto;
|
||||
final bool isBusy = isSaving || isUploadingPhoto;
|
||||
return Column(
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
@@ -96,26 +177,29 @@ class _PersonalInfoContentState extends State<PersonalInfoContent> {
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: <Widget>[
|
||||
ProfilePhotoWidget(
|
||||
photoUrl: widget.staff.avatar,
|
||||
fullName: widget.staff.name,
|
||||
onTap: isSaving ? null : _handlePhotoTap,
|
||||
photoUrl: state.personalInfo?.photoUrl,
|
||||
fullName: _displayName,
|
||||
onTap: isBusy ? null : _handlePhotoTap,
|
||||
isUploading: isUploadingPhoto,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space6),
|
||||
PersonalInfoForm(
|
||||
fullName: widget.staff.name,
|
||||
email: widget.staff.email,
|
||||
fullName: _displayName,
|
||||
email: widget.personalInfo.email ?? '',
|
||||
emailController: _emailController,
|
||||
phoneController: _phoneController,
|
||||
currentLocations: _toStringList(state.formValues['preferredLocations']),
|
||||
enabled: !isSaving,
|
||||
currentLocations: _toStringList(
|
||||
state.formValues['preferredLocations'],
|
||||
),
|
||||
enabled: !isBusy,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space16), // Space for bottom button
|
||||
const SizedBox(height: UiConstants.space16),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
SaveButton(
|
||||
onPressed: isSaving ? null : _handleSave,
|
||||
onPressed: isBusy ? null : _handleSave,
|
||||
label: i18n.save_button,
|
||||
isLoading: isSaving,
|
||||
),
|
||||
@@ -125,6 +209,7 @@ class _PersonalInfoContentState extends State<PersonalInfoContent> {
|
||||
);
|
||||
}
|
||||
|
||||
/// Safely converts a dynamic value to a string list.
|
||||
List<String> _toStringList(dynamic raw) {
|
||||
if (raw is List<String>) return raw;
|
||||
if (raw is List) return raw.map((dynamic e) => e.toString()).toList();
|
||||
|
||||
@@ -16,7 +16,9 @@ class ProfilePhotoWidget extends StatelessWidget {
|
||||
required this.photoUrl,
|
||||
required this.fullName,
|
||||
required this.onTap,
|
||||
this.isUploading = false,
|
||||
});
|
||||
|
||||
/// The URL of the staff member's photo.
|
||||
final String? photoUrl;
|
||||
|
||||
@@ -26,6 +28,9 @@ class ProfilePhotoWidget extends StatelessWidget {
|
||||
/// Callback when the photo/camera button is tapped.
|
||||
final VoidCallback? onTap;
|
||||
|
||||
/// Whether a photo upload is currently in progress.
|
||||
final bool isUploading;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final TranslationsStaffOnboardingPersonalInfoEn i18n =
|
||||
@@ -44,19 +49,34 @@ class ProfilePhotoWidget extends StatelessWidget {
|
||||
shape: BoxShape.circle,
|
||||
color: UiColors.primary.withValues(alpha: 0.1),
|
||||
),
|
||||
child: photoUrl != null
|
||||
? ClipOval(
|
||||
child: Image.network(
|
||||
photoUrl!,
|
||||
fit: BoxFit.cover,
|
||||
child: isUploading
|
||||
? const Center(
|
||||
child: SizedBox(
|
||||
width: 32,
|
||||
height: 32,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: UiColors.primary,
|
||||
),
|
||||
),
|
||||
)
|
||||
: Center(
|
||||
child: Text(
|
||||
fullName.isNotEmpty ? fullName[0].toUpperCase() : '?',
|
||||
style: UiTypography.displayL.primary,
|
||||
),
|
||||
),
|
||||
: photoUrl != null
|
||||
? ClipOval(
|
||||
child: Image.network(
|
||||
photoUrl!,
|
||||
width: 96,
|
||||
height: 96,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
)
|
||||
: Center(
|
||||
child: Text(
|
||||
fullName.isNotEmpty
|
||||
? fullName[0].toUpperCase()
|
||||
: '?',
|
||||
style: UiTypography.displayL.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
bottom: 0,
|
||||
|
||||
@@ -1,47 +1,57 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
import 'data/repositories/personal_info_repository_impl.dart';
|
||||
import 'domain/repositories/personal_info_repository_interface.dart';
|
||||
import 'domain/usecases/get_personal_info_usecase.dart';
|
||||
import 'domain/usecases/update_personal_info_usecase.dart';
|
||||
import 'presentation/blocs/personal_info_bloc.dart';
|
||||
import 'presentation/pages/personal_info_page.dart';
|
||||
import 'presentation/pages/language_selection_page.dart';
|
||||
import 'presentation/pages/preferred_locations_page.dart';
|
||||
import 'package:staff_profile_info/src/data/repositories/personal_info_repository_impl.dart';
|
||||
import 'package:staff_profile_info/src/domain/repositories/personal_info_repository_interface.dart';
|
||||
import 'package:staff_profile_info/src/domain/usecases/get_personal_info_usecase.dart';
|
||||
import 'package:staff_profile_info/src/domain/usecases/update_personal_info_usecase.dart';
|
||||
import 'package:staff_profile_info/src/domain/usecases/upload_profile_photo_usecase.dart';
|
||||
import 'package:staff_profile_info/src/presentation/blocs/personal_info_bloc.dart';
|
||||
import 'package:staff_profile_info/src/presentation/pages/personal_info_page.dart';
|
||||
import 'package:staff_profile_info/src/presentation/pages/language_selection_page.dart';
|
||||
import 'package:staff_profile_info/src/presentation/pages/preferred_locations_page.dart';
|
||||
|
||||
/// The entry module for the Staff Profile Info feature.
|
||||
///
|
||||
/// This module provides routing and dependency injection for
|
||||
/// personal information functionality following Clean Architecture.
|
||||
///
|
||||
/// The module:
|
||||
/// - Registers repository implementations
|
||||
/// - Registers use cases that contain business logic
|
||||
/// - Registers BLoC for state management
|
||||
/// - Defines routes for navigation
|
||||
/// Provides routing and dependency injection for personal information
|
||||
/// functionality, using the V2 REST API via [BaseApiService].
|
||||
class StaffProfileInfoModule extends Module {
|
||||
@override
|
||||
List<Module> get imports => <Module>[CoreModule()];
|
||||
|
||||
@override
|
||||
void binds(Injector i) {
|
||||
// Repository
|
||||
i.addLazySingleton<PersonalInfoRepositoryInterface>(
|
||||
PersonalInfoRepositoryImpl.new,
|
||||
() => PersonalInfoRepositoryImpl(
|
||||
apiService: i.get<BaseApiService>(),
|
||||
uploadService: i.get<FileUploadService>(),
|
||||
signedUrlService: i.get<SignedUrlService>(),
|
||||
),
|
||||
);
|
||||
|
||||
// Use Cases - delegate business logic to repository
|
||||
// Use Cases
|
||||
i.addLazySingleton<GetPersonalInfoUseCase>(
|
||||
() => GetPersonalInfoUseCase(i.get<PersonalInfoRepositoryInterface>()),
|
||||
);
|
||||
i.addLazySingleton<UpdatePersonalInfoUseCase>(
|
||||
() => UpdatePersonalInfoUseCase(i.get<PersonalInfoRepositoryInterface>()),
|
||||
() =>
|
||||
UpdatePersonalInfoUseCase(i.get<PersonalInfoRepositoryInterface>()),
|
||||
);
|
||||
i.addLazySingleton<UploadProfilePhotoUseCase>(
|
||||
() => UploadProfilePhotoUseCase(
|
||||
i.get<PersonalInfoRepositoryInterface>(),
|
||||
),
|
||||
);
|
||||
|
||||
// BLoC - manages presentation state
|
||||
// BLoC
|
||||
i.addLazySingleton<PersonalInfoBloc>(
|
||||
() => PersonalInfoBloc(
|
||||
getPersonalInfoUseCase: i.get<GetPersonalInfoUseCase>(),
|
||||
updatePersonalInfoUseCase: i.get<UpdatePersonalInfoUseCase>(),
|
||||
uploadProfilePhotoUseCase: i.get<UploadProfilePhotoUseCase>(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ dependencies:
|
||||
bloc: ^8.1.0
|
||||
flutter_modular: ^6.3.0
|
||||
equatable: ^2.0.5
|
||||
|
||||
|
||||
# Architecture Packages
|
||||
design_system:
|
||||
path: ../../../../../design_system
|
||||
@@ -25,13 +25,10 @@ dependencies:
|
||||
path: ../../../../../core
|
||||
krow_domain:
|
||||
path: ../../../../../domain
|
||||
krow_data_connect:
|
||||
path: ../../../../../data_connect
|
||||
|
||||
firebase_auth: any
|
||||
firebase_data_connect: any
|
||||
google_places_flutter: ^2.1.1
|
||||
http: ^1.2.2
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
|
||||
@@ -1,58 +1,26 @@
|
||||
import 'dart:convert';
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:staff_faqs/src/domain/entities/faq_category.dart';
|
||||
import 'package:staff_faqs/src/domain/repositories/faqs_repository_interface.dart';
|
||||
|
||||
import '../../domain/entities/faq_category.dart';
|
||||
import '../../domain/entities/faq_item.dart';
|
||||
import '../../domain/repositories/faqs_repository_interface.dart';
|
||||
|
||||
/// Data layer implementation of FAQs repository
|
||||
/// V2 API implementation of [FaqsRepositoryInterface].
|
||||
///
|
||||
/// Handles loading FAQs from app assets (JSON file)
|
||||
/// Fetches FAQ data from the V2 REST backend via [ApiService].
|
||||
class FaqsRepositoryImpl implements FaqsRepositoryInterface {
|
||||
/// Private cache for FAQs to avoid reloading from assets multiple times
|
||||
List<FaqCategory>? _cachedFaqs;
|
||||
/// Creates a [FaqsRepositoryImpl] backed by the given [apiService].
|
||||
FaqsRepositoryImpl({required ApiService apiService})
|
||||
: _apiService = apiService;
|
||||
|
||||
final ApiService _apiService;
|
||||
|
||||
@override
|
||||
Future<List<FaqCategory>> getFaqs() async {
|
||||
try {
|
||||
// Return cached FAQs if available
|
||||
if (_cachedFaqs != null) {
|
||||
return _cachedFaqs!;
|
||||
}
|
||||
|
||||
// Load FAQs from JSON asset
|
||||
final String faqsJson = await rootBundle.loadString(
|
||||
'packages/staff_faqs/lib/src/assets/faqs/faqs.json',
|
||||
);
|
||||
|
||||
// Parse JSON
|
||||
final List<dynamic> decoded = jsonDecode(faqsJson) as List<dynamic>;
|
||||
|
||||
// Convert to domain entities
|
||||
_cachedFaqs = decoded.map((dynamic item) {
|
||||
final Map<String, dynamic> category = item as Map<String, dynamic>;
|
||||
final String categoryName = category['category'] as String;
|
||||
final List<dynamic> questionsData =
|
||||
category['questions'] as List<dynamic>;
|
||||
|
||||
final List<FaqItem> questions = questionsData.map((dynamic q) {
|
||||
final Map<String, dynamic> questionMap = q as Map<String, dynamic>;
|
||||
return FaqItem(
|
||||
question: questionMap['q'] as String,
|
||||
answer: questionMap['a'] as String,
|
||||
);
|
||||
}).toList();
|
||||
|
||||
return FaqCategory(
|
||||
category: categoryName,
|
||||
questions: questions,
|
||||
);
|
||||
}).toList();
|
||||
|
||||
return _cachedFaqs!;
|
||||
} catch (e) {
|
||||
// Return empty list on error
|
||||
final ApiResponse response =
|
||||
await _apiService.get(V2ApiEndpoints.staffFaqs);
|
||||
return _parseCategories(response);
|
||||
} catch (_) {
|
||||
return <FaqCategory>[];
|
||||
}
|
||||
}
|
||||
@@ -60,42 +28,24 @@ class FaqsRepositoryImpl implements FaqsRepositoryInterface {
|
||||
@override
|
||||
Future<List<FaqCategory>> searchFaqs(String query) async {
|
||||
try {
|
||||
// Get all FAQs first
|
||||
final List<FaqCategory> allFaqs = await getFaqs();
|
||||
|
||||
if (query.isEmpty) {
|
||||
return allFaqs;
|
||||
}
|
||||
|
||||
final String lowerQuery = query.toLowerCase();
|
||||
|
||||
// Filter categories based on matching questions
|
||||
final List<FaqCategory> filtered = allFaqs
|
||||
.map((FaqCategory category) {
|
||||
// Filter questions that match the query
|
||||
final List<FaqItem> matchingQuestions =
|
||||
category.questions.where((FaqItem item) {
|
||||
final String questionLower = item.question.toLowerCase();
|
||||
final String answerLower = item.answer.toLowerCase();
|
||||
return questionLower.contains(lowerQuery) ||
|
||||
answerLower.contains(lowerQuery);
|
||||
}).toList();
|
||||
|
||||
// Only include category if it has matching questions
|
||||
if (matchingQuestions.isNotEmpty) {
|
||||
return FaqCategory(
|
||||
category: category.category,
|
||||
questions: matchingQuestions,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.whereType<FaqCategory>()
|
||||
.toList();
|
||||
|
||||
return filtered;
|
||||
} catch (e) {
|
||||
final ApiResponse response = await _apiService.get(
|
||||
V2ApiEndpoints.staffFaqsSearch,
|
||||
params: <String, dynamic>{'q': query},
|
||||
);
|
||||
return _parseCategories(response);
|
||||
} catch (_) {
|
||||
return <FaqCategory>[];
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses the `items` array from a V2 API response into [FaqCategory] list.
|
||||
List<FaqCategory> _parseCategories(ApiResponse response) {
|
||||
final List<dynamic> items = response.data['items'] as List<dynamic>;
|
||||
return items
|
||||
.map(
|
||||
(dynamic item) =>
|
||||
FaqCategory.fromJson(item as Map<String, dynamic>),
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,35 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
import 'faq_item.dart';
|
||||
import 'package:staff_faqs/src/domain/entities/faq_item.dart';
|
||||
|
||||
/// Entity representing an FAQ category with its questions
|
||||
/// Entity representing an FAQ category with its questions.
|
||||
class FaqCategory extends Equatable {
|
||||
|
||||
/// Creates a [FaqCategory] with the given [category] name and [questions].
|
||||
const FaqCategory({
|
||||
required this.category,
|
||||
required this.questions,
|
||||
});
|
||||
/// The category name (e.g., "Getting Started", "Shifts & Work")
|
||||
|
||||
/// Deserializes a [FaqCategory] from a V2 API JSON map.
|
||||
///
|
||||
/// The API returns question items under the `items` key.
|
||||
factory FaqCategory.fromJson(Map<String, dynamic> json) {
|
||||
final List<dynamic> items = json['items'] as List<dynamic>;
|
||||
return FaqCategory(
|
||||
category: json['category'] as String,
|
||||
questions: items
|
||||
.map(
|
||||
(dynamic item) =>
|
||||
FaqItem.fromJson(item as Map<String, dynamic>),
|
||||
)
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
|
||||
/// The category name (e.g., "Getting Started", "Shifts & Work").
|
||||
final String category;
|
||||
|
||||
/// List of FAQ items in this category
|
||||
/// List of FAQ items in this category.
|
||||
final List<FaqItem> questions;
|
||||
|
||||
@override
|
||||
|
||||
@@ -1,16 +1,25 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// Entity representing a single FAQ question and answer
|
||||
/// Entity representing a single FAQ question and answer.
|
||||
class FaqItem extends Equatable {
|
||||
|
||||
/// Creates a [FaqItem] with the given [question] and [answer].
|
||||
const FaqItem({
|
||||
required this.question,
|
||||
required this.answer,
|
||||
});
|
||||
/// The question text
|
||||
|
||||
/// Deserializes a [FaqItem] from a JSON map.
|
||||
factory FaqItem.fromJson(Map<String, dynamic> json) {
|
||||
return FaqItem(
|
||||
question: json['question'] as String,
|
||||
answer: json['answer'] as String,
|
||||
);
|
||||
}
|
||||
|
||||
/// The question text.
|
||||
final String question;
|
||||
|
||||
/// The answer text
|
||||
/// The answer text.
|
||||
final String answer;
|
||||
|
||||
@override
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import '../entities/faq_category.dart';
|
||||
import 'package:staff_faqs/src/domain/entities/faq_category.dart';
|
||||
|
||||
/// Interface for FAQs repository operations
|
||||
abstract class FaqsRepositoryInterface {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user