Merge 592-migrate-frontend-applications-to-v2-backend-and-database into feature/session-persistence-new

This commit is contained in:
2026-03-18 12:51:23 +05:30
660 changed files with 18935 additions and 21383 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(StaffEndpoints.certificates);
final List<dynamic> items =
response.data['items'] as List<dynamic>? ?? <dynamic>[];
return items
.map((dynamic json) =>
StaffCertificate.fromJson(json as Map<String, dynamic>))
.toList();
}
@override
Future<domain.StaffCertificate> uploadCertificate({
required domain.ComplianceType certificationType,
Future<StaffCertificate> uploadCertificate({
required String certificateType,
required String name,
required String filePath,
DateTime? expiryDate,
String? issuer,
String? certificateNumber,
}) async {
return _service.run(() async {
// Get existing certificate to check if file has changed
final List<domain.StaffCertificate> existingCerts = await getCertificates();
domain.StaffCertificate? existingCert;
try {
existingCert = existingCerts.firstWhere(
(domain.StaffCertificate c) => c.certificationType == certificationType,
);
} catch (e) {
// Certificate doesn't exist yet
}
// 1. Upload the file to cloud storage
final FileUploadResponse uploadRes = await _uploadService.uploadFile(
filePath: filePath,
fileName:
'staff_cert_${certificateType}_${DateTime.now().millisecondsSinceEpoch}.pdf',
visibility: FileVisibility.private,
);
String? signedUrl = existingCert?.certificateUrl;
String? verificationId = existingCert?.verificationId;
final bool fileChanged = existingCert == null || existingCert.certificateUrl != filePath;
// 2. Generate a signed URL
final SignedUrlResponse signedUrlRes =
await _signedUrlService.createSignedUrl(fileUri: uploadRes.fileUri);
// Only upload and verify if file path has changed
if (fileChanged) {
// 1. Upload the file to cloud storage
final FileUploadResponse uploadRes = await _uploadService.uploadFile(
filePath: filePath,
fileName:
'staff_cert_${certificationType.name}_${DateTime.now().millisecondsSinceEpoch}.pdf',
visibility: domain.FileVisibility.private,
);
// 3. Initiate verification
final VerificationResponse verificationRes =
await _verificationService.createVerification(
fileUri: uploadRes.fileUri,
type: 'certification',
subjectType: 'worker',
subjectId: certificateType,
rules: <String, dynamic>{
'certificateName': name,
'certificateIssuer': issuer,
'certificateNumber': certificateNumber,
},
);
// 2. Generate a signed URL for verification service to access the file
final SignedUrlResponse signedUrlRes = await _signedUrlService.createSignedUrl(fileUri: uploadRes.fileUri);
signedUrl = signedUrlRes.signedUrl;
// 4. Save certificate via V2 API
await _api.post(
StaffEndpoints.certificates,
data: <String, dynamic>{
'certificateType': certificateType,
'name': name,
'fileUri': signedUrlRes.signedUrl,
'expiresAt': expiryDate?.toIso8601String(),
'issuer': issuer,
'certificateNumber': certificateNumber,
'verificationId': verificationRes.verificationId,
},
);
// 3. Initiate verification
final String staffId = await _service.getStaffId();
final VerificationResponse verificationRes = await _verificationService
.createVerification(
fileUri: uploadRes.fileUri,
type: 'certification',
subjectType: 'worker',
subjectId: staffId,
rules: <String, dynamic>{
'certificateName': name,
'certificateIssuer': issuer,
'certificateNumber': certificateNumber,
},
);
verificationId = verificationRes.verificationId;
}
// 4. Update/Create Certificate in Data Connect
await _service.getStaffRepository().upsertStaffCertificate(
certificationType: certificationType,
name: name,
status: existingCert?.status ?? domain.StaffCertificateStatus.pending,
fileUrl: signedUrl,
expiry: expiryDate,
issuer: issuer,
certificateNumber: certificateNumber,
validationStatus: existingCert?.validationStatus ?? domain.StaffCertificateValidationStatus.pendingExpertReview,
verificationId: verificationId,
);
// 5. Return updated list or the specific certificate
final List<domain.StaffCertificate> certificates =
await getCertificates();
return certificates.firstWhere(
(domain.StaffCertificate c) => c.certificationType == certificationType,
);
});
}
@override
Future<void> upsertCertificate({
required domain.ComplianceType certificationType,
required String name,
required domain.StaffCertificateStatus status,
String? fileUrl,
DateTime? expiry,
String? issuer,
String? certificateNumber,
domain.StaffCertificateValidationStatus? validationStatus,
}) async {
await _service.getStaffRepository().upsertStaffCertificate(
certificationType: certificationType,
name: name,
status: status,
fileUrl: fileUrl,
expiry: expiry,
issuer: issuer,
certificateNumber: certificateNumber,
validationStatus: validationStatus,
// 5. Return updated list
final List<StaffCertificate> certificates = await getCertificates();
return certificates.firstWhere(
(StaffCertificate c) => c.certificateType == certificateType,
);
}
@override
Future<void> deleteCertificate({
required domain.ComplianceType certificationType,
}) async {
return _service.getStaffRepository().deleteStaffCertificate(
certificationType: certificationType,
Future<void> deleteCertificate({required String certificateId}) async {
await _api.delete(
StaffEndpoints.certificateDelete(certificateId),
);
}
}

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.issuer ?? certificate.certificateType,
style: UiTypography.body3r.textSecondary,
),
if (showComplete) ...<Widget>[
@@ -159,17 +146,15 @@ class CertificateCard extends StatelessWidget {
_buildMiniStatus(
t.staff_certificates.card.verified,
UiColors.primary,
certificate.expiryDate,
certificate.expiresAt,
),
],
if (isExpiring || isExpired) ...<Widget>[
if (isExpired) ...<Widget>[
const SizedBox(height: UiConstants.space2),
_buildMiniStatus(
isExpired
? t.staff_certificates.card.expired
: t.staff_certificates.card.expiring_soon,
isExpired ? UiColors.destructive : UiColors.primary,
certificate.expiryDate,
t.staff_certificates.card.expired,
UiColors.destructive,
certificate.expiresAt,
),
],
if (isNotStarted) ...<Widget>[
@@ -220,18 +205,14 @@ class CertificateCard extends StatelessWidget {
);
}
int _daysUntilExpiry(DateTime? expiry) {
if (expiry == null) return 0;
return expiry.difference(DateTime.now()).inDays;
}
_CertificateUiProps _getUiProps(ComplianceType type) {
switch (type) {
case ComplianceType.backgroundCheck:
_CertificateUiProps _getUiProps(String type) {
switch (type.toUpperCase()) {
case 'BACKGROUND_CHECK':
return _CertificateUiProps(UiIcons.fileCheck, UiColors.primary);
case ComplianceType.foodHandler:
case 'FOOD_HYGIENE':
case 'FOOD_HANDLER':
return _CertificateUiProps(UiIcons.utensils, UiColors.primary);
case ComplianceType.rbs:
case 'RBS':
return _CertificateUiProps(UiIcons.wine, UiColors.foreground);
default:
return _CertificateUiProps(UiIcons.award, UiColors.primary);

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(StaffEndpoints.documents);
final List<dynamic> items = response.data['items'] as List<dynamic>? ?? <dynamic>[];
return items
.map((dynamic json) =>
ProfileDocument.fromJson(json as Map<String, dynamic>))
.toList();
}
@override
Future<domain.StaffDocument> uploadDocument(
Future<ProfileDocument> uploadDocument(
String documentId,
String filePath,
) async {
return _service.run(() async {
// 1. Upload the file to cloud storage
final FileUploadResponse uploadRes = await _uploadService.uploadFile(
filePath: filePath,
fileName: 'staff_document_${DateTime.now().millisecondsSinceEpoch}.pdf',
visibility: domain.FileVisibility.private,
);
// 1. Upload the file to cloud storage
final FileUploadResponse uploadRes = await _uploadService.uploadFile(
filePath: filePath,
fileName:
'staff_document_${DateTime.now().millisecondsSinceEpoch}.pdf',
visibility: FileVisibility.private,
);
// 2. Generate a signed URL for verification service to access the file
final SignedUrlResponse signedUrlRes = await _signedUrlService
.createSignedUrl(fileUri: uploadRes.fileUri);
// 2. Generate a signed URL
final SignedUrlResponse signedUrlRes =
await _signedUrlService.createSignedUrl(fileUri: uploadRes.fileUri);
// 3. Initiate verification
final List<domain.StaffDocument> allDocs = await getDocuments();
final domain.StaffDocument currentDoc = allDocs.firstWhere(
(domain.StaffDocument d) => d.documentId == documentId,
);
final String description = (currentDoc.description ?? '').toLowerCase();
// 3. Initiate verification
final VerificationResponse verificationRes =
await _verificationService.createVerification(
fileUri: uploadRes.fileUri,
type: 'government_id',
subjectType: 'worker',
subjectId: documentId,
rules: <String, dynamic>{'documentId': documentId},
);
final String staffId = await _service.getStaffId();
final VerificationResponse verificationRes = await _verificationService
.createVerification(
fileUri: uploadRes.fileUri,
type: 'government_id',
subjectType: 'worker',
subjectId: staffId,
rules: <String, dynamic>{
'documentDescription': currentDoc.description,
},
);
// 4. Submit upload result to V2 API
await _api.put(
StaffEndpoints.documentUpload(documentId),
data: <String, dynamic>{
'fileUri': signedUrlRes.signedUrl,
'verificationId': verificationRes.verificationId,
},
);
// 4. Update/Create StaffDocument in Data Connect
await _service.getStaffRepository().upsertStaffDocument(
documentId: documentId,
documentUrl: signedUrlRes.signedUrl,
status: domain.DocumentStatus.pending,
verificationId: verificationRes.verificationId,
);
// 5. Return the updated document state
final List<domain.StaffDocument> documents = await getDocuments();
return documents.firstWhere(
(domain.StaffDocument d) => d.documentId == documentId,
);
});
}
domain.DocumentStatus _mapStatus(EnumValue<DocumentStatus> status) {
if (status is Known<DocumentStatus>) {
switch (status.value) {
case DocumentStatus.VERIFIED:
case DocumentStatus.AUTO_PASS:
case DocumentStatus.APPROVED:
return domain.DocumentStatus.verified;
case DocumentStatus.PENDING:
case DocumentStatus.UPLOADED:
case DocumentStatus.PROCESSING:
case DocumentStatus.NEEDS_REVIEW:
case DocumentStatus.EXPIRING:
return domain.DocumentStatus.pending;
case DocumentStatus.MISSING:
return domain.DocumentStatus.missing;
case DocumentStatus.AUTO_FAIL:
case DocumentStatus.REJECTED:
case DocumentStatus.ERROR:
return domain.DocumentStatus.rejected;
}
}
// Default to pending for Unknown or unhandled cases
return domain.DocumentStatus.pending;
// 5. Return the updated document
final List<ProfileDocument> documents = await getDocuments();
return documents.firstWhere(
(ProfileDocument d) => d.documentId == documentId,
);
}
}

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 Semantics(
identifier: 'staff_document_upload',
label: isVerified
@@ -160,6 +149,7 @@ class DocumentCard extends StatelessWidget {
child: InkWell(
onTap: onTap,
borderRadius: UiConstants.radiusSm,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Row(

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(StaffEndpoints.taxForms);
final List<dynamic> items = response.data['items'] as List<dynamic>? ?? <dynamic>[];
return items
.map((dynamic json) =>
TaxForm.fromJson(json as Map<String, dynamic>))
.toList();
}
@override
Future<void> updateI9Form(I9TaxForm form) async {
return _service.run(() async {
final Map<String, dynamic> data = form.formData;
final dc.UpdateTaxFormVariablesBuilder builder = _service.connector
.updateTaxForm(id: form.id);
_mapCommonFields(builder, data);
_mapI9Fields(builder, data);
await builder.execute();
});
Future<void> updateTaxForm(TaxForm form) async {
await _api.put(
StaffEndpoints.taxFormUpdate(form.formType),
data: form.toJson(),
);
}
@override
Future<void> submitI9Form(I9TaxForm form) async {
return _service.run(() async {
final Map<String, dynamic> data = form.formData;
final dc.UpdateTaxFormVariablesBuilder builder = _service.connector
.updateTaxForm(id: form.id);
_mapCommonFields(builder, data);
_mapI9Fields(builder, data);
await builder.status(dc.TaxFormStatus.SUBMITTED).execute();
});
}
@override
Future<void> updateW4Form(W4TaxForm form) async {
return _service.run(() async {
final Map<String, dynamic> data = form.formData;
final dc.UpdateTaxFormVariablesBuilder builder = _service.connector
.updateTaxForm(id: form.id);
_mapCommonFields(builder, data);
_mapW4Fields(builder, data);
await builder.execute();
});
}
@override
Future<void> submitW4Form(W4TaxForm form) async {
return _service.run(() async {
final Map<String, dynamic> data = form.formData;
final dc.UpdateTaxFormVariablesBuilder builder = _service.connector
.updateTaxForm(id: form.id);
_mapCommonFields(builder, data);
_mapW4Fields(builder, data);
await builder.status(dc.TaxFormStatus.SUBMITTED).execute();
});
}
void _mapCommonFields(
dc.UpdateTaxFormVariablesBuilder builder,
Map<String, dynamic> data,
) {
if (data.containsKey('firstName')) {
builder.firstName(data['firstName'] as String?);
}
if (data.containsKey('lastName')) {
builder.lastName(data['lastName'] as String?);
}
if (data.containsKey('middleInitial')) {
builder.mInitial(data['middleInitial'] as String?);
}
if (data.containsKey('otherLastNames')) {
builder.oLastName(data['otherLastNames'] as String?);
}
if (data.containsKey('dob')) {
final String dob = data['dob'] as String;
// Handle both ISO string and MM/dd/yyyy manual entry
DateTime? date;
try {
date = DateTime.parse(dob);
} catch (_) {
try {
// Fallback minimal parse for mm/dd/yyyy
final List<String> parts = dob.split('/');
if (parts.length == 3) {
date = DateTime(
int.parse(parts[2]),
int.parse(parts[0]),
int.parse(parts[1]),
);
}
} catch (_) {}
}
if (date != null) {
final int ms = date.millisecondsSinceEpoch;
final int seconds = (ms / 1000).floor();
builder.dob(Timestamp(0, seconds));
}
}
if (data.containsKey('ssn') && data['ssn']?.toString().isNotEmpty == true) {
builder.socialSN(
int.tryParse(data['ssn'].toString().replaceAll(RegExp(r'\D'), '')) ?? 0,
);
}
if (data.containsKey('email')) builder.email(data['email'] as String?);
if (data.containsKey('phone')) builder.phone(data['phone'] as String?);
if (data.containsKey('address')) {
builder.address(data['address'] as String?);
}
if (data.containsKey('aptNumber')) {
builder.apt(data['aptNumber'] as String?);
}
if (data.containsKey('city')) builder.city(data['city'] as String?);
if (data.containsKey('state')) builder.state(data['state'] as String?);
if (data.containsKey('zipCode')) {
builder.zipCode(data['zipCode'] as String?);
}
}
void _mapI9Fields(
dc.UpdateTaxFormVariablesBuilder builder,
Map<String, dynamic> data,
) {
if (data.containsKey('citizenshipStatus')) {
final String status = data['citizenshipStatus'] as String;
// Map string to enum if possible, or handle otherwise.
// Generated enum: CITIZEN, NONCITIZEN_NATIONAL, PERMANENT_RESIDENT, ALIEN_AUTHORIZED
try {
builder.citizen(
dc.CitizenshipStatus.values.byName(status.toUpperCase()),
);
} catch (_) {}
}
if (data.containsKey('uscisNumber')) {
builder.uscis(data['uscisNumber'] as String?);
}
if (data.containsKey('passportNumber')) {
builder.passportNumber(data['passportNumber'] as String?);
}
if (data.containsKey('countryIssuance')) {
builder.countryIssue(data['countryIssuance'] as String?);
}
if (data.containsKey('preparerUsed')) {
builder.prepartorOrTranslator(data['preparerUsed'] as bool?);
}
if (data.containsKey('signature')) {
builder.signature(data['signature'] as String?);
}
// Note: admissionNumber not in builder based on file read
}
void _mapW4Fields(
dc.UpdateTaxFormVariablesBuilder builder,
Map<String, dynamic> data,
) {
if (data.containsKey('cityStateZip')) {
final String csz = data['cityStateZip'] as String;
// Extremely basic split: City, State Zip
final List<String> parts = csz.split(',');
if (parts.length >= 2) {
builder.city(parts[0].trim());
final String stateZip = parts[1].trim();
final List<String> szParts = stateZip.split(' ');
if (szParts.isNotEmpty) builder.state(szParts[0]);
if (szParts.length > 1) builder.zipCode(szParts.last);
}
}
if (data.containsKey('filingStatus')) {
// MARITIAL_STATUS_SINGLE, MARITIAL_STATUS_MARRIED, MARITIAL_STATUS_HEAD
try {
final String status = data['filingStatus'] as String;
// Simple mapping assumptions:
if (status.contains('single')) {
builder.marital(dc.MaritalStatus.SINGLE);
} else if (status.contains('married')) {
builder.marital(dc.MaritalStatus.MARRIED);
} else if (status.contains('head')) {
builder.marital(dc.MaritalStatus.HEAD);
}
} catch (_) {}
}
if (data.containsKey('multipleJobs')) {
builder.multipleJob(data['multipleJobs'] as bool?);
}
if (data.containsKey('qualifyingChildren')) {
builder.childrens(data['qualifyingChildren'] as int?);
}
if (data.containsKey('otherDependents')) {
builder.otherDeps(data['otherDependents'] as int?);
}
if (data.containsKey('otherIncome')) {
builder.otherInconme(double.tryParse(data['otherIncome'].toString()));
}
if (data.containsKey('deductions')) {
builder.deductions(double.tryParse(data['deductions'].toString()));
}
if (data.containsKey('extraWithholding')) {
builder.extraWithholding(
double.tryParse(data['extraWithholding'].toString()),
);
}
if (data.containsKey('signature')) {
builder.signature(data['signature'] as String?);
}
Future<void> submitTaxForm(TaxForm form) async {
await _api.post(
StaffEndpoints.taxFormSubmit(form.formType),
data: form.toJson(),
);
}
}

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