feat: Migrate staff profile features from Data Connect to V2 REST API

- Removed data_connect package from mobile pubspec.yaml.
- Added documentation for V2 profile migration status and QA findings.
- Implemented new session management with ClientSessionStore and StaffSessionStore.
- Created V2SessionService for handling user sessions via the V2 API.
- Developed use cases for cancelling late worker assignments and submitting worker reviews.
- Added arguments and use cases for payment chart retrieval and profile completion checks.
- Implemented repository interfaces and their implementations for staff main and profile features.
- Ensured proper error handling and validation in use cases.
This commit is contained in:
Achintha Isuru
2026-03-16 22:45:06 -04:00
parent 4834266986
commit b31a615092
478 changed files with 10512 additions and 19854 deletions

View File

@@ -1,137 +1,101 @@
import 'package:krow_core/core.dart';
import 'package:krow_data_connect/krow_data_connect.dart';
import 'package:krow_domain/krow_domain.dart' as domain;
import 'package:krow_domain/krow_domain.dart';
import '../../domain/repositories/certificates_repository.dart';
import 'package:staff_certificates/src/domain/repositories/certificates_repository.dart';
/// Implementation of [CertificatesRepository] using Data Connect.
/// Implementation of [CertificatesRepository] using the V2 API for reads
/// and core services for uploads/verification.
///
/// Replaces the previous Firebase Data Connect implementation.
class CertificatesRepositoryImpl implements CertificatesRepository {
/// Creates a [CertificatesRepositoryImpl].
CertificatesRepositoryImpl({
required BaseApiService apiService,
required FileUploadService uploadService,
required SignedUrlService signedUrlService,
required VerificationService verificationService,
}) : _service = DataConnectService.instance,
_uploadService = uploadService,
_signedUrlService = signedUrlService,
_verificationService = verificationService;
}) : _api = apiService,
_uploadService = uploadService,
_signedUrlService = signedUrlService,
_verificationService = verificationService;
final DataConnectService _service;
final BaseApiService _api;
final FileUploadService _uploadService;
final SignedUrlService _signedUrlService;
final VerificationService _verificationService;
@override
Future<List<domain.StaffCertificate>> getCertificates() async {
return _service.getStaffRepository().getStaffCertificates();
Future<List<StaffCertificate>> getCertificates() async {
final ApiResponse response =
await _api.get(V2ApiEndpoints.staffCertificates);
final List<dynamic> items =
response.data['certificates'] as List<dynamic>;
return items
.map((dynamic json) =>
StaffCertificate.fromJson(json as Map<String, dynamic>))
.toList();
}
@override
Future<domain.StaffCertificate> uploadCertificate({
required domain.ComplianceType certificationType,
Future<StaffCertificate> uploadCertificate({
required String certificateType,
required String name,
required String filePath,
DateTime? expiryDate,
String? issuer,
String? certificateNumber,
}) async {
return _service.run(() async {
// Get existing certificate to check if file has changed
final List<domain.StaffCertificate> existingCerts = await getCertificates();
domain.StaffCertificate? existingCert;
try {
existingCert = existingCerts.firstWhere(
(domain.StaffCertificate c) => c.certificationType == certificationType,
);
} catch (e) {
// Certificate doesn't exist yet
}
// 1. Upload the file to cloud storage
final FileUploadResponse uploadRes = await _uploadService.uploadFile(
filePath: filePath,
fileName:
'staff_cert_${certificateType}_${DateTime.now().millisecondsSinceEpoch}.pdf',
visibility: FileVisibility.private,
);
String? signedUrl = existingCert?.certificateUrl;
String? verificationId = existingCert?.verificationId;
final bool fileChanged = existingCert == null || existingCert.certificateUrl != filePath;
// 2. Generate a signed URL
final SignedUrlResponse signedUrlRes =
await _signedUrlService.createSignedUrl(fileUri: uploadRes.fileUri);
// Only upload and verify if file path has changed
if (fileChanged) {
// 1. Upload the file to cloud storage
final FileUploadResponse uploadRes = await _uploadService.uploadFile(
filePath: filePath,
fileName:
'staff_cert_${certificationType.name}_${DateTime.now().millisecondsSinceEpoch}.pdf',
visibility: domain.FileVisibility.private,
);
// 3. Initiate verification
final VerificationResponse verificationRes =
await _verificationService.createVerification(
fileUri: uploadRes.fileUri,
type: 'certification',
subjectType: 'worker',
subjectId: certificateType,
rules: <String, dynamic>{
'certificateName': name,
'certificateIssuer': issuer,
'certificateNumber': certificateNumber,
},
);
// 2. Generate a signed URL for verification service to access the file
final SignedUrlResponse signedUrlRes = await _signedUrlService.createSignedUrl(fileUri: uploadRes.fileUri);
signedUrl = signedUrlRes.signedUrl;
// 4. Save certificate via V2 API
await _api.post(
V2ApiEndpoints.staffCertificates,
data: <String, dynamic>{
'certificateType': certificateType,
'name': name,
'fileUri': signedUrlRes.signedUrl,
'expiresAt': expiryDate?.toIso8601String(),
'issuer': issuer,
'certificateNumber': certificateNumber,
'verificationId': verificationRes.verificationId,
},
);
// 3. Initiate verification
final String staffId = await _service.getStaffId();
final VerificationResponse verificationRes = await _verificationService
.createVerification(
fileUri: uploadRes.fileUri,
type: 'certification',
subjectType: 'worker',
subjectId: staffId,
rules: <String, dynamic>{
'certificateName': name,
'certificateIssuer': issuer,
'certificateNumber': certificateNumber,
},
);
verificationId = verificationRes.verificationId;
}
// 4. Update/Create Certificate in Data Connect
await _service.getStaffRepository().upsertStaffCertificate(
certificationType: certificationType,
name: name,
status: existingCert?.status ?? domain.StaffCertificateStatus.pending,
fileUrl: signedUrl,
expiry: expiryDate,
issuer: issuer,
certificateNumber: certificateNumber,
validationStatus: existingCert?.validationStatus ?? domain.StaffCertificateValidationStatus.pendingExpertReview,
verificationId: verificationId,
);
// 5. Return updated list or the specific certificate
final List<domain.StaffCertificate> certificates =
await getCertificates();
return certificates.firstWhere(
(domain.StaffCertificate c) => c.certificationType == certificationType,
);
});
}
@override
Future<void> upsertCertificate({
required domain.ComplianceType certificationType,
required String name,
required domain.StaffCertificateStatus status,
String? fileUrl,
DateTime? expiry,
String? issuer,
String? certificateNumber,
domain.StaffCertificateValidationStatus? validationStatus,
}) async {
await _service.getStaffRepository().upsertStaffCertificate(
certificationType: certificationType,
name: name,
status: status,
fileUrl: fileUrl,
expiry: expiry,
issuer: issuer,
certificateNumber: certificateNumber,
validationStatus: validationStatus,
// 5. Return updated list
final List<StaffCertificate> certificates = await getCertificates();
return certificates.firstWhere(
(StaffCertificate c) => c.certificateType == certificateType,
);
}
@override
Future<void> deleteCertificate({
required domain.ComplianceType certificationType,
}) async {
return _service.getStaffRepository().deleteStaffCertificate(
certificationType: certificationType,
Future<void> deleteCertificate({required String certificateId}) async {
await _api.delete(
V2ApiEndpoints.staffCertificateDelete(certificateId),
);
}
}

View File

@@ -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});
}

View File

@@ -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);
}
}

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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(

View File

@@ -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(

View File

@@ -33,7 +33,7 @@ class CertificatesState extends Equatable {
int get completedCount => certificates
.where(
(StaffCertificate cert) =>
cert.validationStatus == StaffCertificateValidationStatus.approved,
cert.status == CertificateStatus.verified,
)
.length;

View File

@@ -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,

View File

@@ -19,28 +19,19 @@ class CertificateCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
// Determine UI state from certificate
final bool isComplete =
certificate.validationStatus ==
StaffCertificateValidationStatus.approved;
final bool isExpiring =
certificate.status == StaffCertificateStatus.expiring ||
certificate.status == StaffCertificateStatus.expiringSoon;
final bool isExpired = certificate.status == StaffCertificateStatus.expired;
final bool isVerified = certificate.status == CertificateStatus.verified;
final bool isExpired = certificate.status == CertificateStatus.expired ||
certificate.isExpired;
final bool isPending = certificate.status == CertificateStatus.pending;
final bool isNotStarted = certificate.fileUri == null ||
certificate.status == CertificateStatus.rejected;
// Override isComplete if expiring or expired
final bool showComplete = isComplete && !isExpired && !isExpiring;
final bool isPending =
certificate.validationStatus ==
StaffCertificateValidationStatus.pendingExpertReview;
final bool isNotStarted =
certificate.status == StaffCertificateStatus.notStarted ||
certificate.validationStatus ==
StaffCertificateValidationStatus.rejected;
// Show verified badge only if not expired
final bool showComplete = isVerified && !isExpired;
// UI Properties helper
final _CertificateUiProps uiProps = _getUiProps(
certificate.certificationType,
certificate.certificateType,
);
return GestureDetector(
@@ -55,7 +46,7 @@ class CertificateCard extends StatelessWidget {
clipBehavior: Clip.hardEdge,
child: Column(
children: <Widget>[
if (isExpiring || isExpired)
if (isExpired)
Container(
padding: const EdgeInsets.symmetric(
horizontal: UiConstants.space4,
@@ -78,11 +69,7 @@ class CertificateCard extends StatelessWidget {
),
const SizedBox(width: UiConstants.space2),
Text(
isExpired
? t.staff_certificates.card.expired
: t.staff_certificates.card.expires_in_days(
days: _daysUntilExpiry(certificate.expiryDate),
),
t.staff_certificates.card.expired,
style: UiTypography.body3m.textPrimary,
),
],
@@ -151,7 +138,7 @@ class CertificateCard extends StatelessWidget {
),
const SizedBox(height: 2),
Text(
certificate.description ?? '',
certificate.certificateType,
style: UiTypography.body3r.textSecondary,
),
if (showComplete) ...<Widget>[
@@ -159,17 +146,15 @@ class CertificateCard extends StatelessWidget {
_buildMiniStatus(
t.staff_certificates.card.verified,
UiColors.primary,
certificate.expiryDate,
certificate.expiresAt,
),
],
if (isExpiring || isExpired) ...<Widget>[
if (isExpired) ...<Widget>[
const SizedBox(height: UiConstants.space2),
_buildMiniStatus(
isExpired
? t.staff_certificates.card.expired
: t.staff_certificates.card.expiring_soon,
isExpired ? UiColors.destructive : UiColors.primary,
certificate.expiryDate,
t.staff_certificates.card.expired,
UiColors.destructive,
certificate.expiresAt,
),
],
if (isNotStarted) ...<Widget>[
@@ -220,18 +205,14 @@ class CertificateCard extends StatelessWidget {
);
}
int _daysUntilExpiry(DateTime? expiry) {
if (expiry == null) return 0;
return expiry.difference(DateTime.now()).inDays;
}
_CertificateUiProps _getUiProps(ComplianceType type) {
switch (type) {
case ComplianceType.backgroundCheck:
_CertificateUiProps _getUiProps(String type) {
switch (type.toUpperCase()) {
case 'BACKGROUND_CHECK':
return _CertificateUiProps(UiIcons.fileCheck, UiColors.primary);
case ComplianceType.foodHandler:
case 'FOOD_HYGIENE':
case 'FOOD_HANDLER':
return _CertificateUiProps(UiIcons.utensils, UiColors.primary);
case ComplianceType.rbs:
case 'RBS':
return _CertificateUiProps(UiIcons.wine, UiColors.foreground);
default:
return _CertificateUiProps(UiIcons.award, UiColors.primary);

View File

@@ -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);
}

View File

@@ -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:

View File

@@ -1,105 +1,80 @@
// ignore_for_file: always_specify_types, depend_on_referenced_packages, dead_code, dead_null_aware_expression, unused_local_variable, unused_import, sort_constructors_first, prefer_final_fields, prefer_const_constructors, deprecated_member_use, implicit_call_tearoffs, implementation_imports, unused_element, unused_field, duplicate_ignore
import 'package:krow_core/core.dart';
import 'package:krow_data_connect/krow_data_connect.dart';
import 'package:krow_domain/krow_domain.dart' as domain;
import 'package:krow_domain/krow_domain.dart';
import '../../domain/repositories/documents_repository.dart';
import 'package:staff_documents/src/domain/repositories/documents_repository.dart';
/// Implementation of [DocumentsRepository] using Data Connect.
/// Implementation of [DocumentsRepository] using the V2 API for reads
/// and core services for uploads/verification.
///
/// Replaces the previous Firebase Data Connect implementation.
class DocumentsRepositoryImpl implements DocumentsRepository {
/// Creates a [DocumentsRepositoryImpl].
DocumentsRepositoryImpl({
required BaseApiService apiService,
required FileUploadService uploadService,
required SignedUrlService signedUrlService,
required VerificationService verificationService,
}) : _service = DataConnectService.instance,
_uploadService = uploadService,
_signedUrlService = signedUrlService,
_verificationService = verificationService;
}) : _api = apiService,
_uploadService = uploadService,
_signedUrlService = signedUrlService,
_verificationService = verificationService;
final DataConnectService _service;
final BaseApiService _api;
final FileUploadService _uploadService;
final SignedUrlService _signedUrlService;
final VerificationService _verificationService;
@override
Future<List<domain.StaffDocument>> getDocuments() async {
return _service.getStaffRepository().getStaffDocuments();
Future<List<ProfileDocument>> getDocuments() async {
final ApiResponse response =
await _api.get(V2ApiEndpoints.staffDocuments);
final List<dynamic> items = response.data['documents'] as List<dynamic>;
return items
.map((dynamic json) =>
ProfileDocument.fromJson(json as Map<String, dynamic>))
.toList();
}
@override
Future<domain.StaffDocument> uploadDocument(
Future<ProfileDocument> uploadDocument(
String documentId,
String filePath,
) async {
return _service.run(() async {
// 1. Upload the file to cloud storage
final FileUploadResponse uploadRes = await _uploadService.uploadFile(
filePath: filePath,
fileName: 'staff_document_${DateTime.now().millisecondsSinceEpoch}.pdf',
visibility: domain.FileVisibility.private,
);
// 1. Upload the file to cloud storage
final FileUploadResponse uploadRes = await _uploadService.uploadFile(
filePath: filePath,
fileName:
'staff_document_${DateTime.now().millisecondsSinceEpoch}.pdf',
visibility: FileVisibility.private,
);
// 2. Generate a signed URL for verification service to access the file
final SignedUrlResponse signedUrlRes = await _signedUrlService
.createSignedUrl(fileUri: uploadRes.fileUri);
// 2. Generate a signed URL
final SignedUrlResponse signedUrlRes =
await _signedUrlService.createSignedUrl(fileUri: uploadRes.fileUri);
// 3. Initiate verification
final List<domain.StaffDocument> allDocs = await getDocuments();
final domain.StaffDocument currentDoc = allDocs.firstWhere(
(domain.StaffDocument d) => d.documentId == documentId,
);
final String description = (currentDoc.description ?? '').toLowerCase();
// 3. Initiate verification
final VerificationResponse verificationRes =
await _verificationService.createVerification(
fileUri: uploadRes.fileUri,
type: 'government_id',
subjectType: 'worker',
subjectId: documentId,
rules: <String, dynamic>{'documentId': documentId},
);
final String staffId = await _service.getStaffId();
final VerificationResponse verificationRes = await _verificationService
.createVerification(
fileUri: uploadRes.fileUri,
type: 'government_id',
subjectType: 'worker',
subjectId: staffId,
rules: <String, dynamic>{
'documentDescription': currentDoc.description,
},
);
// 4. Submit upload result to V2 API
await _api.put(
V2ApiEndpoints.staffDocumentUpload(documentId),
data: <String, dynamic>{
'fileUri': signedUrlRes.signedUrl,
'verificationId': verificationRes.verificationId,
},
);
// 4. Update/Create StaffDocument in Data Connect
await _service.getStaffRepository().upsertStaffDocument(
documentId: documentId,
documentUrl: signedUrlRes.signedUrl,
status: domain.DocumentStatus.pending,
verificationId: verificationRes.verificationId,
);
// 5. Return the updated document state
final List<domain.StaffDocument> documents = await getDocuments();
return documents.firstWhere(
(domain.StaffDocument d) => d.documentId == documentId,
);
});
}
domain.DocumentStatus _mapStatus(EnumValue<DocumentStatus> status) {
if (status is Known<DocumentStatus>) {
switch (status.value) {
case DocumentStatus.VERIFIED:
case DocumentStatus.AUTO_PASS:
case DocumentStatus.APPROVED:
return domain.DocumentStatus.verified;
case DocumentStatus.PENDING:
case DocumentStatus.UPLOADED:
case DocumentStatus.PROCESSING:
case DocumentStatus.NEEDS_REVIEW:
case DocumentStatus.EXPIRING:
return domain.DocumentStatus.pending;
case DocumentStatus.MISSING:
return domain.DocumentStatus.missing;
case DocumentStatus.AUTO_FAIL:
case DocumentStatus.REJECTED:
case DocumentStatus.ERROR:
return domain.DocumentStatus.rejected;
}
}
// Default to pending for Unknown or unhandled cases
return domain.DocumentStatus.pending;
// 5. Return the updated document
final List<ProfileDocument> documents = await getDocuments();
return documents.firstWhere(
(ProfileDocument d) => d.documentId == documentId,
);
}
}

View File

@@ -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);
}

View File

@@ -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();
}
}

View File

@@ -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);
}
}

View File

@@ -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),
);

View File

@@ -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(

View File

@@ -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,

View File

@@ -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;

View File

@@ -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(

View File

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

View File

@@ -11,7 +11,7 @@ class DocumentCard extends StatelessWidget {
required this.document,
this.onTap,
});
final StaffDocument document;
final ProfileDocument document;
final VoidCallback? onTap;
@override
@@ -57,12 +57,6 @@ class DocumentCard extends StatelessWidget {
_getStatusIcon(document.status),
],
),
const SizedBox(height: UiConstants.space1 / 2),
if (document.description != null)
Text(
document.description!,
style: UiTypography.body2r.textSecondary,
),
const SizedBox(height: UiConstants.space3),
Row(
children: <Widget>[
@@ -79,15 +73,15 @@ class DocumentCard extends StatelessWidget {
);
}
Widget _getStatusIcon(DocumentStatus status) {
Widget _getStatusIcon(ProfileDocumentStatus status) {
switch (status) {
case DocumentStatus.verified:
case ProfileDocumentStatus.verified:
return const Icon(
UiIcons.check,
color: UiColors.iconSuccess,
size: 20,
);
case DocumentStatus.pending:
case ProfileDocumentStatus.pending:
return const Icon(
UiIcons.clock,
color: UiColors.textWarning,
@@ -102,37 +96,32 @@ class DocumentCard extends StatelessWidget {
}
}
Widget _buildStatusBadge(DocumentStatus status) {
Widget _buildStatusBadge(ProfileDocumentStatus status) {
Color bg;
Color text;
String label;
switch (status) {
case DocumentStatus.verified:
case ProfileDocumentStatus.verified:
bg = UiColors.tagSuccess;
text = UiColors.textSuccess;
label = t.staff_documents.card.verified;
break;
case DocumentStatus.pending:
case ProfileDocumentStatus.pending:
bg = UiColors.tagPending;
text = UiColors.textWarning;
label = t.staff_documents.card.pending;
break;
case DocumentStatus.missing:
case ProfileDocumentStatus.notUploaded:
bg = UiColors.textError.withValues(alpha: 0.1);
text = UiColors.textError;
label = t.staff_documents.card.missing;
break;
case DocumentStatus.rejected:
case ProfileDocumentStatus.rejected:
bg = UiColors.textError.withValues(alpha: 0.1);
text = UiColors.textError;
label = t.staff_documents.card.rejected;
break;
case DocumentStatus.expired:
case ProfileDocumentStatus.expired:
bg = UiColors.textError.withValues(alpha: 0.1);
text = UiColors.textError;
label = t.staff_documents.card.rejected; // Or define "Expired" string
break;
label = t.staff_documents.card.rejected;
}
return Container(
@@ -150,8 +139,8 @@ class DocumentCard extends StatelessWidget {
);
}
Widget _buildActionButton(DocumentStatus status) {
final bool isVerified = status == DocumentStatus.verified;
Widget _buildActionButton(ProfileDocumentStatus status) {
final bool isVerified = status == ProfileDocumentStatus.verified;
return InkWell(
onTap: onTap,
borderRadius: UiConstants.radiusSm,

View File

@@ -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?,
),
);

View File

@@ -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

View File

@@ -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}';
}
}

View File

@@ -1,274 +1,44 @@
import 'dart:async';
import 'package:firebase_data_connect/firebase_data_connect.dart';
import 'package:krow_data_connect/krow_data_connect.dart' as dc;
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import '../../domain/repositories/tax_forms_repository.dart';
import '../mappers/tax_form_mapper.dart';
import 'package:staff_tax_forms/src/domain/repositories/tax_forms_repository.dart';
/// Implementation of [TaxFormsRepository] using the V2 API.
///
/// Replaces the previous Firebase Data Connect implementation.
class TaxFormsRepositoryImpl implements TaxFormsRepository {
TaxFormsRepositoryImpl() : _service = dc.DataConnectService.instance;
/// Creates a [TaxFormsRepositoryImpl].
TaxFormsRepositoryImpl({required BaseApiService apiService})
: _api = apiService;
final dc.DataConnectService _service;
final BaseApiService _api;
@override
Future<List<TaxForm>> getTaxForms() async {
return _service.run(() async {
final String staffId = await _service.getStaffId();
final QueryResult<
dc.GetTaxFormsByStaffIdData,
dc.GetTaxFormsByStaffIdVariables
>
response = await _service.connector
.getTaxFormsByStaffId(staffId: staffId)
.execute();
final List<TaxForm> forms = response.data.taxForms
.map(TaxFormMapper.fromDataConnect)
.toList();
// Check if required forms exist, create if not.
final Set<TaxFormType> typesPresent = forms
.map((TaxForm f) => f.type)
.toSet();
bool createdNew = false;
if (!typesPresent.contains(TaxFormType.i9)) {
await _createInitialForm(staffId, TaxFormType.i9);
createdNew = true;
}
if (!typesPresent.contains(TaxFormType.w4)) {
await _createInitialForm(staffId, TaxFormType.w4);
createdNew = true;
}
if (createdNew) {
final QueryResult<
dc.GetTaxFormsByStaffIdData,
dc.GetTaxFormsByStaffIdVariables
>
response2 = await _service.connector
.getTaxFormsByStaffId(staffId: staffId)
.execute();
return response2.data.taxForms
.map(TaxFormMapper.fromDataConnect)
.toList();
}
return forms;
});
}
Future<void> _createInitialForm(String staffId, TaxFormType type) async {
await _service.connector
.createTaxForm(
staffId: staffId,
formType: dc.TaxFormType.values.byName(
TaxFormAdapter.typeToString(type),
),
firstName: '',
lastName: '',
socialSN: 0,
address: '',
status: dc.TaxFormStatus.NOT_STARTED,
)
.execute();
final ApiResponse response =
await _api.get(V2ApiEndpoints.staffTaxForms);
final List<dynamic> items = response.data['taxForms'] as List<dynamic>;
return items
.map((dynamic json) =>
TaxForm.fromJson(json as Map<String, dynamic>))
.toList();
}
@override
Future<void> updateI9Form(I9TaxForm form) async {
return _service.run(() async {
final Map<String, dynamic> data = form.formData;
final dc.UpdateTaxFormVariablesBuilder builder = _service.connector
.updateTaxForm(id: form.id);
_mapCommonFields(builder, data);
_mapI9Fields(builder, data);
await builder.execute();
});
Future<void> updateTaxForm(TaxForm form) async {
await _api.put(
V2ApiEndpoints.staffTaxFormUpdate(form.formType),
data: form.toJson(),
);
}
@override
Future<void> submitI9Form(I9TaxForm form) async {
return _service.run(() async {
final Map<String, dynamic> data = form.formData;
final dc.UpdateTaxFormVariablesBuilder builder = _service.connector
.updateTaxForm(id: form.id);
_mapCommonFields(builder, data);
_mapI9Fields(builder, data);
await builder.status(dc.TaxFormStatus.SUBMITTED).execute();
});
}
@override
Future<void> updateW4Form(W4TaxForm form) async {
return _service.run(() async {
final Map<String, dynamic> data = form.formData;
final dc.UpdateTaxFormVariablesBuilder builder = _service.connector
.updateTaxForm(id: form.id);
_mapCommonFields(builder, data);
_mapW4Fields(builder, data);
await builder.execute();
});
}
@override
Future<void> submitW4Form(W4TaxForm form) async {
return _service.run(() async {
final Map<String, dynamic> data = form.formData;
final dc.UpdateTaxFormVariablesBuilder builder = _service.connector
.updateTaxForm(id: form.id);
_mapCommonFields(builder, data);
_mapW4Fields(builder, data);
await builder.status(dc.TaxFormStatus.SUBMITTED).execute();
});
}
void _mapCommonFields(
dc.UpdateTaxFormVariablesBuilder builder,
Map<String, dynamic> data,
) {
if (data.containsKey('firstName')) {
builder.firstName(data['firstName'] as String?);
}
if (data.containsKey('lastName')) {
builder.lastName(data['lastName'] as String?);
}
if (data.containsKey('middleInitial')) {
builder.mInitial(data['middleInitial'] as String?);
}
if (data.containsKey('otherLastNames')) {
builder.oLastName(data['otherLastNames'] as String?);
}
if (data.containsKey('dob')) {
final String dob = data['dob'] as String;
// Handle both ISO string and MM/dd/yyyy manual entry
DateTime? date;
try {
date = DateTime.parse(dob);
} catch (_) {
try {
// Fallback minimal parse for mm/dd/yyyy
final List<String> parts = dob.split('/');
if (parts.length == 3) {
date = DateTime(
int.parse(parts[2]),
int.parse(parts[0]),
int.parse(parts[1]),
);
}
} catch (_) {}
}
if (date != null) {
final int ms = date.millisecondsSinceEpoch;
final int seconds = (ms / 1000).floor();
builder.dob(Timestamp(0, seconds));
}
}
if (data.containsKey('ssn') && data['ssn']?.toString().isNotEmpty == true) {
builder.socialSN(
int.tryParse(data['ssn'].toString().replaceAll(RegExp(r'\D'), '')) ?? 0,
);
}
if (data.containsKey('email')) builder.email(data['email'] as String?);
if (data.containsKey('phone')) builder.phone(data['phone'] as String?);
if (data.containsKey('address')) {
builder.address(data['address'] as String?);
}
if (data.containsKey('aptNumber')) {
builder.apt(data['aptNumber'] as String?);
}
if (data.containsKey('city')) builder.city(data['city'] as String?);
if (data.containsKey('state')) builder.state(data['state'] as String?);
if (data.containsKey('zipCode')) {
builder.zipCode(data['zipCode'] as String?);
}
}
void _mapI9Fields(
dc.UpdateTaxFormVariablesBuilder builder,
Map<String, dynamic> data,
) {
if (data.containsKey('citizenshipStatus')) {
final String status = data['citizenshipStatus'] as String;
// Map string to enum if possible, or handle otherwise.
// Generated enum: CITIZEN, NONCITIZEN_NATIONAL, PERMANENT_RESIDENT, ALIEN_AUTHORIZED
try {
builder.citizen(
dc.CitizenshipStatus.values.byName(status.toUpperCase()),
);
} catch (_) {}
}
if (data.containsKey('uscisNumber')) {
builder.uscis(data['uscisNumber'] as String?);
}
if (data.containsKey('passportNumber')) {
builder.passportNumber(data['passportNumber'] as String?);
}
if (data.containsKey('countryIssuance')) {
builder.countryIssue(data['countryIssuance'] as String?);
}
if (data.containsKey('preparerUsed')) {
builder.prepartorOrTranslator(data['preparerUsed'] as bool?);
}
if (data.containsKey('signature')) {
builder.signature(data['signature'] as String?);
}
// Note: admissionNumber not in builder based on file read
}
void _mapW4Fields(
dc.UpdateTaxFormVariablesBuilder builder,
Map<String, dynamic> data,
) {
if (data.containsKey('cityStateZip')) {
final String csz = data['cityStateZip'] as String;
// Extremely basic split: City, State Zip
final List<String> parts = csz.split(',');
if (parts.length >= 2) {
builder.city(parts[0].trim());
final String stateZip = parts[1].trim();
final List<String> szParts = stateZip.split(' ');
if (szParts.isNotEmpty) builder.state(szParts[0]);
if (szParts.length > 1) builder.zipCode(szParts.last);
}
}
if (data.containsKey('filingStatus')) {
// MARITIAL_STATUS_SINGLE, MARITIAL_STATUS_MARRIED, MARITIAL_STATUS_HEAD
try {
final String status = data['filingStatus'] as String;
// Simple mapping assumptions:
if (status.contains('single')) {
builder.marital(dc.MaritalStatus.SINGLE);
} else if (status.contains('married')) {
builder.marital(dc.MaritalStatus.MARRIED);
} else if (status.contains('head')) {
builder.marital(dc.MaritalStatus.HEAD);
}
} catch (_) {}
}
if (data.containsKey('multipleJobs')) {
builder.multipleJob(data['multipleJobs'] as bool?);
}
if (data.containsKey('qualifyingChildren')) {
builder.childrens(data['qualifyingChildren'] as int?);
}
if (data.containsKey('otherDependents')) {
builder.otherDeps(data['otherDependents'] as int?);
}
if (data.containsKey('otherIncome')) {
builder.otherInconme(double.tryParse(data['otherIncome'].toString()));
}
if (data.containsKey('deductions')) {
builder.deductions(double.tryParse(data['deductions'].toString()));
}
if (data.containsKey('extraWithholding')) {
builder.extraWithholding(
double.tryParse(data['extraWithholding'].toString()),
);
}
if (data.containsKey('signature')) {
builder.signature(data['signature'] as String?);
}
Future<void> submitTaxForm(TaxForm form) async {
await _api.post(
V2ApiEndpoints.staffTaxFormSubmit(form.formType),
data: form.toJson(),
);
}
}

View File

@@ -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);
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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>
);
}
}

View File

@@ -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>
);
}
}

View File

@@ -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();
}
},
);

View File

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

View File

@@ -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);

View File

@@ -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

View File

@@ -1,83 +1,34 @@
import 'package:firebase_data_connect/firebase_data_connect.dart';
import 'package:krow_data_connect/krow_data_connect.dart';
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import '../../domain/repositories/bank_account_repository.dart';
/// Implementation of [BankAccountRepository] that integrates with Data Connect.
import 'package:staff_bank_account/src/domain/repositories/bank_account_repository.dart';
/// Implementation of [BankAccountRepository] using the V2 API.
///
/// Replaces the previous Firebase Data Connect implementation.
class BankAccountRepositoryImpl implements BankAccountRepository {
/// Creates a [BankAccountRepositoryImpl].
BankAccountRepositoryImpl({
DataConnectService? service,
}) : _service = service ?? DataConnectService.instance;
BankAccountRepositoryImpl({required BaseApiService apiService})
: _api = apiService;
/// The Data Connect service.
final DataConnectService _service;
final BaseApiService _api;
@override
Future<List<StaffBankAccount>> getAccounts() async {
return _service.run(() async {
final String staffId = await _service.getStaffId();
final QueryResult<GetAccountsByOwnerIdData, GetAccountsByOwnerIdVariables>
result = await _service.connector
.getAccountsByOwnerId(ownerId: staffId)
.execute();
return result.data.accounts.map((GetAccountsByOwnerIdAccounts account) {
return BankAccountAdapter.fromPrimitives(
id: account.id,
userId: account.ownerId,
bankName: account.bank,
accountNumber: account.accountNumber,
last4: account.last4,
sortCode: account.routeNumber,
type: account.type is Known<AccountType>
? (account.type as Known<AccountType>).value.name
: null,
isPrimary: account.isPrimary,
);
}).toList();
});
Future<List<BankAccount>> getAccounts() async {
final ApiResponse response =
await _api.get(V2ApiEndpoints.staffBankAccounts);
final List<dynamic> items = response.data['accounts'] as List<dynamic>;
return items
.map((dynamic json) =>
BankAccount.fromJson(json as Map<String, dynamic>))
.toList();
}
@override
Future<void> addAccount(StaffBankAccount account) async {
return _service.run(() async {
final String staffId = await _service.getStaffId();
final QueryResult<GetAccountsByOwnerIdData, GetAccountsByOwnerIdVariables>
existingAccounts = await _service.connector
.getAccountsByOwnerId(ownerId: staffId)
.execute();
final bool hasAccounts = existingAccounts.data.accounts.isNotEmpty;
final bool isPrimary = !hasAccounts;
await _service.connector
.createAccount(
bank: account.bankName,
type: AccountType.values
.byName(BankAccountAdapter.typeToString(account.type)),
last4: _safeLast4(account.last4, account.accountNumber),
ownerId: staffId,
)
.isPrimary(isPrimary)
.accountNumber(account.accountNumber)
.routeNumber(account.sortCode)
.execute();
});
}
/// Ensures we have a last4 value, either from input or derived from account number.
String _safeLast4(String? last4, String accountNumber) {
if (last4 != null && last4.isNotEmpty) {
return last4;
}
if (accountNumber.isEmpty) {
return '0000';
}
if (accountNumber.length < 4) {
return accountNumber.padLeft(4, '0');
}
return accountNumber.substring(accountNumber.length - 4);
Future<void> addAccount(BankAccount account) async {
await _api.post(
V2ApiEndpoints.staffBankAccounts,
data: account.toJson(),
);
}
}

View File

@@ -6,11 +6,11 @@ import 'package:krow_domain/krow_domain.dart';
class AddBankAccountParams extends UseCaseArgument with EquatableMixin {
const AddBankAccountParams({required this.account});
final StaffBankAccount account;
final BankAccount account;
@override
List<Object?> get props => <Object?>[account];
@override
bool? get stringify => true;
}

View File

@@ -1,10 +1,12 @@
import 'package:krow_domain/krow_domain.dart';
/// Repository interface for managing bank accounts.
///
/// Uses [BankAccount] from the V2 domain layer.
abstract class BankAccountRepository {
/// Fetches the list of bank accounts for the current user.
Future<List<StaffBankAccount>> getAccounts();
/// Fetches the list of bank accounts for the current staff member.
Future<List<BankAccount>> getAccounts();
/// adds a new bank account.
Future<void> addAccount(StaffBankAccount account);
/// Adds a new bank account.
Future<void> addAccount(BankAccount account);
}

View File

@@ -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();
}
}

View File

@@ -23,7 +23,7 @@ class BankAccountCubit extends Cubit<BankAccountState>
await handleError(
emit: emit,
action: () async {
final List<StaffBankAccount> accounts = await _getBankAccountsUseCase();
final List<BankAccount> accounts = await _getBankAccountsUseCase();
emit(
state.copyWith(status: BankAccountStatus.loaded, accounts: accounts),
);
@@ -48,19 +48,17 @@ class BankAccountCubit extends Cubit<BankAccountState>
emit(state.copyWith(status: BankAccountStatus.loading));
// Create domain entity
final StaffBankAccount newAccount = StaffBankAccount(
id: '', // Generated by server usually
userId: '', // Handled by Repo/Auth
final BankAccount newAccount = BankAccount(
accountId: '', // Generated by server
bankName: bankName,
accountNumber: accountNumber.length > 4
providerReference: routingNumber,
last4: accountNumber.length > 4
? accountNumber.substring(accountNumber.length - 4)
: accountNumber,
accountName: '',
sortCode: routingNumber,
type: type == 'CHECKING'
? StaffBankAccountType.checking
: StaffBankAccountType.savings,
isPrimary: false,
accountType: type == 'CHECKING'
? AccountType.checking
: AccountType.savings,
);
await handleError(

View File

@@ -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,
}) {

View File

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

View File

@@ -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({

View File

@@ -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(

View File

@@ -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:

View File

@@ -1,74 +1,31 @@
import 'package:firebase_data_connect/firebase_data_connect.dart' as fdc;
import 'package:intl/intl.dart';
import 'package:krow_data_connect/krow_data_connect.dart' as dc;
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
// ignore: implementation_imports
import 'package:krow_domain/src/adapters/financial/time_card_adapter.dart';
import '../../domain/repositories/time_card_repository.dart';
/// Implementation of [TimeCardRepository] using Firebase Data Connect.
import 'package:staff_time_card/src/domain/repositories/time_card_repository.dart';
/// Implementation of [TimeCardRepository] using the V2 API.
///
/// Replaces the previous Firebase Data Connect implementation.
class TimeCardRepositoryImpl implements TimeCardRepository {
/// Creates a [TimeCardRepositoryImpl].
TimeCardRepositoryImpl({dc.DataConnectService? service})
: _service = service ?? dc.DataConnectService.instance;
final dc.DataConnectService _service;
TimeCardRepositoryImpl({required BaseApiService apiService})
: _api = apiService;
final BaseApiService _api;
@override
Future<List<TimeCard>> getTimeCards(DateTime month) async {
return _service.run(() async {
final String staffId = await _service.getStaffId();
// Fetch applications. Limit can be adjusted, assuming 100 is safe for now.
final fdc.QueryResult<dc.GetApplicationsByStaffIdData,
dc.GetApplicationsByStaffIdVariables> result =
await _service.connector
.getApplicationsByStaffId(staffId: staffId)
.limit(100)
.execute();
return result.data.applications
.where((dc.GetApplicationsByStaffIdApplications app) {
final DateTime? shiftDate = _service.toDateTime(app.shift.date);
if (shiftDate == null) return false;
return shiftDate.year == month.year &&
shiftDate.month == month.month;
})
.map((dc.GetApplicationsByStaffIdApplications app) {
final DateTime shiftDate = _service.toDateTime(app.shift.date)!;
final String startTime = _formatTime(app.checkInTime) ??
_formatTime(app.shift.startTime) ??
'';
final String endTime = _formatTime(app.checkOutTime) ??
_formatTime(app.shift.endTime) ??
'';
// Prefer shiftRole values for pay/hours
final double hours = app.shiftRole.hours ?? 0.0;
final double rate = app.shiftRole.role.costPerHour;
final double pay = app.shiftRole.totalValue ?? 0.0;
return TimeCardAdapter.fromPrimitives(
id: app.id,
shiftTitle: app.shift.title,
clientName: app.shift.order.business.businessName,
date: shiftDate,
startTime: startTime,
endTime: endTime,
totalHours: hours,
hourlyRate: rate,
totalPay: pay,
status: app.status.stringValue,
location: app.shift.location,
);
})
.toList();
});
}
String? _formatTime(fdc.Timestamp? timestamp) {
if (timestamp == null) return null;
final DateTime? dt = _service.toDateTime(timestamp);
if (dt == null) return null;
return DateFormat('HH:mm').format(dt);
Future<List<TimeCardEntry>> getTimeCards(DateTime month) async {
final ApiResponse response = await _api.get(
V2ApiEndpoints.staffTimeCard,
params: <String, dynamic>{
'year': month.year,
'month': month.month,
},
);
final List<dynamic> items = response.data['entries'] as List<dynamic>;
return items
.map((dynamic json) =>
TimeCardEntry.fromJson(json as Map<String, dynamic>))
.toList();
}
}

View File

@@ -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);
}

View File

@@ -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);
}
}

View File

@@ -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));
}
}

View File

@@ -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];
}

View File

@@ -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)),
],
);
}

View File

@@ -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 {

View File

@@ -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);

View File

@@ -23,8 +23,6 @@ dependencies:
path: ../../../../../core
krow_domain:
path: ../../../../../domain
krow_data_connect:
path: ../../../../../data_connect
dev_dependencies:
flutter_test:

View File

@@ -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?,
),
);

View File

@@ -1,36 +1,38 @@
import 'package:flutter/foundation.dart';
import 'package:krow_core/core.dart';
import 'package:krow_data_connect/krow_data_connect.dart'
hide AttireVerificationStatus;
import 'package:krow_domain/krow_domain.dart';
import '../../domain/repositories/attire_repository.dart';
import 'package:staff_attire/src/domain/repositories/attire_repository.dart';
/// Implementation of [AttireRepository].
/// Implementation of [AttireRepository] using the V2 API for reads
/// and core services for uploads.
///
/// Delegates data access to [StaffConnectorRepository].
/// Replaces the previous Firebase Data Connect / StaffConnectorRepository.
class AttireRepositoryImpl implements AttireRepository {
/// Creates an [AttireRepositoryImpl].
AttireRepositoryImpl({
required BaseApiService apiService,
required FileUploadService uploadService,
required SignedUrlService signedUrlService,
required VerificationService verificationService,
StaffConnectorRepository? connector,
}) : _connector =
connector ?? DataConnectService.instance.getStaffRepository(),
_uploadService = uploadService,
_signedUrlService = signedUrlService,
_verificationService = verificationService;
}) : _api = apiService,
_uploadService = uploadService,
_signedUrlService = signedUrlService,
_verificationService = verificationService;
/// The Staff Connector repository.
final StaffConnectorRepository _connector;
final BaseApiService _api;
final FileUploadService _uploadService;
final SignedUrlService _signedUrlService;
final VerificationService _verificationService;
@override
Future<List<AttireItem>> getAttireOptions() async {
return _connector.getAttireOptions();
Future<List<AttireChecklist>> getAttireOptions() async {
final ApiResponse response = await _api.get(V2ApiEndpoints.staffAttire);
final List<dynamic> items = response.data['items'] as List<dynamic>;
return items
.map((dynamic json) =>
AttireChecklist.fromJson(json as Map<String, dynamic>))
.toList();
}
@override
@@ -38,13 +40,11 @@ class AttireRepositoryImpl implements AttireRepository {
required List<String> selectedItemIds,
required Map<String, String> photoUrls,
}) async {
// We already upsert photos in uploadPhoto (to follow the new flow).
// This could save selections if there was a separate "SelectedAttire" table.
// For now, it's a no-op as the source of truth is the StaffAttire table.
// Attire selection is saved per-item via uploadPhoto; this is a no-op.
}
@override
Future<AttireItem> uploadPhoto(String itemId, String filePath) async {
Future<AttireChecklist> uploadPhoto(String itemId, String filePath) async {
// 1. Upload file to Core API
final FileUploadResponse uploadRes = await _uploadService.uploadFile(
filePath: filePath,
@@ -53,41 +53,40 @@ class AttireRepositoryImpl implements AttireRepository {
final String fileUri = uploadRes.fileUri;
// 2. Create signed URL for the uploaded file
final SignedUrlResponse signedUrlRes = await _signedUrlService
.createSignedUrl(fileUri: fileUri);
// 2. Create signed URL
final SignedUrlResponse signedUrlRes =
await _signedUrlService.createSignedUrl(fileUri: fileUri);
final String photoUrl = signedUrlRes.signedUrl;
// 3. Initiate verification job
final Staff staff = await _connector.getStaffProfile();
// Get item details for verification rules
final List<AttireItem> options = await _connector.getAttireOptions();
final AttireItem targetItem = options.firstWhere(
(AttireItem e) => e.id == itemId,
final List<AttireChecklist> options = await getAttireOptions();
final AttireChecklist targetItem = options.firstWhere(
(AttireChecklist e) => e.documentId == itemId,
orElse: () => throw UnknownException(
technicalMessage: 'Attire item $itemId not found in checklist',
),
);
final String dressCode =
'${targetItem.description ?? ''} ${targetItem.label}'.trim();
'${targetItem.description} ${targetItem.name}'.trim();
final VerificationResponse verifyRes = await _verificationService
.createVerification(
type: 'attire',
subjectType: 'worker',
subjectId: staff.id,
fileUri: fileUri,
rules: <String, dynamic>{'dressCode': dressCode},
);
final String verificationId = verifyRes.verificationId;
final VerificationResponse verifyRes =
await _verificationService.createVerification(
type: 'attire',
subjectType: 'worker',
subjectId: itemId,
fileUri: fileUri,
rules: <String, dynamic>{'dressCode': dressCode},
);
// 4. Poll for status until finished or timeout (max 3 seconds)
VerificationStatus currentStatus = verifyRes.status;
// 4. Poll for status until it's finished or timeout (max 10 seconds)
try {
int attempts = 0;
bool isFinished = false;
while (!isFinished && attempts < 5) {
await Future<void>.delayed(const Duration(seconds: 2));
final VerificationResponse statusRes = await _verificationService
.getStatus(verificationId);
while (!isFinished && attempts < 3) {
await Future<void>.delayed(const Duration(seconds: 1));
final VerificationResponse statusRes =
await _verificationService.getStatus(verifyRes.verificationId);
currentStatus = statusRes.status;
if (currentStatus != VerificationStatus.pending &&
currentStatus != VerificationStatus.processing) {
@@ -97,40 +96,24 @@ class AttireRepositoryImpl implements AttireRepository {
}
} catch (e) {
debugPrint('Polling failed or timed out: $e');
// Continue anyway, as we have the verificationId
}
// 5. Update Data Connect
await _connector.upsertStaffAttire(
attireOptionId: itemId,
photoUrl: photoUrl,
verificationId: verificationId,
verificationStatus: _mapToAttireStatus(currentStatus),
// 5. Update attire item via V2 API
await _api.put(
V2ApiEndpoints.staffAttireUpload(itemId),
data: <String, dynamic>{
'photoUrl': photoUrl,
'verificationId': verifyRes.verificationId,
},
);
// 6. Return updated AttireItem by re-fetching to get the PENDING/SUCCESS status
final List<AttireItem> finalOptions = await _connector.getAttireOptions();
return finalOptions.firstWhere((AttireItem e) => e.id == itemId);
}
AttireVerificationStatus _mapToAttireStatus(VerificationStatus status) {
switch (status) {
case VerificationStatus.pending:
return AttireVerificationStatus.pending;
case VerificationStatus.processing:
return AttireVerificationStatus.processing;
case VerificationStatus.autoPass:
return AttireVerificationStatus.autoPass;
case VerificationStatus.autoFail:
return AttireVerificationStatus.autoFail;
case VerificationStatus.needsReview:
return AttireVerificationStatus.needsReview;
case VerificationStatus.approved:
return AttireVerificationStatus.approved;
case VerificationStatus.rejected:
return AttireVerificationStatus.rejected;
case VerificationStatus.error:
return AttireVerificationStatus.error;
}
// 6. Return updated item by re-fetching
final List<AttireChecklist> finalOptions = await getAttireOptions();
return finalOptions.firstWhere(
(AttireChecklist e) => e.documentId == itemId,
orElse: () => throw UnknownException(
technicalMessage: 'Attire item $itemId not found after upload',
),
);
}
}

View File

@@ -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({

View File

@@ -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();
}
}

View File

@@ -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);
}
}

View File

@@ -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));

View File

@@ -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,

View File

@@ -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,
),
);

View File

@@ -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(

View File

@@ -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,

View File

@@ -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],
);
},
),

View File

@@ -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;

View File

@@ -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(

View File

@@ -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,

View File

@@ -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:

View File

@@ -1,81 +1,38 @@
import 'package:krow_data_connect/krow_data_connect.dart' as dc;
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import '../../domain/repositories/emergency_contact_repository_interface.dart';
/// Implementation of [EmergencyContactRepositoryInterface].
import 'package:staff_emergency_contact/src/domain/repositories/emergency_contact_repository_interface.dart';
/// Implementation of [EmergencyContactRepositoryInterface] using the V2 API.
///
/// This repository delegates data operations to Firebase Data Connect.
/// Replaces the previous Firebase Data Connect implementation.
class EmergencyContactRepositoryImpl
implements EmergencyContactRepositoryInterface {
final dc.DataConnectService _service;
/// Creates an [EmergencyContactRepositoryImpl].
EmergencyContactRepositoryImpl({
dc.DataConnectService? service,
}) : _service = service ?? dc.DataConnectService.instance;
EmergencyContactRepositoryImpl({required BaseApiService apiService})
: _api = apiService;
final BaseApiService _api;
@override
Future<List<EmergencyContact>> getContacts() async {
return _service.run(() async {
final staffId = await _service.getStaffId();
final result = await _service.connector
.getEmergencyContactsByStaffId(staffId: staffId)
.execute();
return result.data.emergencyContacts.map((dto) {
return EmergencyContactAdapter.fromPrimitives(
id: dto.id,
name: dto.name,
phone: dto.phone,
relationship: dto.relationship.stringValue,
);
}).toList();
});
final ApiResponse response =
await _api.get(V2ApiEndpoints.staffEmergencyContacts);
final List<dynamic> items = response.data['contacts'] as List<dynamic>;
return items
.map((dynamic json) =>
EmergencyContact.fromJson(json as Map<String, dynamic>))
.toList();
}
@override
Future<void> saveContacts(List<EmergencyContact> contacts) async {
return _service.run(() async {
final staffId = await _service.getStaffId();
// 1. Get existing to delete
final existingResult = await _service.connector
.getEmergencyContactsByStaffId(staffId: staffId)
.execute();
final existingIds =
existingResult.data.emergencyContacts.map((e) => e.id).toList();
// 2. Delete all existing
await Future.wait(existingIds.map(
(id) => _service.connector.deleteEmergencyContact(id: id).execute()));
// 3. Create new
await Future.wait(contacts.map((contact) {
dc.RelationshipType rel = dc.RelationshipType.OTHER;
switch (contact.relationship) {
case RelationshipType.family:
rel = dc.RelationshipType.FAMILY;
break;
case RelationshipType.spouse:
rel = dc.RelationshipType.SPOUSE;
break;
case RelationshipType.friend:
rel = dc.RelationshipType.FRIEND;
break;
case RelationshipType.other:
rel = dc.RelationshipType.OTHER;
break;
}
return _service.connector
.createEmergencyContact(
name: contact.name,
phone: contact.phone,
relationship: rel,
staffId: staffId,
)
.execute();
}));
});
await _api.put(
V2ApiEndpoints.staffEmergencyContacts,
data: <String, dynamic>{
'contacts':
contacts.map((EmergencyContact c) => c.toJson()).toList(),
},
);
}
}
}

View File

@@ -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.

View File

@@ -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

View File

@@ -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;
}
}

View File

@@ -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

View File

@@ -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:

View File

@@ -1,42 +1,31 @@
import 'package:krow_data_connect/krow_data_connect.dart' as dc;
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import '../../domain/repositories/experience_repository_interface.dart';
import 'package:staff_profile_experience/src/domain/repositories/experience_repository_interface.dart';
/// Implementation of [ExperienceRepositoryInterface] that delegates to Data Connect.
/// Implementation of [ExperienceRepositoryInterface] using the V2 API.
///
/// Replaces the previous Firebase Data Connect implementation.
class ExperienceRepositoryImpl implements ExperienceRepositoryInterface {
final dc.DataConnectService _service;
/// Creates an [ExperienceRepositoryImpl].
ExperienceRepositoryImpl({required BaseApiService apiService})
: _api = apiService;
/// Creates a [ExperienceRepositoryImpl] using Data Connect Service.
ExperienceRepositoryImpl({
dc.DataConnectService? service,
}) : _service = service ?? dc.DataConnectService.instance;
Future<dc.GetStaffByIdStaff> _getStaff() async {
final staffId = await _service.getStaffId();
final result =
await _service.connector.getStaffById(id: staffId).execute();
if (result.data.staff == null) {
throw const ServerException(technicalMessage: 'Staff profile not found');
}
return result.data.staff!;
}
final BaseApiService _api;
@override
Future<List<String>> getIndustries() async {
return _service.run(() async {
final staff = await _getStaff();
return staff.industries ?? [];
});
final ApiResponse response =
await _api.get(V2ApiEndpoints.staffIndustries);
final List<dynamic> items = response.data['industries'] as List<dynamic>;
return items.map((dynamic e) => e.toString()).toList();
}
@override
Future<List<String>> getSkills() async {
return _service.run(() async {
final staff = await _getStaff();
return staff.skills ?? [];
});
final ApiResponse response = await _api.get(V2ApiEndpoints.staffSkills);
final List<dynamic> items = response.data['skills'] as List<dynamic>;
return items.map((dynamic e) => e.toString()).toList();
}
@override
@@ -44,13 +33,12 @@ class ExperienceRepositoryImpl implements ExperienceRepositoryInterface {
List<String> industries,
List<String> skills,
) async {
return _service.run(() async {
final staff = await _getStaff();
await _service.connector
.updateStaff(id: staff.id)
.industries(industries)
.skills(skills)
.execute();
});
await _api.put(
V2ApiEndpoints.staffPersonalInfo,
data: <String, dynamic>{
'industries': industries,
'skills': skills,
},
);
}
}

View File

@@ -1,7 +1,6 @@
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import '../../domain/arguments/save_experience_arguments.dart';
import '../../domain/usecases/get_staff_industries_usecase.dart';
import '../../domain/usecases/get_staff_skills_usecase.dart';
@@ -18,7 +17,7 @@ abstract class ExperienceEvent extends Equatable {
class ExperienceLoaded extends ExperienceEvent {}
class ExperienceIndustryToggled extends ExperienceEvent {
final Industry industry;
final String industry;
const ExperienceIndustryToggled(this.industry);
@override
@@ -48,10 +47,10 @@ enum ExperienceStatus { initial, loading, success, failure }
class ExperienceState extends Equatable {
final ExperienceStatus status;
final List<Industry> selectedIndustries;
final List<String> selectedIndustries;
final List<String> selectedSkills;
final List<Industry> availableIndustries;
final List<ExperienceSkill> availableSkills;
final List<String> availableIndustries;
final List<String> availableSkills;
final String? errorMessage;
const ExperienceState({
@@ -65,10 +64,10 @@ class ExperienceState extends Equatable {
ExperienceState copyWith({
ExperienceStatus? status,
List<Industry>? selectedIndustries,
List<String>? selectedIndustries,
List<String>? selectedSkills,
List<Industry>? availableIndustries,
List<ExperienceSkill>? availableSkills,
List<String>? availableIndustries,
List<String>? availableSkills,
String? errorMessage,
}) {
return ExperienceState(
@@ -92,6 +91,37 @@ class ExperienceState extends Equatable {
];
}
/// Available industry option values.
const List<String> _kAvailableIndustries = <String>[
'hospitality',
'food_service',
'warehouse',
'events',
'retail',
'healthcare',
'other',
];
/// Available skill option values.
const List<String> _kAvailableSkills = <String>[
'food_service',
'bartending',
'event_setup',
'hospitality',
'warehouse',
'customer_service',
'cleaning',
'security',
'retail',
'driving',
'cooking',
'cashier',
'server',
'barista',
'host_hostess',
'busser',
];
// BLoC
class ExperienceBloc extends Bloc<ExperienceEvent, ExperienceState>
with BlocErrorHandler<ExperienceState> {
@@ -105,8 +135,8 @@ class ExperienceBloc extends Bloc<ExperienceEvent, ExperienceState>
required this.saveExperience,
}) : super(
const ExperienceState(
availableIndustries: Industry.values,
availableSkills: ExperienceSkill.values,
availableIndustries: _kAvailableIndustries,
availableSkills: _kAvailableSkills,
),
) {
on<ExperienceLoaded>(_onLoaded);
@@ -131,11 +161,7 @@ class ExperienceBloc extends Bloc<ExperienceEvent, ExperienceState>
emit(
state.copyWith(
status: ExperienceStatus.initial,
selectedIndustries:
results[0]
.map((e) => Industry.fromString(e))
.whereType<Industry>()
.toList(),
selectedIndustries: results[0],
selectedSkills: results[1],
),
);
@@ -151,7 +177,7 @@ class ExperienceBloc extends Bloc<ExperienceEvent, ExperienceState>
ExperienceIndustryToggled event,
Emitter<ExperienceState> emit,
) {
final industries = List<Industry>.from(state.selectedIndustries);
final industries = List<String>.from(state.selectedIndustries);
if (industries.contains(event.industry)) {
industries.remove(event.industry);
} else {
@@ -193,7 +219,7 @@ class ExperienceBloc extends Bloc<ExperienceEvent, ExperienceState>
action: () async {
await saveExperience(
SaveExperienceArguments(
industries: state.selectedIndustries.map((e) => e.value).toList(),
industries: state.selectedIndustries,
skills: state.selectedSkills,
),
);
@@ -206,4 +232,3 @@ class ExperienceBloc extends Bloc<ExperienceEvent, ExperienceState>
);
}
}

View File

@@ -4,7 +4,6 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import '../blocs/experience_bloc.dart';
import '../widgets/experience_section_title.dart';
@@ -12,59 +11,63 @@ import '../widgets/experience_section_title.dart';
class ExperiencePage extends StatelessWidget {
const ExperiencePage({super.key});
String _getIndustryLabel(dynamic node, Industry industry) {
String _getIndustryLabel(dynamic node, String industry) {
switch (industry) {
case Industry.hospitality:
case 'hospitality':
return node.hospitality;
case Industry.foodService:
case 'food_service':
return node.food_service;
case Industry.warehouse:
case 'warehouse':
return node.warehouse;
case Industry.events:
case 'events':
return node.events;
case Industry.retail:
case 'retail':
return node.retail;
case Industry.healthcare:
case 'healthcare':
return node.healthcare;
case Industry.other:
case 'other':
return node.other;
default:
return industry;
}
}
String _getSkillLabel(dynamic node, ExperienceSkill skill) {
String _getSkillLabel(dynamic node, String skill) {
switch (skill) {
case ExperienceSkill.foodService:
case 'food_service':
return node.food_service;
case ExperienceSkill.bartending:
case 'bartending':
return node.bartending;
case ExperienceSkill.eventSetup:
case 'event_setup':
return node.event_setup;
case ExperienceSkill.hospitality:
case 'hospitality':
return node.hospitality;
case ExperienceSkill.warehouse:
case 'warehouse':
return node.warehouse;
case ExperienceSkill.customerService:
case 'customer_service':
return node.customer_service;
case ExperienceSkill.cleaning:
case 'cleaning':
return node.cleaning;
case ExperienceSkill.security:
case 'security':
return node.security;
case ExperienceSkill.retail:
case 'retail':
return node.retail;
case ExperienceSkill.driving:
case 'driving':
return node.driving;
case ExperienceSkill.cooking:
case 'cooking':
return node.cooking;
case ExperienceSkill.cashier:
case 'cashier':
return node.cashier;
case ExperienceSkill.server:
case 'server':
return node.server;
case ExperienceSkill.barista:
case 'barista':
return node.barista;
case ExperienceSkill.hostHostess:
case 'host_hostess':
return node.host_hostess;
case ExperienceSkill.busser:
case 'busser':
return node.busser;
default:
return skill;
}
}
@@ -154,14 +157,12 @@ class ExperiencePage extends StatelessWidget {
.map(
(s) => UiChip(
label: _getSkillLabel(i18n.skills, s),
isSelected: state.selectedSkills.contains(
s.value,
),
isSelected: state.selectedSkills.contains(s),
onTap: () => BlocProvider.of<ExperienceBloc>(
context,
).add(ExperienceSkillToggled(s.value)),
).add(ExperienceSkillToggled(s)),
variant:
state.selectedSkills.contains(s.value)
state.selectedSkills.contains(s)
? UiChipVariant.primary
: UiChipVariant.secondary,
),
@@ -183,7 +184,7 @@ class ExperiencePage extends StatelessWidget {
Widget _buildCustomSkillsList(ExperienceState state, dynamic i18n) {
final customSkills = state.selectedSkills
.where((s) => !state.availableSkills.any((e) => e.value == s))
.where((s) => !state.availableSkills.contains(s))
.toList();
if (customSkills.isEmpty) return const SizedBox.shrink();

View File

@@ -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>()),

View File

@@ -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:

View File

@@ -1,119 +1,77 @@
import 'package:firebase_data_connect/firebase_data_connect.dart';
import 'package:krow_data_connect/krow_data_connect.dart';
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import '../../domain/repositories/personal_info_repository_interface.dart';
import 'package:staff_profile_info/src/domain/repositories/personal_info_repository_interface.dart';
/// Implementation of [PersonalInfoRepositoryInterface] that delegates
/// to Firebase Data Connect for all data operations.
/// to the V2 REST API for all data operations.
///
/// This implementation follows Clean Architecture by:
/// - Implementing the domain's repository interface
/// - Delegating all data access to the data_connect layer
/// - Mapping between data_connect DTOs and domain entities
/// - Containing no business logic
class PersonalInfoRepositoryImpl
implements PersonalInfoRepositoryInterface {
/// Replaces the previous Firebase Data Connect implementation.
class PersonalInfoRepositoryImpl implements PersonalInfoRepositoryInterface {
/// Creates a [PersonalInfoRepositoryImpl].
///
/// Requires the Firebase Data Connect service.
/// Requires the V2 [BaseApiService] for HTTP communication,
/// [FileUploadService] for uploading files to cloud storage, and
/// [SignedUrlService] for generating signed download URLs.
PersonalInfoRepositoryImpl({
DataConnectService? service,
}) : _service = service ?? DataConnectService.instance;
required BaseApiService apiService,
required FileUploadService uploadService,
required SignedUrlService signedUrlService,
}) : _api = apiService,
_uploadService = uploadService,
_signedUrlService = signedUrlService;
final DataConnectService _service;
final BaseApiService _api;
final FileUploadService _uploadService;
final SignedUrlService _signedUrlService;
@override
Future<Staff> getStaffProfile() async {
return _service.run(() async {
final String uid = _service.auth.currentUser!.uid;
// Query staff data from Firebase Data Connect
final QueryResult<GetStaffByUserIdData, GetStaffByUserIdVariables> result =
await _service.connector.getStaffByUserId(userId: uid).execute();
if (result.data.staffs.isEmpty) {
throw const ServerException(technicalMessage: 'Staff profile not found');
}
final GetStaffByUserIdStaffs rawStaff = result.data.staffs.first;
// Map from data_connect DTO to domain entity
return _mapToStaffEntity(rawStaff);
});
Future<StaffPersonalInfo> getStaffProfile() async {
final ApiResponse response =
await _api.get(V2ApiEndpoints.staffPersonalInfo);
final Map<String, dynamic> json =
response.data as Map<String, dynamic>;
return StaffPersonalInfo.fromJson(json);
}
@override
Future<Staff> updateStaffProfile(
{required String staffId, required Map<String, dynamic> data}) async {
return _service.run(() async {
// Start building the update mutation
UpdateStaffVariablesBuilder updateBuilder =
_service.connector.updateStaff(id: staffId);
// Apply updates from map if present
if (data.containsKey('name')) {
updateBuilder = updateBuilder.fullName(data['name'] as String);
}
if (data.containsKey('email')) {
updateBuilder = updateBuilder.email(data['email'] as String);
}
if (data.containsKey('phone')) {
updateBuilder = updateBuilder.phone(data['phone'] as String?);
}
if (data.containsKey('avatar')) {
updateBuilder = updateBuilder.photoUrl(data['avatar'] as String?);
}
if (data.containsKey('preferredLocations')) {
// After schema update and SDK regeneration, preferredLocations accepts List<String>
updateBuilder = updateBuilder.preferredLocations(
data['preferredLocations'] as List<String>);
}
// Execute the update
final OperationResult<UpdateStaffData, UpdateStaffVariables> result =
await updateBuilder.execute();
if (result.data.staff_update == null) {
throw const ServerException(
technicalMessage: 'Failed to update staff profile');
}
// Fetch the updated staff profile to return complete entity
return getStaffProfile();
});
Future<StaffPersonalInfo> updateStaffProfile({
required String staffId,
required Map<String, dynamic> data,
}) async {
final ApiResponse response = await _api.put(
V2ApiEndpoints.staffPersonalInfo,
data: data,
);
final Map<String, dynamic> json =
response.data as Map<String, dynamic>;
return StaffPersonalInfo.fromJson(json);
}
@override
Future<String> uploadProfilePhoto(String filePath) async {
// TODO: Implement photo upload to Firebase Storage
// This will be implemented when Firebase Storage integration is ready
throw UnimplementedError(
'Photo upload not yet implemented. Will integrate with Firebase Storage.',
// 1. Upload the file to cloud storage.
final FileUploadResponse uploadRes = await _uploadService.uploadFile(
filePath: filePath,
fileName:
'staff_profile_photo_${DateTime.now().millisecondsSinceEpoch}.jpg',
visibility: FileVisibility.public,
);
}
/// Maps a data_connect Staff DTO to a domain Staff entity.
///
/// This mapping isolates the domain from data layer implementation details.
Staff _mapToStaffEntity(GetStaffByUserIdStaffs dto) {
return Staff(
id: dto.id,
authProviderId: dto.userId,
name: dto.fullName,
email: dto.email ?? '',
phone: dto.phone,
avatar: dto.photoUrl,
status: StaffStatus.active,
address: dto.addres,
totalShifts: dto.totalShifts,
averageRating: dto.averageRating,
onTimeRate: dto.onTimeRate,
noShowCount: dto.noShowCount,
cancellationCount: dto.cancellationCount,
reliabilityScore: dto.reliabilityScore,
// After schema update and SDK regeneration, preferredLocations is List<String>?
preferredLocations: dto.preferredLocations,
// 2. Generate a signed URL for the uploaded file.
final SignedUrlResponse signedUrlRes =
await _signedUrlService.createSignedUrl(fileUri: uploadRes.fileUri);
final String photoUrl = signedUrlRes.signedUrl;
// 3. Submit the photo URL to the V2 API.
await _api.post(
V2ApiEndpoints.staffProfilePhoto,
data: <String, dynamic>{
'fileUri': uploadRes.fileUri,
'photoUrl': photoUrl,
},
);
return photoUrl;
}
}

View File

@@ -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);
}

View File

@@ -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();
}
}

View File

@@ -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,

View File

@@ -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);
}
}

View File

@@ -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();
}
}

View File

@@ -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];
}

View File

@@ -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];
}

View File

@@ -9,14 +9,10 @@ import 'package:staff_profile_info/src/presentation/blocs/personal_info_state.da
import 'package:staff_profile_info/src/presentation/widgets/personal_info_page/personal_info_content.dart';
import 'package:staff_profile_info/src/presentation/widgets/personal_info_skeleton/personal_info_skeleton.dart';
/// The Personal Info page for staff onboarding.
///
/// This page allows staff members to view and edit their personal information
/// including phone number and address. Full name and email are read-only as they come from authentication.
///
/// This page is a StatelessWidget that uses BLoC for state management,
/// following Clean Architecture and the design system guidelines.
/// Allows staff members to view and edit their personal information
/// including phone number and address. Uses V2 API via BLoC.
class PersonalInfoPage extends StatelessWidget {
/// Creates a [PersonalInfoPage].
const PersonalInfoPage({super.key});
@@ -37,6 +33,12 @@ class PersonalInfoPage extends StatelessWidget {
type: UiSnackbarType.success,
);
Modular.to.popSafe();
} else if (state.status == PersonalInfoStatus.photoUploaded) {
UiSnackbar.show(
context,
message: i18n.photo_upload_success,
type: UiSnackbarType.success,
);
} else if (state.status == PersonalInfoStatus.error) {
UiSnackbar.show(
context,
@@ -60,7 +62,7 @@ class PersonalInfoPage extends StatelessWidget {
return const PersonalInfoSkeleton();
}
if (state.staff == null) {
if (state.personalInfo == null) {
return Center(
child: Text(
'Failed to load personal information',
@@ -69,7 +71,9 @@ class PersonalInfoPage extends StatelessWidget {
);
}
return PersonalInfoContent(staff: state.staff!);
return PersonalInfoContent(
personalInfo: state.personalInfo!,
);
},
),
),

View File

@@ -1,7 +1,9 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_modular/flutter_modular.dart';
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import 'package:staff_profile_info/src/presentation/blocs/personal_info_bloc.dart';
import 'package:staff_profile_info/src/presentation/blocs/personal_info_event.dart';
@@ -12,18 +14,16 @@ import 'package:staff_profile_info/src/presentation/widgets/save_button.dart';
/// Content widget that displays and manages the staff profile form.
///
/// This widget is extracted from the page to handle form state separately,
/// following Clean Architecture's separation of concerns principle and the design system guidelines.
/// Works with the shared [Staff] entity from the domain layer.
/// Works with [StaffPersonalInfo] from the V2 domain layer.
class PersonalInfoContent extends StatefulWidget {
/// Creates a [PersonalInfoContent].
const PersonalInfoContent({
super.key,
required this.staff,
required this.personalInfo,
});
/// The staff profile to display and edit.
final Staff staff;
/// The staff personal info to display and edit.
final StaffPersonalInfo personalInfo;
@override
State<PersonalInfoContent> createState() => _PersonalInfoContentState();
@@ -36,10 +36,13 @@ class _PersonalInfoContentState extends State<PersonalInfoContent> {
@override
void initState() {
super.initState();
_emailController = TextEditingController(text: widget.staff.email);
_phoneController = TextEditingController(text: widget.staff.phone ?? '');
_emailController = TextEditingController(
text: widget.personalInfo.email ?? '',
);
_phoneController = TextEditingController(
text: widget.personalInfo.phone ?? '',
);
// Listen to changes and update BLoC
_emailController.addListener(_onEmailChanged);
_phoneController.addListener(_onPhoneChanged);
}
@@ -51,42 +54,120 @@ class _PersonalInfoContentState extends State<PersonalInfoContent> {
super.dispose();
}
void _onEmailChanged() {
context.read<PersonalInfoBloc>().add(
PersonalInfoFieldChanged(
field: 'email',
value: _emailController.text,
),
);
ReadContext(context).read<PersonalInfoBloc>().add(
PersonalInfoFieldChanged(
field: 'email',
value: _emailController.text,
),
);
}
void _onPhoneChanged() {
context.read<PersonalInfoBloc>().add(
PersonalInfoFieldChanged(
field: 'phone',
value: _phoneController.text,
),
);
ReadContext(context).read<PersonalInfoBloc>().add(
PersonalInfoFieldChanged(
field: 'phone',
value: _phoneController.text,
),
);
}
void _handleSave() {
context.read<PersonalInfoBloc>().add(const PersonalInfoFormSubmitted());
ReadContext(context).read<PersonalInfoBloc>().add(const PersonalInfoFormSubmitted());
}
void _handlePhotoTap() {
// TODO: Implement photo picker
// context.read<PersonalInfoBloc>().add(
// PersonalInfoPhotoUploadRequested(filePath: pickedFilePath),
// );
/// Shows a bottom sheet to choose between camera and gallery, then
/// dispatches the upload event to the BLoC.
Future<void> _handlePhotoTap() async {
final TranslationsStaffOnboardingPersonalInfoEn i18n =
t.staff.onboarding.personal_info;
final TranslationsCommonEn common = t.common;
final String? source = await showModalBottomSheet<String>(
context: context,
builder: (BuildContext ctx) {
return SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(
vertical: UiConstants.space4,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Padding(
padding: const EdgeInsets.only(
bottom: UiConstants.space4,
),
child: Text(
i18n.choose_photo_source,
style: UiTypography.body1b.textPrimary,
),
),
ListTile(
leading: const Icon(
UiIcons.camera,
color: UiColors.primary,
),
title: Text(
common.camera,
style: UiTypography.body1r.textPrimary,
),
onTap: () => Navigator.pop(ctx, 'camera'),
),
ListTile(
leading: const Icon(
UiIcons.gallery,
color: UiColors.primary,
),
title: Text(
common.gallery,
style: UiTypography.body1r.textPrimary,
),
onTap: () => Navigator.pop(ctx, 'gallery'),
),
],
),
),
);
},
);
if (source == null || !mounted) return;
String? filePath;
if (source == 'camera') {
final CameraService cameraService = Modular.get<CameraService>();
filePath = await cameraService.takePhoto();
} else {
final GalleryService galleryService = Modular.get<GalleryService>();
filePath = await galleryService.pickImage();
}
if (filePath == null || !mounted) return;
ReadContext(context).read<PersonalInfoBloc>().add(
PersonalInfoPhotoUploadRequested(filePath: filePath),
);
}
/// Computes the display name from personal info first/last name.
String get _displayName {
final String first = widget.personalInfo.firstName ?? '';
final String last = widget.personalInfo.lastName ?? '';
final String name = '$first $last'.trim();
return name.isNotEmpty ? name : 'Staff';
}
@override
Widget build(BuildContext context) {
final TranslationsStaffOnboardingPersonalInfoEn i18n = t.staff.onboarding.personal_info;
final TranslationsStaffOnboardingPersonalInfoEn i18n =
t.staff.onboarding.personal_info;
return BlocBuilder<PersonalInfoBloc, PersonalInfoState>(
builder: (BuildContext context, PersonalInfoState state) {
final bool isSaving = state.status == PersonalInfoStatus.saving;
final bool isUploadingPhoto =
state.status == PersonalInfoStatus.uploadingPhoto;
final bool isBusy = isSaving || isUploadingPhoto;
return Column(
children: <Widget>[
Expanded(
@@ -96,26 +177,29 @@ class _PersonalInfoContentState extends State<PersonalInfoContent> {
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
ProfilePhotoWidget(
photoUrl: widget.staff.avatar,
fullName: widget.staff.name,
onTap: isSaving ? null : _handlePhotoTap,
photoUrl: state.personalInfo?.photoUrl,
fullName: _displayName,
onTap: isBusy ? null : _handlePhotoTap,
isUploading: isUploadingPhoto,
),
const SizedBox(height: UiConstants.space6),
PersonalInfoForm(
fullName: widget.staff.name,
email: widget.staff.email,
fullName: _displayName,
email: widget.personalInfo.email ?? '',
emailController: _emailController,
phoneController: _phoneController,
currentLocations: _toStringList(state.formValues['preferredLocations']),
enabled: !isSaving,
currentLocations: _toStringList(
state.formValues['preferredLocations'],
),
enabled: !isBusy,
),
const SizedBox(height: UiConstants.space16), // Space for bottom button
const SizedBox(height: UiConstants.space16),
],
),
),
),
SaveButton(
onPressed: isSaving ? null : _handleSave,
onPressed: isBusy ? null : _handleSave,
label: i18n.save_button,
isLoading: isSaving,
),
@@ -125,6 +209,7 @@ class _PersonalInfoContentState extends State<PersonalInfoContent> {
);
}
/// Safely converts a dynamic value to a string list.
List<String> _toStringList(dynamic raw) {
if (raw is List<String>) return raw;
if (raw is List) return raw.map((dynamic e) => e.toString()).toList();

View File

@@ -16,7 +16,9 @@ class ProfilePhotoWidget extends StatelessWidget {
required this.photoUrl,
required this.fullName,
required this.onTap,
this.isUploading = false,
});
/// The URL of the staff member's photo.
final String? photoUrl;
@@ -26,6 +28,9 @@ class ProfilePhotoWidget extends StatelessWidget {
/// Callback when the photo/camera button is tapped.
final VoidCallback? onTap;
/// Whether a photo upload is currently in progress.
final bool isUploading;
@override
Widget build(BuildContext context) {
final TranslationsStaffOnboardingPersonalInfoEn i18n =
@@ -44,19 +49,34 @@ class ProfilePhotoWidget extends StatelessWidget {
shape: BoxShape.circle,
color: UiColors.primary.withValues(alpha: 0.1),
),
child: photoUrl != null
? ClipOval(
child: Image.network(
photoUrl!,
fit: BoxFit.cover,
child: isUploading
? const Center(
child: SizedBox(
width: 32,
height: 32,
child: CircularProgressIndicator(
strokeWidth: 2,
color: UiColors.primary,
),
),
)
: Center(
child: Text(
fullName.isNotEmpty ? fullName[0].toUpperCase() : '?',
style: UiTypography.displayL.primary,
),
),
: photoUrl != null
? ClipOval(
child: Image.network(
photoUrl!,
width: 96,
height: 96,
fit: BoxFit.cover,
),
)
: Center(
child: Text(
fullName.isNotEmpty
? fullName[0].toUpperCase()
: '?',
style: UiTypography.displayL.primary,
),
),
),
Positioned(
bottom: 0,

View File

@@ -1,47 +1,57 @@
import 'package:flutter/material.dart';
import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import 'data/repositories/personal_info_repository_impl.dart';
import 'domain/repositories/personal_info_repository_interface.dart';
import 'domain/usecases/get_personal_info_usecase.dart';
import 'domain/usecases/update_personal_info_usecase.dart';
import 'presentation/blocs/personal_info_bloc.dart';
import 'presentation/pages/personal_info_page.dart';
import 'presentation/pages/language_selection_page.dart';
import 'presentation/pages/preferred_locations_page.dart';
import 'package:staff_profile_info/src/data/repositories/personal_info_repository_impl.dart';
import 'package:staff_profile_info/src/domain/repositories/personal_info_repository_interface.dart';
import 'package:staff_profile_info/src/domain/usecases/get_personal_info_usecase.dart';
import 'package:staff_profile_info/src/domain/usecases/update_personal_info_usecase.dart';
import 'package:staff_profile_info/src/domain/usecases/upload_profile_photo_usecase.dart';
import 'package:staff_profile_info/src/presentation/blocs/personal_info_bloc.dart';
import 'package:staff_profile_info/src/presentation/pages/personal_info_page.dart';
import 'package:staff_profile_info/src/presentation/pages/language_selection_page.dart';
import 'package:staff_profile_info/src/presentation/pages/preferred_locations_page.dart';
/// The entry module for the Staff Profile Info feature.
///
/// This module provides routing and dependency injection for
/// personal information functionality following Clean Architecture.
///
/// The module:
/// - Registers repository implementations
/// - Registers use cases that contain business logic
/// - Registers BLoC for state management
/// - Defines routes for navigation
/// Provides routing and dependency injection for personal information
/// functionality, using the V2 REST API via [BaseApiService].
class StaffProfileInfoModule extends Module {
@override
List<Module> get imports => <Module>[CoreModule()];
@override
void binds(Injector i) {
// Repository
i.addLazySingleton<PersonalInfoRepositoryInterface>(
PersonalInfoRepositoryImpl.new,
() => PersonalInfoRepositoryImpl(
apiService: i.get<BaseApiService>(),
uploadService: i.get<FileUploadService>(),
signedUrlService: i.get<SignedUrlService>(),
),
);
// Use Cases - delegate business logic to repository
// Use Cases
i.addLazySingleton<GetPersonalInfoUseCase>(
() => GetPersonalInfoUseCase(i.get<PersonalInfoRepositoryInterface>()),
);
i.addLazySingleton<UpdatePersonalInfoUseCase>(
() => UpdatePersonalInfoUseCase(i.get<PersonalInfoRepositoryInterface>()),
() =>
UpdatePersonalInfoUseCase(i.get<PersonalInfoRepositoryInterface>()),
);
i.addLazySingleton<UploadProfilePhotoUseCase>(
() => UploadProfilePhotoUseCase(
i.get<PersonalInfoRepositoryInterface>(),
),
);
// BLoC - manages presentation state
// BLoC
i.addLazySingleton<PersonalInfoBloc>(
() => PersonalInfoBloc(
getPersonalInfoUseCase: i.get<GetPersonalInfoUseCase>(),
updatePersonalInfoUseCase: i.get<UpdatePersonalInfoUseCase>(),
uploadProfilePhotoUseCase: i.get<UploadProfilePhotoUseCase>(),
),
);
}

View File

@@ -15,7 +15,7 @@ dependencies:
bloc: ^8.1.0
flutter_modular: ^6.3.0
equatable: ^2.0.5
# Architecture Packages
design_system:
path: ../../../../../design_system
@@ -25,13 +25,10 @@ dependencies:
path: ../../../../../core
krow_domain:
path: ../../../../../domain
krow_data_connect:
path: ../../../../../data_connect
firebase_auth: any
firebase_data_connect: any
google_places_flutter: ^2.1.1
http: ^1.2.2
dev_dependencies:
flutter_test:
sdk: flutter

View File

@@ -1,58 +1,26 @@
import 'dart:convert';
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import 'package:flutter/services.dart';
import 'package:staff_faqs/src/domain/entities/faq_category.dart';
import 'package:staff_faqs/src/domain/repositories/faqs_repository_interface.dart';
import '../../domain/entities/faq_category.dart';
import '../../domain/entities/faq_item.dart';
import '../../domain/repositories/faqs_repository_interface.dart';
/// Data layer implementation of FAQs repository
/// V2 API implementation of [FaqsRepositoryInterface].
///
/// Handles loading FAQs from app assets (JSON file)
/// Fetches FAQ data from the V2 REST backend via [ApiService].
class FaqsRepositoryImpl implements FaqsRepositoryInterface {
/// Private cache for FAQs to avoid reloading from assets multiple times
List<FaqCategory>? _cachedFaqs;
/// Creates a [FaqsRepositoryImpl] backed by the given [apiService].
FaqsRepositoryImpl({required ApiService apiService})
: _apiService = apiService;
final ApiService _apiService;
@override
Future<List<FaqCategory>> getFaqs() async {
try {
// Return cached FAQs if available
if (_cachedFaqs != null) {
return _cachedFaqs!;
}
// Load FAQs from JSON asset
final String faqsJson = await rootBundle.loadString(
'packages/staff_faqs/lib/src/assets/faqs/faqs.json',
);
// Parse JSON
final List<dynamic> decoded = jsonDecode(faqsJson) as List<dynamic>;
// Convert to domain entities
_cachedFaqs = decoded.map((dynamic item) {
final Map<String, dynamic> category = item as Map<String, dynamic>;
final String categoryName = category['category'] as String;
final List<dynamic> questionsData =
category['questions'] as List<dynamic>;
final List<FaqItem> questions = questionsData.map((dynamic q) {
final Map<String, dynamic> questionMap = q as Map<String, dynamic>;
return FaqItem(
question: questionMap['q'] as String,
answer: questionMap['a'] as String,
);
}).toList();
return FaqCategory(
category: categoryName,
questions: questions,
);
}).toList();
return _cachedFaqs!;
} catch (e) {
// Return empty list on error
final ApiResponse response =
await _apiService.get(V2ApiEndpoints.staffFaqs);
return _parseCategories(response);
} catch (_) {
return <FaqCategory>[];
}
}
@@ -60,42 +28,24 @@ class FaqsRepositoryImpl implements FaqsRepositoryInterface {
@override
Future<List<FaqCategory>> searchFaqs(String query) async {
try {
// Get all FAQs first
final List<FaqCategory> allFaqs = await getFaqs();
if (query.isEmpty) {
return allFaqs;
}
final String lowerQuery = query.toLowerCase();
// Filter categories based on matching questions
final List<FaqCategory> filtered = allFaqs
.map((FaqCategory category) {
// Filter questions that match the query
final List<FaqItem> matchingQuestions =
category.questions.where((FaqItem item) {
final String questionLower = item.question.toLowerCase();
final String answerLower = item.answer.toLowerCase();
return questionLower.contains(lowerQuery) ||
answerLower.contains(lowerQuery);
}).toList();
// Only include category if it has matching questions
if (matchingQuestions.isNotEmpty) {
return FaqCategory(
category: category.category,
questions: matchingQuestions,
);
}
return null;
})
.whereType<FaqCategory>()
.toList();
return filtered;
} catch (e) {
final ApiResponse response = await _apiService.get(
V2ApiEndpoints.staffFaqsSearch,
params: <String, dynamic>{'q': query},
);
return _parseCategories(response);
} catch (_) {
return <FaqCategory>[];
}
}
/// Parses the `items` array from a V2 API response into [FaqCategory] list.
List<FaqCategory> _parseCategories(ApiResponse response) {
final List<dynamic> items = response.data['items'] as List<dynamic>;
return items
.map(
(dynamic item) =>
FaqCategory.fromJson(item as Map<String, dynamic>),
)
.toList();
}
}

View File

@@ -1,18 +1,35 @@
import 'package:equatable/equatable.dart';
import 'faq_item.dart';
import 'package:staff_faqs/src/domain/entities/faq_item.dart';
/// Entity representing an FAQ category with its questions
/// Entity representing an FAQ category with its questions.
class FaqCategory extends Equatable {
/// Creates a [FaqCategory] with the given [category] name and [questions].
const FaqCategory({
required this.category,
required this.questions,
});
/// The category name (e.g., "Getting Started", "Shifts & Work")
/// Deserializes a [FaqCategory] from a V2 API JSON map.
///
/// The API returns question items under the `items` key.
factory FaqCategory.fromJson(Map<String, dynamic> json) {
final List<dynamic> items = json['items'] as List<dynamic>;
return FaqCategory(
category: json['category'] as String,
questions: items
.map(
(dynamic item) =>
FaqItem.fromJson(item as Map<String, dynamic>),
)
.toList(),
);
}
/// The category name (e.g., "Getting Started", "Shifts & Work").
final String category;
/// List of FAQ items in this category
/// List of FAQ items in this category.
final List<FaqItem> questions;
@override

View File

@@ -1,16 +1,25 @@
import 'package:equatable/equatable.dart';
/// Entity representing a single FAQ question and answer
/// Entity representing a single FAQ question and answer.
class FaqItem extends Equatable {
/// Creates a [FaqItem] with the given [question] and [answer].
const FaqItem({
required this.question,
required this.answer,
});
/// The question text
/// Deserializes a [FaqItem] from a JSON map.
factory FaqItem.fromJson(Map<String, dynamic> json) {
return FaqItem(
question: json['question'] as String,
answer: json['answer'] as String,
);
}
/// The question text.
final String question;
/// The answer text
/// The answer text.
final String answer;
@override

View File

@@ -1,4 +1,4 @@
import '../entities/faq_category.dart';
import 'package:staff_faqs/src/domain/entities/faq_category.dart';
/// Interface for FAQs repository operations
abstract class FaqsRepositoryInterface {

Some files were not shown because too many files have changed in this diff Show More