Merge 592-migrate-frontend-applications-to-v2-backend-and-database into feature/session-persistence-new
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(StaffEndpoints.certificates);
|
||||
final List<dynamic> items =
|
||||
response.data['items'] as List<dynamic>? ?? <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(
|
||||
StaffEndpoints.certificates,
|
||||
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(
|
||||
StaffEndpoints.certificateDelete(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.issuer ?? 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(StaffEndpoints.documents);
|
||||
final List<dynamic> items = response.data['items'] as List<dynamic>? ?? <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(
|
||||
StaffEndpoints.documentUpload(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 Semantics(
|
||||
identifier: 'staff_document_upload',
|
||||
label: isVerified
|
||||
@@ -160,6 +149,7 @@ class DocumentCard extends StatelessWidget {
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: UiConstants.radiusSm,
|
||||
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
child: Row(
|
||||
|
||||
@@ -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(StaffEndpoints.taxForms);
|
||||
final List<dynamic> items = response.data['items'] as List<dynamic>? ?? <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(
|
||||
StaffEndpoints.taxFormUpdate(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(
|
||||
StaffEndpoints.taxFormSubmit(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,35 @@
|
||||
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/arguments/add_bank_account_params.dart';
|
||||
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(StaffEndpoints.bankAccounts);
|
||||
final List<dynamic> items = response.data['items'] as List<dynamic>? ?? <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(AddBankAccountParams params) async {
|
||||
await _api.post(
|
||||
StaffEndpoints.bankAccounts,
|
||||
data: params.toJson(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,44 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import 'package:krow_domain/krow_domain.dart' show AccountType;
|
||||
|
||||
/// Arguments for adding a bank account.
|
||||
class AddBankAccountParams extends UseCaseArgument with EquatableMixin {
|
||||
/// Parameters for creating a new bank account via the V2 API.
|
||||
///
|
||||
/// Maps directly to the `bankAccountCreateSchema` zod schema:
|
||||
/// `{ bankName, accountNumber, routingNumber, accountType }`.
|
||||
class AddBankAccountParams extends UseCaseArgument {
|
||||
/// Creates an [AddBankAccountParams].
|
||||
const AddBankAccountParams({
|
||||
required this.bankName,
|
||||
required this.accountNumber,
|
||||
required this.routingNumber,
|
||||
required this.accountType,
|
||||
});
|
||||
|
||||
const AddBankAccountParams({required this.account});
|
||||
final StaffBankAccount account;
|
||||
/// Name of the bank / financial institution.
|
||||
final String bankName;
|
||||
|
||||
/// Full account number.
|
||||
final String accountNumber;
|
||||
|
||||
/// Routing / transit number.
|
||||
final String routingNumber;
|
||||
|
||||
/// Account type (checking or savings).
|
||||
final AccountType accountType;
|
||||
|
||||
/// Serialises to the V2 API request body.
|
||||
Map<String, dynamic> toJson() => <String, dynamic>{
|
||||
'bankName': bankName,
|
||||
'accountNumber': accountNumber,
|
||||
'routingNumber': routingNumber,
|
||||
'accountType': accountType.toJson(),
|
||||
};
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[account];
|
||||
|
||||
@override
|
||||
bool? get stringify => true;
|
||||
List<Object?> get props => <Object?>[
|
||||
bankName,
|
||||
accountNumber,
|
||||
routingNumber,
|
||||
accountType,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import 'package:krow_domain/krow_domain.dart' show BankAccount;
|
||||
|
||||
import '../arguments/add_bank_account_params.dart';
|
||||
|
||||
/// Repository interface for managing bank accounts.
|
||||
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);
|
||||
/// Creates a new bank account with the given [params].
|
||||
Future<void> addAccount(AddBankAccountParams params);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
import '../repositories/bank_account_repository.dart';
|
||||
|
||||
import '../arguments/add_bank_account_params.dart';
|
||||
import '../repositories/bank_account_repository.dart';
|
||||
|
||||
/// Use case to add a bank account.
|
||||
class AddBankAccountUseCase implements UseCase<AddBankAccountParams, void> {
|
||||
|
||||
/// Creates an [AddBankAccountUseCase].
|
||||
AddBankAccountUseCase(this._repository);
|
||||
|
||||
final BankAccountRepository _repository;
|
||||
|
||||
@override
|
||||
Future<void> call(AddBankAccountParams params) {
|
||||
return _repository.addAccount(params.account);
|
||||
return _repository.addAccount(params);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import 'package:krow_domain/krow_domain.dart' show AccountType, BankAccount;
|
||||
|
||||
import '../../domain/arguments/add_bank_account_params.dart';
|
||||
import '../../domain/usecases/add_bank_account_usecase.dart';
|
||||
import '../../domain/usecases/get_bank_accounts_usecase.dart';
|
||||
@@ -23,7 +24,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),
|
||||
);
|
||||
@@ -47,26 +48,19 @@ class BankAccountCubit extends Cubit<BankAccountState>
|
||||
}) async {
|
||||
emit(state.copyWith(status: BankAccountStatus.loading));
|
||||
|
||||
// Create domain entity
|
||||
final StaffBankAccount newAccount = StaffBankAccount(
|
||||
id: '', // Generated by server usually
|
||||
userId: '', // Handled by Repo/Auth
|
||||
final AddBankAccountParams params = AddBankAccountParams(
|
||||
bankName: bankName,
|
||||
accountNumber: accountNumber.length > 4
|
||||
? accountNumber.substring(accountNumber.length - 4)
|
||||
: accountNumber,
|
||||
accountName: '',
|
||||
sortCode: routingNumber,
|
||||
type: type == 'CHECKING'
|
||||
? StaffBankAccountType.checking
|
||||
: StaffBankAccountType.savings,
|
||||
isPrimary: false,
|
||||
accountNumber: accountNumber,
|
||||
routingNumber: routingNumber,
|
||||
accountType: type == 'CHECKING'
|
||||
? AccountType.checking
|
||||
: AccountType.savings,
|
||||
);
|
||||
|
||||
await handleError(
|
||||
emit: emit,
|
||||
action: () async {
|
||||
await _addBankAccountUseCase(AddBankAccountParams(account: newAccount));
|
||||
await _addBankAccountUseCase(params);
|
||||
|
||||
// Re-fetch to get latest state including server-generated IDs
|
||||
await loadAccounts();
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -2,12 +2,12 @@ import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class SecurityNotice extends StatelessWidget {
|
||||
final dynamic strings;
|
||||
|
||||
const SecurityNotice({
|
||||
super.key,
|
||||
required this.strings,
|
||||
});
|
||||
|
||||
final dynamic strings;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
||||
@@ -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(
|
||||
StaffEndpoints.timeCard,
|
||||
params: <String, dynamic>{
|
||||
'year': month.year,
|
||||
'month': month.month,
|
||||
},
|
||||
);
|
||||
final List<dynamic> items = response.data['items'] as List<dynamic>? ?? <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(StaffEndpoints.attire);
|
||||
final List<dynamic> items = response.data['items'] as List<dynamic>? ?? <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(
|
||||
StaffEndpoints.attireUpload(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,54 @@
|
||||
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(StaffEndpoints.emergencyContacts);
|
||||
final List<dynamic> items =
|
||||
response.data['items'] as List<dynamic>? ?? <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();
|
||||
for (final EmergencyContact contact in contacts) {
|
||||
final Map<String, dynamic> body = <String, dynamic>{
|
||||
'fullName': contact.fullName,
|
||||
'phone': contact.phone,
|
||||
'relationshipType': contact.relationshipType,
|
||||
'isPrimary': contact.isPrimary,
|
||||
};
|
||||
|
||||
// 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();
|
||||
}));
|
||||
});
|
||||
if (contact.contactId.isNotEmpty) {
|
||||
// Existing contact — update via PUT.
|
||||
await _api.put(
|
||||
StaffEndpoints.emergencyContactUpdate(contact.contactId),
|
||||
data: body,
|
||||
);
|
||||
} else {
|
||||
// New contact — create via POST.
|
||||
await _api.post(
|
||||
StaffEndpoints.emergencyContacts,
|
||||
data: body,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,56 +1,66 @@
|
||||
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.
|
||||
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;
|
||||
final BaseApiService _api;
|
||||
|
||||
Future<dc.GetStaffByIdStaff> _getStaff() async {
|
||||
final staffId = await _service.getStaffId();
|
||||
@override
|
||||
Future<List<StaffIndustry>> getIndustries() async {
|
||||
final ApiResponse response = await _api.get(StaffEndpoints.industries);
|
||||
final List<dynamic> items =
|
||||
response.data['items'] as List<dynamic>? ?? <dynamic>[];
|
||||
return items
|
||||
.map((dynamic e) => StaffIndustry.fromJson(e.toString()))
|
||||
.whereType<StaffIndustry>()
|
||||
.toList();
|
||||
}
|
||||
|
||||
final result =
|
||||
await _service.connector.getStaffById(id: staffId).execute();
|
||||
if (result.data.staff == null) {
|
||||
throw const ServerException(technicalMessage: 'Staff profile not found');
|
||||
@override
|
||||
Future<({List<StaffSkill> skills, List<String> customSkills})>
|
||||
getSkills() async {
|
||||
final ApiResponse response = await _api.get(StaffEndpoints.skills);
|
||||
final List<dynamic> items =
|
||||
response.data['items'] as List<dynamic>? ?? <dynamic>[];
|
||||
|
||||
final List<StaffSkill> skills = <StaffSkill>[];
|
||||
final List<String> customSkills = <String>[];
|
||||
|
||||
for (final dynamic item in items) {
|
||||
final String value = item.toString();
|
||||
final StaffSkill? parsed = StaffSkill.fromJson(value);
|
||||
if (parsed != null) {
|
||||
skills.add(parsed);
|
||||
} else {
|
||||
customSkills.add(value);
|
||||
}
|
||||
}
|
||||
return result.data.staff!;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<String>> getIndustries() async {
|
||||
return _service.run(() async {
|
||||
final staff = await _getStaff();
|
||||
return staff.industries ?? [];
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<String>> getSkills() async {
|
||||
return _service.run(() async {
|
||||
final staff = await _getStaff();
|
||||
return staff.skills ?? [];
|
||||
});
|
||||
return (skills: skills, customSkills: customSkills);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> saveExperience(
|
||||
List<String> industries,
|
||||
List<String> skills,
|
||||
List<StaffIndustry> industries,
|
||||
List<StaffSkill> skills,
|
||||
List<String> customSkills,
|
||||
) async {
|
||||
return _service.run(() async {
|
||||
final staff = await _getStaff();
|
||||
await _service.connector
|
||||
.updateStaff(id: staff.id)
|
||||
.industries(industries)
|
||||
.skills(skills)
|
||||
.execute();
|
||||
});
|
||||
await _api.put(
|
||||
StaffEndpoints.experience,
|
||||
data: <String, dynamic>{
|
||||
'industries':
|
||||
industries.map((StaffIndustry i) => i.value).toList(),
|
||||
'skills': <String>[
|
||||
...skills.map((StaffSkill s) => s.value),
|
||||
...customSkills,
|
||||
],
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,24 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart' show StaffIndustry, StaffSkill;
|
||||
|
||||
/// Arguments for the [SaveExperienceUseCase].
|
||||
class SaveExperienceArguments extends UseCaseArgument {
|
||||
final List<String> industries;
|
||||
final List<String> skills;
|
||||
|
||||
/// Creates a [SaveExperienceArguments].
|
||||
const SaveExperienceArguments({
|
||||
required this.industries,
|
||||
required this.skills,
|
||||
this.customSkills = const <String>[],
|
||||
});
|
||||
|
||||
/// Selected industries.
|
||||
final List<StaffIndustry> industries;
|
||||
|
||||
/// Selected predefined skills.
|
||||
final List<StaffSkill> skills;
|
||||
|
||||
/// Custom skills not in the [StaffSkill] enum.
|
||||
final List<String> customSkills;
|
||||
|
||||
@override
|
||||
List<Object?> get props => [industries, skills];
|
||||
List<Object?> get props => <Object?>[industries, skills, customSkills];
|
||||
}
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
import 'package:krow_domain/krow_domain.dart' show StaffIndustry, StaffSkill;
|
||||
|
||||
/// Interface for accessing staff experience data.
|
||||
abstract class ExperienceRepositoryInterface {
|
||||
/// Fetches the list of industries associated with the staff member.
|
||||
Future<List<String>> getIndustries();
|
||||
Future<List<StaffIndustry>> getIndustries();
|
||||
|
||||
/// Fetches the list of skills associated with the staff member.
|
||||
Future<List<String>> getSkills();
|
||||
///
|
||||
/// Returns recognised [StaffSkill] values. Unrecognised API values are
|
||||
/// returned in [customSkills].
|
||||
Future<({List<StaffSkill> skills, List<String> customSkills})> getSkills();
|
||||
|
||||
/// Saves the staff member's experience (industries and skills).
|
||||
Future<void> saveExperience(
|
||||
List<String> industries,
|
||||
List<String> skills,
|
||||
List<StaffIndustry> industries,
|
||||
List<StaffSkill> skills,
|
||||
List<String> customSkills,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart' show StaffIndustry;
|
||||
|
||||
import '../repositories/experience_repository_interface.dart';
|
||||
|
||||
/// Use case for fetching staff industries.
|
||||
class GetStaffIndustriesUseCase implements NoInputUseCase<List<String>> {
|
||||
final ExperienceRepositoryInterface _repository;
|
||||
|
||||
class GetStaffIndustriesUseCase
|
||||
implements NoInputUseCase<List<StaffIndustry>> {
|
||||
/// Creates a [GetStaffIndustriesUseCase].
|
||||
GetStaffIndustriesUseCase(this._repository);
|
||||
|
||||
final ExperienceRepositoryInterface _repository;
|
||||
|
||||
@override
|
||||
Future<List<String>> call() {
|
||||
Future<List<StaffIndustry>> call() {
|
||||
return _repository.getIndustries();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart' show StaffSkill;
|
||||
|
||||
import '../repositories/experience_repository_interface.dart';
|
||||
|
||||
/// Use case for fetching staff skills.
|
||||
class GetStaffSkillsUseCase implements NoInputUseCase<List<String>> {
|
||||
final ExperienceRepositoryInterface _repository;
|
||||
|
||||
class GetStaffSkillsUseCase
|
||||
implements
|
||||
NoInputUseCase<({List<StaffSkill> skills, List<String> customSkills})> {
|
||||
/// Creates a [GetStaffSkillsUseCase].
|
||||
GetStaffSkillsUseCase(this._repository);
|
||||
|
||||
final ExperienceRepositoryInterface _repository;
|
||||
|
||||
@override
|
||||
Future<List<String>> call() {
|
||||
Future<({List<StaffSkill> skills, List<String> customSkills})> call() {
|
||||
return _repository.getSkills();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,22 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
|
||||
import '../arguments/save_experience_arguments.dart';
|
||||
import '../repositories/experience_repository_interface.dart';
|
||||
|
||||
/// Use case for saving staff experience details.
|
||||
///
|
||||
/// Delegates the saving logic to [ExperienceRepositoryInterface].
|
||||
class SaveExperienceUseCase extends UseCase<SaveExperienceArguments, void> {
|
||||
final ExperienceRepositoryInterface repository;
|
||||
|
||||
/// Creates a [SaveExperienceUseCase].
|
||||
SaveExperienceUseCase(this.repository);
|
||||
|
||||
/// The experience repository.
|
||||
final ExperienceRepositoryInterface repository;
|
||||
|
||||
@override
|
||||
Future<void> call(SaveExperienceArguments params) {
|
||||
return repository.saveExperience(
|
||||
params.industries,
|
||||
params.skills,
|
||||
params.customSkills,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,114 +1,26 @@
|
||||
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 'package:krow_domain/krow_domain.dart' show StaffIndustry, StaffSkill;
|
||||
|
||||
import '../../domain/arguments/save_experience_arguments.dart';
|
||||
import '../../domain/usecases/get_staff_industries_usecase.dart';
|
||||
import '../../domain/usecases/get_staff_skills_usecase.dart';
|
||||
import '../../domain/usecases/save_experience_usecase.dart';
|
||||
import 'experience_event.dart';
|
||||
import 'experience_state.dart';
|
||||
|
||||
// Events
|
||||
abstract class ExperienceEvent extends Equatable {
|
||||
const ExperienceEvent();
|
||||
export 'experience_event.dart';
|
||||
export 'experience_state.dart';
|
||||
|
||||
@override
|
||||
List<Object?> get props => [];
|
||||
}
|
||||
|
||||
class ExperienceLoaded extends ExperienceEvent {}
|
||||
|
||||
class ExperienceIndustryToggled extends ExperienceEvent {
|
||||
final Industry industry;
|
||||
const ExperienceIndustryToggled(this.industry);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [industry];
|
||||
}
|
||||
|
||||
class ExperienceSkillToggled extends ExperienceEvent {
|
||||
final String skill;
|
||||
const ExperienceSkillToggled(this.skill);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [skill];
|
||||
}
|
||||
|
||||
class ExperienceCustomSkillAdded extends ExperienceEvent {
|
||||
final String skill;
|
||||
const ExperienceCustomSkillAdded(this.skill);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [skill];
|
||||
}
|
||||
|
||||
class ExperienceSubmitted extends ExperienceEvent {}
|
||||
|
||||
// State
|
||||
enum ExperienceStatus { initial, loading, success, failure }
|
||||
|
||||
class ExperienceState extends Equatable {
|
||||
final ExperienceStatus status;
|
||||
final List<Industry> selectedIndustries;
|
||||
final List<String> selectedSkills;
|
||||
final List<Industry> availableIndustries;
|
||||
final List<ExperienceSkill> availableSkills;
|
||||
final String? errorMessage;
|
||||
|
||||
const ExperienceState({
|
||||
this.status = ExperienceStatus.initial,
|
||||
this.selectedIndustries = const [],
|
||||
this.selectedSkills = const [],
|
||||
this.availableIndustries = const [],
|
||||
this.availableSkills = const [],
|
||||
this.errorMessage,
|
||||
});
|
||||
|
||||
ExperienceState copyWith({
|
||||
ExperienceStatus? status,
|
||||
List<Industry>? selectedIndustries,
|
||||
List<String>? selectedSkills,
|
||||
List<Industry>? availableIndustries,
|
||||
List<ExperienceSkill>? availableSkills,
|
||||
String? errorMessage,
|
||||
}) {
|
||||
return ExperienceState(
|
||||
status: status ?? this.status,
|
||||
selectedIndustries: selectedIndustries ?? this.selectedIndustries,
|
||||
selectedSkills: selectedSkills ?? this.selectedSkills,
|
||||
availableIndustries: availableIndustries ?? this.availableIndustries,
|
||||
availableSkills: availableSkills ?? this.availableSkills,
|
||||
errorMessage: errorMessage ?? this.errorMessage,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
status,
|
||||
selectedIndustries,
|
||||
selectedSkills,
|
||||
availableIndustries,
|
||||
availableSkills,
|
||||
errorMessage,
|
||||
];
|
||||
}
|
||||
|
||||
// BLoC
|
||||
/// BLoC that manages the staff experience (industries & skills) selection.
|
||||
class ExperienceBloc extends Bloc<ExperienceEvent, ExperienceState>
|
||||
with BlocErrorHandler<ExperienceState> {
|
||||
final GetStaffIndustriesUseCase getIndustries;
|
||||
final GetStaffSkillsUseCase getSkills;
|
||||
final SaveExperienceUseCase saveExperience;
|
||||
|
||||
/// Creates an [ExperienceBloc].
|
||||
ExperienceBloc({
|
||||
required this.getIndustries,
|
||||
required this.getSkills,
|
||||
required this.saveExperience,
|
||||
}) : super(
|
||||
const ExperienceState(
|
||||
availableIndustries: Industry.values,
|
||||
availableSkills: ExperienceSkill.values,
|
||||
),
|
||||
) {
|
||||
}) : super(const ExperienceState()) {
|
||||
on<ExperienceLoaded>(_onLoaded);
|
||||
on<ExperienceIndustryToggled>(_onIndustryToggled);
|
||||
on<ExperienceSkillToggled>(_onSkillToggled);
|
||||
@@ -118,6 +30,15 @@ class ExperienceBloc extends Bloc<ExperienceEvent, ExperienceState>
|
||||
add(ExperienceLoaded());
|
||||
}
|
||||
|
||||
/// Use case for fetching saved industries.
|
||||
final GetStaffIndustriesUseCase getIndustries;
|
||||
|
||||
/// Use case for fetching saved skills.
|
||||
final GetStaffSkillsUseCase getSkills;
|
||||
|
||||
/// Use case for saving experience selections.
|
||||
final SaveExperienceUseCase saveExperience;
|
||||
|
||||
Future<void> _onLoaded(
|
||||
ExperienceLoaded event,
|
||||
Emitter<ExperienceState> emit,
|
||||
@@ -126,17 +47,16 @@ class ExperienceBloc extends Bloc<ExperienceEvent, ExperienceState>
|
||||
await handleError(
|
||||
emit: emit.call,
|
||||
action: () async {
|
||||
final results = await Future.wait([getIndustries(), getSkills()]);
|
||||
final List<StaffIndustry> industries = await getIndustries();
|
||||
final ({List<StaffSkill> skills, List<String> customSkills}) skillsResult =
|
||||
await getSkills();
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: ExperienceStatus.initial,
|
||||
selectedIndustries:
|
||||
results[0]
|
||||
.map((e) => Industry.fromString(e))
|
||||
.whereType<Industry>()
|
||||
.toList(),
|
||||
selectedSkills: results[1],
|
||||
selectedIndustries: industries,
|
||||
selectedSkills: skillsResult.skills,
|
||||
customSkills: skillsResult.customSkills,
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -151,7 +71,8 @@ class ExperienceBloc extends Bloc<ExperienceEvent, ExperienceState>
|
||||
ExperienceIndustryToggled event,
|
||||
Emitter<ExperienceState> emit,
|
||||
) {
|
||||
final industries = List<Industry>.from(state.selectedIndustries);
|
||||
final List<StaffIndustry> industries =
|
||||
List<StaffIndustry>.from(state.selectedIndustries);
|
||||
if (industries.contains(event.industry)) {
|
||||
industries.remove(event.industry);
|
||||
} else {
|
||||
@@ -164,7 +85,8 @@ class ExperienceBloc extends Bloc<ExperienceEvent, ExperienceState>
|
||||
ExperienceSkillToggled event,
|
||||
Emitter<ExperienceState> emit,
|
||||
) {
|
||||
final skills = List<String>.from(state.selectedSkills);
|
||||
final List<StaffSkill> skills =
|
||||
List<StaffSkill>.from(state.selectedSkills);
|
||||
if (skills.contains(event.skill)) {
|
||||
skills.remove(event.skill);
|
||||
} else {
|
||||
@@ -177,9 +99,10 @@ class ExperienceBloc extends Bloc<ExperienceEvent, ExperienceState>
|
||||
ExperienceCustomSkillAdded event,
|
||||
Emitter<ExperienceState> emit,
|
||||
) {
|
||||
if (!state.selectedSkills.contains(event.skill)) {
|
||||
final skills = List<String>.from(state.selectedSkills)..add(event.skill);
|
||||
emit(state.copyWith(selectedSkills: skills));
|
||||
if (!state.customSkills.contains(event.skill)) {
|
||||
final List<String> custom = List<String>.from(state.customSkills)
|
||||
..add(event.skill);
|
||||
emit(state.copyWith(customSkills: custom));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -193,8 +116,9 @@ 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,
|
||||
customSkills: state.customSkills,
|
||||
),
|
||||
);
|
||||
emit(state.copyWith(status: ExperienceStatus.success));
|
||||
@@ -206,4 +130,3 @@ class ExperienceBloc extends Bloc<ExperienceEvent, ExperienceState>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:krow_domain/krow_domain.dart' show StaffIndustry, StaffSkill;
|
||||
|
||||
/// Base event for the experience BLoC.
|
||||
abstract class ExperienceEvent extends Equatable {
|
||||
/// Creates an [ExperienceEvent].
|
||||
const ExperienceEvent();
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[];
|
||||
}
|
||||
|
||||
/// Triggers initial load of saved industries and skills.
|
||||
class ExperienceLoaded extends ExperienceEvent {}
|
||||
|
||||
/// Toggles an industry selection on or off.
|
||||
class ExperienceIndustryToggled extends ExperienceEvent {
|
||||
/// Creates an [ExperienceIndustryToggled] event.
|
||||
const ExperienceIndustryToggled(this.industry);
|
||||
|
||||
/// The industry to toggle.
|
||||
final StaffIndustry industry;
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[industry];
|
||||
}
|
||||
|
||||
/// Toggles a skill selection on or off.
|
||||
class ExperienceSkillToggled extends ExperienceEvent {
|
||||
/// Creates an [ExperienceSkillToggled] event.
|
||||
const ExperienceSkillToggled(this.skill);
|
||||
|
||||
/// The skill to toggle.
|
||||
final StaffSkill skill;
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[skill];
|
||||
}
|
||||
|
||||
/// Adds a custom skill not in the predefined [StaffSkill] enum.
|
||||
class ExperienceCustomSkillAdded extends ExperienceEvent {
|
||||
/// Creates an [ExperienceCustomSkillAdded] event.
|
||||
const ExperienceCustomSkillAdded(this.skill);
|
||||
|
||||
/// The custom skill value to add.
|
||||
final String skill;
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[skill];
|
||||
}
|
||||
|
||||
/// Submits the selected industries and skills to the backend.
|
||||
class ExperienceSubmitted extends ExperienceEvent {}
|
||||
@@ -0,0 +1,77 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:krow_domain/krow_domain.dart' show StaffIndustry, StaffSkill;
|
||||
|
||||
/// Status of the experience feature.
|
||||
enum ExperienceStatus {
|
||||
/// Initial state before any action.
|
||||
initial,
|
||||
|
||||
/// Loading data from the backend.
|
||||
loading,
|
||||
|
||||
/// Operation completed successfully.
|
||||
success,
|
||||
|
||||
/// An error occurred.
|
||||
failure,
|
||||
}
|
||||
|
||||
/// State for the experience BLoC.
|
||||
class ExperienceState extends Equatable {
|
||||
/// Creates an [ExperienceState].
|
||||
const ExperienceState({
|
||||
this.status = ExperienceStatus.initial,
|
||||
this.selectedIndustries = const <StaffIndustry>[],
|
||||
this.selectedSkills = const <StaffSkill>[],
|
||||
this.customSkills = const <String>[],
|
||||
this.errorMessage,
|
||||
});
|
||||
|
||||
/// Current operation status.
|
||||
final ExperienceStatus status;
|
||||
|
||||
/// Industries the staff member has selected.
|
||||
final List<StaffIndustry> selectedIndustries;
|
||||
|
||||
/// Skills the staff member has selected.
|
||||
final List<StaffSkill> selectedSkills;
|
||||
|
||||
/// Custom skills not in [StaffSkill] that the user added.
|
||||
final List<String> customSkills;
|
||||
|
||||
/// Error message key when [status] is [ExperienceStatus.failure].
|
||||
final String? errorMessage;
|
||||
|
||||
/// All selected skill values as API strings (enum + custom combined).
|
||||
List<String> get allSkillValues =>
|
||||
<String>[
|
||||
...selectedSkills.map((StaffSkill s) => s.value),
|
||||
...customSkills,
|
||||
];
|
||||
|
||||
/// Creates a copy with the given fields replaced.
|
||||
ExperienceState copyWith({
|
||||
ExperienceStatus? status,
|
||||
List<StaffIndustry>? selectedIndustries,
|
||||
List<StaffSkill>? selectedSkills,
|
||||
List<String>? customSkills,
|
||||
String? errorMessage,
|
||||
}) {
|
||||
return ExperienceState(
|
||||
status: status ?? this.status,
|
||||
selectedIndustries: selectedIndustries ?? this.selectedIndustries,
|
||||
selectedSkills: selectedSkills ?? this.selectedSkills,
|
||||
customSkills: customSkills ?? this.customSkills,
|
||||
errorMessage: errorMessage ?? this.errorMessage,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[
|
||||
status,
|
||||
selectedIndustries,
|
||||
selectedSkills,
|
||||
customSkills,
|
||||
errorMessage,
|
||||
];
|
||||
}
|
||||
@@ -4,87 +4,33 @@ 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 'package:krow_domain/krow_domain.dart' show StaffIndustry, StaffSkill;
|
||||
|
||||
import '../blocs/experience_bloc.dart';
|
||||
import '../widgets/experience_section_title.dart';
|
||||
|
||||
/// Page for selecting staff industries and skills.
|
||||
class ExperiencePage extends StatelessWidget {
|
||||
/// Creates an [ExperiencePage].
|
||||
const ExperiencePage({super.key});
|
||||
|
||||
String _getIndustryLabel(dynamic node, Industry industry) {
|
||||
switch (industry) {
|
||||
case Industry.hospitality:
|
||||
return node.hospitality;
|
||||
case Industry.foodService:
|
||||
return node.food_service;
|
||||
case Industry.warehouse:
|
||||
return node.warehouse;
|
||||
case Industry.events:
|
||||
return node.events;
|
||||
case Industry.retail:
|
||||
return node.retail;
|
||||
case Industry.healthcare:
|
||||
return node.healthcare;
|
||||
case Industry.other:
|
||||
return node.other;
|
||||
}
|
||||
}
|
||||
|
||||
String _getSkillLabel(dynamic node, ExperienceSkill skill) {
|
||||
switch (skill) {
|
||||
case ExperienceSkill.foodService:
|
||||
return node.food_service;
|
||||
case ExperienceSkill.bartending:
|
||||
return node.bartending;
|
||||
case ExperienceSkill.eventSetup:
|
||||
return node.event_setup;
|
||||
case ExperienceSkill.hospitality:
|
||||
return node.hospitality;
|
||||
case ExperienceSkill.warehouse:
|
||||
return node.warehouse;
|
||||
case ExperienceSkill.customerService:
|
||||
return node.customer_service;
|
||||
case ExperienceSkill.cleaning:
|
||||
return node.cleaning;
|
||||
case ExperienceSkill.security:
|
||||
return node.security;
|
||||
case ExperienceSkill.retail:
|
||||
return node.retail;
|
||||
case ExperienceSkill.driving:
|
||||
return node.driving;
|
||||
case ExperienceSkill.cooking:
|
||||
return node.cooking;
|
||||
case ExperienceSkill.cashier:
|
||||
return node.cashier;
|
||||
case ExperienceSkill.server:
|
||||
return node.server;
|
||||
case ExperienceSkill.barista:
|
||||
return node.barista;
|
||||
case ExperienceSkill.hostHostess:
|
||||
return node.host_hostess;
|
||||
case ExperienceSkill.busser:
|
||||
return node.busser;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final i18n = Translations.of(context).staff.onboarding.experience;
|
||||
final dynamic i18n = Translations.of(context).staff.onboarding.experience;
|
||||
|
||||
return Scaffold(
|
||||
appBar: UiAppBar(
|
||||
title: i18n.title,
|
||||
title: i18n.title as String,
|
||||
onLeadingPressed: () => Modular.to.toProfile(),
|
||||
),
|
||||
body: BlocProvider<ExperienceBloc>(
|
||||
create: (context) => Modular.get<ExperienceBloc>(),
|
||||
create: (BuildContext context) => Modular.get<ExperienceBloc>(),
|
||||
child: BlocConsumer<ExperienceBloc, ExperienceState>(
|
||||
listener: (context, state) {
|
||||
listener: (BuildContext context, ExperienceState state) {
|
||||
if (state.status == ExperienceStatus.success) {
|
||||
UiSnackbar.show(
|
||||
context,
|
||||
message: 'Experience saved successfully',
|
||||
message: i18n.save_success as String,
|
||||
type: UiSnackbarType.success,
|
||||
margin: const EdgeInsets.only(
|
||||
bottom: 120,
|
||||
@@ -97,7 +43,7 @@ class ExperiencePage extends StatelessWidget {
|
||||
context,
|
||||
message: state.errorMessage != null
|
||||
? translateErrorKey(state.errorMessage!)
|
||||
: 'An error occurred',
|
||||
: i18n.save_error as String,
|
||||
type: UiSnackbarType.error,
|
||||
margin: const EdgeInsets.only(
|
||||
bottom: 120,
|
||||
@@ -107,67 +53,28 @@ class ExperiencePage extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
},
|
||||
builder: (context, state) {
|
||||
builder: (BuildContext context, ExperienceState state) {
|
||||
return Column(
|
||||
children: [
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(UiConstants.space5),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
children: <Widget>[
|
||||
ExperienceSectionTitle(
|
||||
title: i18n.industries_title,
|
||||
subtitle: i18n.industries_subtitle,
|
||||
title: i18n.industries_title as String,
|
||||
subtitle: i18n.industries_subtitle as String,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space3),
|
||||
Wrap(
|
||||
spacing: UiConstants.space2,
|
||||
runSpacing: UiConstants.space2,
|
||||
children: state.availableIndustries
|
||||
.map(
|
||||
(i) => UiChip(
|
||||
label: _getIndustryLabel(i18n.industries, i),
|
||||
isSelected: state.selectedIndustries.contains(
|
||||
i,
|
||||
),
|
||||
onTap: () => BlocProvider.of<ExperienceBloc>(
|
||||
context,
|
||||
).add(ExperienceIndustryToggled(i)),
|
||||
variant: state.selectedIndustries.contains(i)
|
||||
? UiChipVariant.primary
|
||||
: UiChipVariant.secondary,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
_buildIndustryChips(context, state, i18n),
|
||||
const SizedBox(height: UiConstants.space10),
|
||||
ExperienceSectionTitle(
|
||||
title: i18n.skills_title,
|
||||
subtitle: i18n.skills_subtitle,
|
||||
title: i18n.skills_title as String,
|
||||
subtitle: i18n.skills_subtitle as String,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space3),
|
||||
Wrap(
|
||||
spacing: UiConstants.space2,
|
||||
runSpacing: UiConstants.space2,
|
||||
children: state.availableSkills
|
||||
.map(
|
||||
(s) => UiChip(
|
||||
label: _getSkillLabel(i18n.skills, s),
|
||||
isSelected: state.selectedSkills.contains(
|
||||
s.value,
|
||||
),
|
||||
onTap: () => BlocProvider.of<ExperienceBloc>(
|
||||
context,
|
||||
).add(ExperienceSkillToggled(s.value)),
|
||||
variant:
|
||||
state.selectedSkills.contains(s.value)
|
||||
? UiChipVariant.primary
|
||||
: UiChipVariant.secondary,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
_buildSkillChips(context, state, i18n),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -181,28 +88,45 @@ class ExperiencePage extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCustomSkillsList(ExperienceState state, dynamic i18n) {
|
||||
final customSkills = state.selectedSkills
|
||||
.where((s) => !state.availableSkills.any((e) => e.value == s))
|
||||
.toList();
|
||||
if (customSkills.isEmpty) return const SizedBox.shrink();
|
||||
Widget _buildIndustryChips(
|
||||
BuildContext context,
|
||||
ExperienceState state,
|
||||
dynamic i18n,
|
||||
) {
|
||||
return Wrap(
|
||||
spacing: UiConstants.space2,
|
||||
runSpacing: UiConstants.space2,
|
||||
children: StaffIndustry.values.map((StaffIndustry industry) {
|
||||
final bool isSelected = state.selectedIndustries.contains(industry);
|
||||
return UiChip(
|
||||
label: _getIndustryLabel(i18n.industries, industry),
|
||||
isSelected: isSelected,
|
||||
onTap: () => BlocProvider.of<ExperienceBloc>(context)
|
||||
.add(ExperienceIndustryToggled(industry)),
|
||||
variant: isSelected ? UiChipVariant.primary : UiChipVariant.secondary,
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
i18n.custom_skills_title,
|
||||
style: UiTypography.body2m.textSecondary,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
Wrap(
|
||||
spacing: UiConstants.space2,
|
||||
runSpacing: UiConstants.space2,
|
||||
children: customSkills.map((skill) {
|
||||
return UiChip(label: skill, variant: UiChipVariant.accent);
|
||||
}).toList(),
|
||||
),
|
||||
],
|
||||
Widget _buildSkillChips(
|
||||
BuildContext context,
|
||||
ExperienceState state,
|
||||
dynamic i18n,
|
||||
) {
|
||||
return Wrap(
|
||||
spacing: UiConstants.space2,
|
||||
runSpacing: UiConstants.space2,
|
||||
children: StaffSkill.values.map((StaffSkill skill) {
|
||||
final bool isSelected = state.selectedSkills.contains(skill);
|
||||
return UiChip(
|
||||
label: _getSkillLabel(i18n.skills, skill),
|
||||
isSelected: isSelected,
|
||||
onTap: () => BlocProvider.of<ExperienceBloc>(context)
|
||||
.add(ExperienceSkillToggled(skill)),
|
||||
variant: isSelected ? UiChipVariant.primary : UiChipVariant.secondary,
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -211,6 +135,7 @@ class ExperiencePage extends StatelessWidget {
|
||||
ExperienceState state,
|
||||
dynamic i18n,
|
||||
) {
|
||||
final bool isLoading = state.status == ExperienceStatus.loading;
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(UiConstants.space4),
|
||||
decoration: const BoxDecoration(
|
||||
@@ -219,24 +144,20 @@ class ExperiencePage extends StatelessWidget {
|
||||
),
|
||||
child: SafeArea(
|
||||
child: UiButton.primary(
|
||||
onPressed: state.status == ExperienceStatus.loading
|
||||
onPressed: isLoading
|
||||
? null
|
||||
: () => BlocProvider.of<ExperienceBloc>(
|
||||
context,
|
||||
).add(ExperienceSubmitted()),
|
||||
: () => BlocProvider.of<ExperienceBloc>(context)
|
||||
.add(ExperienceSubmitted()),
|
||||
fullWidth: true,
|
||||
text: state.status == ExperienceStatus.loading
|
||||
? null
|
||||
: i18n.save_button,
|
||||
child: state.status == ExperienceStatus.loading
|
||||
text: isLoading ? null : i18n.save_button as String,
|
||||
child: isLoading
|
||||
? const SizedBox(
|
||||
height: UiConstants.iconMd,
|
||||
width: UiConstants.iconMd,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
UiColors.white,
|
||||
), // UiColors.primaryForeground is white mostly
|
||||
valueColor:
|
||||
AlwaysStoppedAnimation<Color>(UiColors.white),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
@@ -244,4 +165,66 @@ class ExperiencePage extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Maps a [StaffIndustry] to its localized label.
|
||||
String _getIndustryLabel(dynamic node, StaffIndustry industry) {
|
||||
switch (industry) {
|
||||
case StaffIndustry.hospitality:
|
||||
return node.hospitality as String;
|
||||
case StaffIndustry.foodService:
|
||||
return node.food_service as String;
|
||||
case StaffIndustry.warehouse:
|
||||
return node.warehouse as String;
|
||||
case StaffIndustry.events:
|
||||
return node.events as String;
|
||||
case StaffIndustry.retail:
|
||||
return node.retail as String;
|
||||
case StaffIndustry.healthcare:
|
||||
return node.healthcare as String;
|
||||
case StaffIndustry.catering:
|
||||
return node.catering as String;
|
||||
case StaffIndustry.cafe:
|
||||
return node.cafe as String;
|
||||
case StaffIndustry.other:
|
||||
return node.other as String;
|
||||
}
|
||||
}
|
||||
|
||||
/// Maps a [StaffSkill] to its localized label.
|
||||
String _getSkillLabel(dynamic node, StaffSkill skill) {
|
||||
switch (skill) {
|
||||
case StaffSkill.foodService:
|
||||
return node.food_service as String;
|
||||
case StaffSkill.bartending:
|
||||
return node.bartending as String;
|
||||
case StaffSkill.eventSetup:
|
||||
return node.event_setup as String;
|
||||
case StaffSkill.hospitality:
|
||||
return node.hospitality as String;
|
||||
case StaffSkill.warehouse:
|
||||
return node.warehouse as String;
|
||||
case StaffSkill.customerService:
|
||||
return node.customer_service as String;
|
||||
case StaffSkill.cleaning:
|
||||
return node.cleaning as String;
|
||||
case StaffSkill.security:
|
||||
return node.security as String;
|
||||
case StaffSkill.retail:
|
||||
return node.retail as String;
|
||||
case StaffSkill.driving:
|
||||
return node.driving as String;
|
||||
case StaffSkill.cooking:
|
||||
return node.cooking as String;
|
||||
case StaffSkill.cashier:
|
||||
return node.cashier as String;
|
||||
case StaffSkill.server:
|
||||
return node.server as String;
|
||||
case StaffSkill.barista:
|
||||
return node.barista as String;
|
||||
case StaffSkill.hostHostess:
|
||||
return node.host_hostess as String;
|
||||
case StaffSkill.busser:
|
||||
return node.busser as String;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,65 @@
|
||||
import 'package:firebase_data_connect/firebase_data_connect.dart';
|
||||
import 'package:krow_data_connect/krow_data_connect.dart';
|
||||
import 'package:dio/dio.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.
|
||||
PersonalInfoRepositoryImpl({
|
||||
DataConnectService? service,
|
||||
}) : _service = service ?? DataConnectService.instance;
|
||||
required BaseApiService apiService,
|
||||
}) : _api = apiService;
|
||||
|
||||
final DataConnectService _service;
|
||||
final BaseApiService _api;
|
||||
|
||||
@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(StaffEndpoints.personalInfo);
|
||||
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 {
|
||||
// The PUT response returns { staffId, fullName, email, phone, metadata }
|
||||
// which does not match the StaffPersonalInfo shape. Perform the update
|
||||
// and then re-fetch the full profile to return the correct entity.
|
||||
await _api.put(
|
||||
StaffEndpoints.personalInfo,
|
||||
data: data,
|
||||
);
|
||||
return getStaffProfile();
|
||||
}
|
||||
|
||||
@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.',
|
||||
);
|
||||
}
|
||||
// The backend expects a multipart file upload at /staff/profile/photo.
|
||||
// It uploads to GCS, updates staff metadata, and returns a signed URL.
|
||||
final String fileName =
|
||||
'staff_profile_photo_${DateTime.now().millisecondsSinceEpoch}.jpg';
|
||||
final FormData formData = FormData.fromMap(<String, dynamic>{
|
||||
'file': await MultipartFile.fromFile(filePath, filename: fileName),
|
||||
});
|
||||
|
||||
/// 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,
|
||||
final ApiResponse response = await _api.post(
|
||||
StaffEndpoints.profilePhoto,
|
||||
data: formData,
|
||||
);
|
||||
final Map<String, dynamic> json =
|
||||
response.data as Map<String, dynamic>;
|
||||
|
||||
// Backend returns { staffId, fileUri, signedUrl, expiresAt }.
|
||||
return json['signedUrl'] as String? ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user