feat: add staff certificates feature with pubspec configuration

- Created pubspec.yaml for the staff_certificates feature with dependencies including flutter_bloc, equatable, get_it, and flutter_modular.
- Established paths for KROW dependencies: design_system, core_localization, krow_domain, krow_core, and krow_data_connect.
- Added firebase_auth and firebase_data_connect as dependencies.
- Generated pubspec.lock file to lock dependency versions.
This commit is contained in:
Achintha Isuru
2026-01-25 10:28:43 -05:00
parent b3e156ebbe
commit 9dae80e66e
24 changed files with 2383 additions and 15 deletions

View File

@@ -593,12 +593,52 @@
"error": "Error: $message" "error": "Error: $message"
}, },
"card": { "card": {
"view": "View", "view": "View",
"upload": "Upload", "upload": "Upload",
"verified": "Verified", "verified": "Verified",
"pending": "Pending", "pending": "Pending",
"missing": "Missing", "missing": "Missing",
"rejected": "Rejected" "rejected": "Rejected"
}
},
"staff_certificates": {
"title": "Certificates",
"progress": {
"title": "Your Progress",
"verified_count": "$completed of $total verified",
"active": "Compliance Active"
},
"card": {
"expires_in_days": "Expires in $days days - Renew now",
"expired": "Expired - Renew now",
"verified": "Verified",
"expiring_soon": "Expiring Soon",
"exp": "Exp: $date",
"upload_button": "Upload Certificate",
"edit_expiry": "Edit Expiration Date",
"remove": "Remove Certificate",
"renew": "Renew",
"opened_snackbar": "Certificate opened in new tab"
},
"add_more": {
"title": "Add Another Certificate",
"subtitle": "Upload additional certifications"
},
"upload_modal": {
"title": "Upload Certificate",
"expiry_label": "Expiration Date (Optional)",
"select_date": "Select date",
"upload_file": "Upload File",
"drag_drop": "Drag and drop or click to upload",
"supported_formats": "PDF, JPG, PNG up to 10MB",
"cancel": "Cancel",
"save": "Save Certificate"
},
"delete_modal": {
"title": "Remove Certificate?",
"message": "This action cannot be undone.",
"cancel": "Cancel",
"confirm": "Remove"
} }
} }
} }

View File

@@ -599,5 +599,45 @@
"missing": "Missing", "missing": "Missing",
"rejected": "Rejected" "rejected": "Rejected"
} }
},
"staff_certificates": {
"title": "Certificates",
"progress": {
"title": "Your Progress",
"verified_count": "$completed of $total verified",
"active": "Compliance Active"
},
"card": {
"expires_in_days": "Expires in $days days - Renew now",
"expired": "Expired - Renew now",
"verified": "Verified",
"expiring_soon": "Expiring Soon",
"exp": "Exp: $date",
"upload_button": "Upload Certificate",
"edit_expiry": "Edit Expiration Date",
"remove": "Remove Certificate",
"renew": "Renew",
"opened_snackbar": "Certificate opened in new tab"
},
"add_more": {
"title": "Add Another Certificate",
"subtitle": "Upload additional certifications"
},
"upload_modal": {
"title": "Upload Certificate",
"expiry_label": "Expiration Date (Optional)",
"select_date": "Select date",
"upload_file": "Upload File",
"drag_drop": "Drag and drop or click to upload",
"supported_formats": "PDF, JPG, PNG up to 10MB",
"cancel": "Cancel",
"save": "Save Certificate"
},
"delete_modal": {
"title": "Remove Certificate?",
"message": "This action cannot be undone.",
"cancel": "Cancel",
"confirm": "Remove"
}
} }
} }

View File

@@ -201,4 +201,22 @@ class UiIcons {
/// Download icon /// Download icon
static const IconData download = _IconLib.download; static const IconData download = _IconLib.download;
/// Upload icon
static const IconData upload = _IconLib.upload;
/// Upload Cloud icon
static const IconData uploadCloud = _IconLib.uploadCloud;
/// File Check icon
static const IconData fileCheck = _IconLib.fileCheck;
/// Utensils icon
static const IconData utensils = _IconLib.utensils;
/// Wine icon
static const IconData wine = _IconLib.wine;
/// Award icon
static const IconData award = _IconLib.award;
} }

View File

@@ -38,17 +38,12 @@ class AuthRepositoryImpl implements AuthRepositoryInterface {
Future<String?> signInWithPhone({required String phoneNumber}) async { Future<String?> signInWithPhone({required String phoneNumber}) async {
final Completer<String?> completer = Completer<String?>(); final Completer<String?> completer = Completer<String?>();
print('Starting phone number verification for $phoneNumber');
await firebaseAuth.verifyPhoneNumber( await firebaseAuth.verifyPhoneNumber(
phoneNumber: phoneNumber, phoneNumber: phoneNumber,
verificationCompleted: (_) { verificationCompleted: (_) {
print('Phone verification completed automatically.');
print(phoneNumber);
}, },
verificationFailed: (FirebaseAuthException e) { verificationFailed: (FirebaseAuthException e) {
if (!completer.isCompleted) { if (!completer.isCompleted) {
print('Phone verification failed: ${e.message}');
completer.completeError( completer.completeError(
Exception(e.message ?? 'Phone verification failed.'), Exception(e.message ?? 'Phone verification failed.'),
); );

View File

@@ -0,0 +1 @@
include: ../../../../../../analysis_options.yaml

View File

@@ -0,0 +1,43 @@
import 'package:krow_domain/krow_domain.dart';
import '../../domain/repositories/certificates_repository.dart';
class CertificatesRepositoryMock implements CertificatesRepository {
@override
Future<List<StaffDocument>> getCertificates() async {
final DateTime now = DateTime.now();
// Create copies with dynamic dates
final List<StaffDocument> dynamicDocuments = <StaffDocument>[
StaffDocument(
id: '1',
documentId: 'background',
staffId: 'current_user',
name: 'Background Check',
description: 'Required for all shifts',
status: DocumentStatus.verified,
expiryDate: now.add(const Duration(days: 365)),
),
StaffDocument(
id: '2',
documentId: 'food_handler',
staffId: 'current_user',
name: 'Food Handler',
description: 'Required for food service',
status: DocumentStatus.verified,
expiryDate: now.add(const Duration(days: 15)),
),
const StaffDocument(
id: '3',
documentId: 'rbs',
staffId: 'current_user',
name: 'RBS Alcohol',
description: 'Required for bar shifts',
status: DocumentStatus.missing,
expiryDate: null,
),
];
await Future<void>.delayed(const Duration(seconds: 1)); // Simulate network delay
return dynamicDocuments;
}
}

View File

@@ -0,0 +1,91 @@
import 'package:firebase_auth/firebase_auth.dart';
import 'package:firebase_data_connect/firebase_data_connect.dart';
import 'package:krow_data_connect/krow_data_connect.dart';
import 'package:krow_domain/krow_domain.dart' as domain;
import '../../domain/repositories/certificates_repository.dart';
/// Implementation of [CertificatesRepository] using Data Connect.
///
/// This class handles the communication with the backend via [ExampleConnector].
/// It maps raw generated data types to clean [domain.StaffDocument] entities.
class CertificatesRepositoryImpl implements CertificatesRepository {
/// The generated Data Connect SDK client.
final ExampleConnector _dataConnect;
/// The Firebase Authentication instance.
final FirebaseAuth _firebaseAuth;
/// Creates a [CertificatesRepositoryImpl].
///
/// Requires [ExampleConnector] for data access and [FirebaseAuth] for user context.
CertificatesRepositoryImpl({
required ExampleConnector dataConnect,
required FirebaseAuth firebaseAuth,
}) : _dataConnect = dataConnect,
_firebaseAuth = firebaseAuth;
@override
Future<List<domain.StaffDocument>> getCertificates() async {
final User? currentUser = _firebaseAuth.currentUser;
if (currentUser == null) {
throw Exception('User not authenticated');
}
try {
// Execute the query via DataConnect generated SDK
final QueryResult<ListStaffDocumentsByStaffIdData,
ListStaffDocumentsByStaffIdVariables> result =
await _dataConnect
.listStaffDocumentsByStaffId(staffId: currentUser.uid)
.execute();
// Map the generated SDK types to pure Domain entities
return result.data.staffDocuments
.map((ListStaffDocumentsByStaffIdStaffDocuments doc) =>
_mapToDomain(doc))
.toList();
} catch (e) {
// In a real app, we would map specific exceptions to domain Failures here.
throw Exception('Failed to fetch certificates: $e');
}
}
/// Maps the Data Connect [ListStaffDocumentsByStaffIdStaffDocuments] to a domain [domain.StaffDocument].
domain.StaffDocument _mapToDomain(
ListStaffDocumentsByStaffIdStaffDocuments doc,
) {
return domain.StaffDocument(
id: doc.id,
staffId: doc.staffId,
documentId: doc.documentId,
name: doc.document.name,
description: null, // Description not available in this query response
status: _mapStatus(doc.status),
documentUrl: doc.documentUrl,
expiryDate: doc.expiryDate?.toDateTime(),
);
}
/// Maps the Data Connect [DocumentStatus] enum to the domain [domain.DocumentStatus].
domain.DocumentStatus _mapStatus(EnumValue<DocumentStatus> status) {
if (status is Known<DocumentStatus>) {
switch (status.value) {
case DocumentStatus.VERIFIED:
return domain.DocumentStatus.verified;
case DocumentStatus.PENDING:
return domain.DocumentStatus.pending;
case DocumentStatus.MISSING:
return domain.DocumentStatus.missing;
case DocumentStatus.UPLOADED:
return domain.DocumentStatus.pending;
case DocumentStatus.EXPIRING:
// 'EXPIRING' in backend is treated as 'verified' in domain,
// as the document is strictly valid until the expiry date.
return domain.DocumentStatus.verified;
}
}
// Fallback for unknown status
return domain.DocumentStatus.pending;
}
}

View File

@@ -0,0 +1,12 @@
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.
abstract interface class CertificatesRepository {
/// Fetches the list of compliance certificates for the current staff member.
///
/// Returns a list of [StaffDocument] entities.
Future<List<StaffDocument>> getCertificates();
}

View File

@@ -0,0 +1,21 @@
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import '../repositories/certificates_repository.dart';
/// Use case for fetching staff compliance certificates.
///
/// Delegates the data retrieval to the [CertificatesRepository].
/// Follows the strict one-to-one mapping between action and use case.
class GetCertificatesUseCase extends NoInputUseCase<List<StaffDocument>> {
final CertificatesRepository _repository;
/// Creates a [GetCertificatesUseCase].
///
/// Requires a [CertificatesRepository] to access the certificates data source.
GetCertificatesUseCase(this._repository);
@override
Future<List<StaffDocument>> call() {
return _repository.getCertificates();
}
}

View File

@@ -0,0 +1,28 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:krow_domain/krow_domain.dart';
import '../../../domain/usecases/get_certificates_usecase.dart';
import 'certificates_state.dart';
class CertificatesCubit extends Cubit<CertificatesState> {
final GetCertificatesUseCase _getCertificatesUseCase;
CertificatesCubit(this._getCertificatesUseCase) : super(const CertificatesState()) {
loadCertificates();
}
Future<void> loadCertificates() async {
emit(state.copyWith(status: CertificatesStatus.loading));
try {
final List<StaffDocument> certificates = await _getCertificatesUseCase();
emit(state.copyWith(
status: CertificatesStatus.success,
certificates: certificates,
));
} catch (e) {
emit(state.copyWith(
status: CertificatesStatus.failure,
errorMessage: e.toString(),
));
}
}
}

View File

@@ -0,0 +1,39 @@
import 'package:equatable/equatable.dart';
import 'package:krow_domain/krow_domain.dart';
enum CertificatesStatus { initial, loading, success, failure }
class CertificatesState extends Equatable {
final CertificatesStatus status;
final List<StaffDocument> certificates;
final String? errorMessage;
const CertificatesState({
this.status = CertificatesStatus.initial,
List<StaffDocument>? certificates,
this.errorMessage,
}) : certificates = certificates ?? const <StaffDocument>[];
CertificatesState copyWith({
CertificatesStatus? status,
List<StaffDocument>? certificates,
String? errorMessage,
}) {
return CertificatesState(
status: status ?? this.status,
certificates: certificates ?? this.certificates,
errorMessage: errorMessage ?? this.errorMessage,
);
}
@override
List<Object?> get props => [status, certificates, errorMessage];
/// The number of verified certificates.
int get completedCount => certificates
.where((doc) => doc.status == DocumentStatus.verified)
.length;
/// The total number of certificates.
int get totalCount => certificates.length;
}

View File

@@ -0,0 +1,10 @@
import 'package:flutter_modular/flutter_modular.dart';
/// Extension on [IModularNavigator] to provide strongly-typed navigation
/// for the staff certificates feature.
extension CertificatesNavigator on IModularNavigator {
/// Navigates back.
void popCertificates() {
pop();
}
}

View File

@@ -0,0 +1,135 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_domain/krow_domain.dart';
import 'package:core_localization/core_localization.dart';
import '../blocs/certificates/certificates_cubit.dart';
import '../blocs/certificates/certificates_state.dart';
import '../widgets/add_certificate_card.dart';
import '../widgets/certificate_card.dart';
import '../widgets/certificate_upload_modal.dart';
import '../widgets/certificates_header.dart';
/// Page for viewing and managing staff certificates.
///
/// Refactored to be stateless and follow clean architecture.
class CertificatesPage extends StatelessWidget {
const CertificatesPage({super.key});
@override
Widget build(BuildContext context) {
// Dependency Injection: Retrieve the Cubit
final CertificatesCubit cubit = Modular.get<CertificatesCubit>();
return BlocBuilder<CertificatesCubit, CertificatesState>(
bloc: cubit,
builder: (BuildContext context, CertificatesState state) {
if (state.status == CertificatesStatus.loading ||
state.status == CertificatesStatus.initial) {
return const Scaffold(
body: Center(child: CircularProgressIndicator()),
);
}
if (state.status == CertificatesStatus.failure) {
return Scaffold(
body: Center(child: Text('Error: ${state.errorMessage}')),
);
}
final List<StaffDocument> documents = state.certificates;
return Scaffold(
backgroundColor: UiColors.background, // Matches 0xFFF8FAFC
body: SingleChildScrollView(
child: Column(
children: <Widget>[
CertificatesHeader(
completedCount: state.completedCount,
totalCount: state.totalCount,
),
Transform.translate(
offset: const Offset(0, -48),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Column(
children: <Widget>[
...documents.map((StaffDocument doc) => CertificateCard(
document: doc,
onUpload: () => _showUploadModal(context, doc),
onEditExpiry: () => _showEditExpiryDialog(context, doc),
onRemove: () => _showRemoveConfirmation(context, doc),
onView: () {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(t.staff_certificates.card.opened_snackbar),
duration: const Duration(seconds: 2),
),
);
},
)),
const SizedBox(height: 16),
AddCertificateCard(
onTap: () => _showUploadModal(context, null),
),
const SizedBox(height: 32),
],
),
),
),
],
),
),
);
},
);
}
void _showUploadModal(BuildContext context, StaffDocument? document) {
showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (BuildContext context) => CertificateUploadModal(
document: document,
onSave: () {
// TODO: Implement upload via Cubit
// Modular.get<CertificatesCubit>().uploadCertificate(...);
Navigator.pop(context);
},
onCancel: () => Navigator.pop(context),
),
);
}
void _showEditExpiryDialog(BuildContext context, StaffDocument document) {
_showUploadModal(context, document);
}
void _showRemoveConfirmation(BuildContext context, StaffDocument document) {
showDialog<void>(
context: context,
builder: (BuildContext context) => AlertDialog(
title: Text(t.staff_certificates.delete_modal.title),
content: Text(t.staff_certificates.delete_modal.message),
actions: <Widget>[
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(t.staff_certificates.delete_modal.cancel),
),
TextButton(
onPressed: () {
// TODO: Implement delete via Cubit
// Modular.get<CertificatesCubit>().deleteCertificate(document.id);
Navigator.pop(context);
},
style: TextButton.styleFrom(foregroundColor: UiColors.destructive),
child: Text(t.staff_certificates.delete_modal.confirm),
),
],
),
);
}
}

View File

@@ -0,0 +1,54 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:core_localization/core_localization.dart';
class AddCertificateCard extends StatelessWidget {
final VoidCallback onTap;
const AddCertificateCard({super.key, required this.onTap});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: <Color>[Colors.grey[50]!, Colors.grey[100]!], // Keep prototype style
),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: Colors.grey[300]!,
style: BorderStyle.solid,
),
),
child: Row(
children: <Widget>[
const Icon(UiIcons.add, color: UiColors.primary, size: 24),
const SizedBox(width: 16),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
t.staff_certificates.add_more.title,
style: UiTypography.body1b.copyWith( // 16px Bold
color: UiColors.textPrimary,
),
),
Text(
t.staff_certificates.add_more.subtitle,
style: UiTypography.body3r.copyWith( // 12px Regular
color: UiColors.textSecondary,
),
),
],
),
],
),
),
);
}
}

View File

@@ -0,0 +1,417 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:krow_domain/krow_domain.dart';
import 'package:core_localization/core_localization.dart';
class CertificateCard extends StatelessWidget {
final StaffDocument document;
final VoidCallback? onUpload;
final VoidCallback? onEditExpiry;
final VoidCallback? onRemove;
final VoidCallback? onView;
const CertificateCard({
super.key,
required this.document,
this.onUpload,
this.onEditExpiry,
this.onRemove,
this.onView,
});
@override
Widget build(BuildContext context) {
// Determine UI state from document
final bool isComplete = document.status == DocumentStatus.verified;
// Todo: Better logic for expring. Assuming if expiryDate is close.
// Prototype used 'EXPIRING' status. We map this logic:
final bool isExpiring = _isExpiring(document.expiryDate);
final bool isExpired = _isExpired(document.expiryDate);
// Override isComplete if expiring or expired
final bool showComplete = isComplete && !isExpired && !isExpiring;
final bool isPending = document.status == DocumentStatus.pending;
final bool isNotStarted = document.status == DocumentStatus.missing || document.status == DocumentStatus.rejected;
// UI Properties helper
final _CertificateUiProps uiProps = _getUiProps(document.documentId);
return Container(
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: UiColors.white,
borderRadius: BorderRadius.circular(UiConstants.space4),
boxShadow: <BoxShadow>[
BoxShadow(
color: UiColors.black.withOpacity(0.05),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
border: Border.all(color: UiColors.border),
),
clipBehavior: Clip.hardEdge,
child: Column(
children: <Widget>[
if (isExpiring || isExpired)
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: const Color(0xFFF9E547).withOpacity(0.2), // Yellow tint
border: const Border(
bottom: BorderSide(color: Color(0x66F9E547)),
),
),
child: Row(
children: <Widget>[
const Icon(
UiIcons.warning,
size: 16,
color: UiColors.textPrimary,
),
const SizedBox(width: 8),
Text(
isExpired
? t.staff_certificates.card.expired
: t.staff_certificates.card.expires_in_days(days: _daysUntilExpiry(document.expiryDate)),
style: UiTypography.body3m.copyWith( // 12px Medium
color: UiColors.textPrimary,
),
),
],
),
),
Padding(
padding: const EdgeInsets.all(20),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Stack(
clipBehavior: Clip.none,
children: <Widget>[
Container(
width: 64,
height: 64,
decoration: BoxDecoration(
color: uiProps.color.withOpacity(0.1),
borderRadius: BorderRadius.circular(16),
),
child: Center(
child: Icon(
uiProps.icon,
color: uiProps.color,
size: 28,
),
),
),
if (showComplete)
const Positioned(
bottom: -4,
right: -4,
child: CircleAvatar(
radius: 12,
backgroundColor: UiColors.primary,
child: Icon(
UiIcons.success,
color: UiColors.white,
size: 16,
),
),
),
if (isPending)
const Positioned(
bottom: -4,
right: -4,
child: CircleAvatar(
radius: 12,
backgroundColor: UiColors.textPrimary,
child: Icon(
UiIcons.clock,
color: UiColors.white,
size: 16,
),
),
),
],
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
document.name,
style: UiTypography.body1m.copyWith( // 16px Medium
color: UiColors.textPrimary,
),
),
const SizedBox(height: 2),
Text(
document.description ?? '', // Optional description
style: UiTypography.body3r.copyWith( // 12px Regular
color: UiColors.textSecondary,
),
),
],
),
),
const Icon(
UiIcons.chevronRight,
color: UiColors.textSecondary,
size: 20,
),
],
),
const SizedBox(height: 16),
if (showComplete) _buildCompleteStatus(document.expiryDate),
if (isExpiring || isExpired) _buildExpiringStatus(context, document.expiryDate),
if (isNotStarted)
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: onUpload,
style: ElevatedButton.styleFrom(
backgroundColor: UiColors.primary,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
padding: const EdgeInsets.symmetric(vertical: 12),
elevation: 0,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const Icon(
UiIcons.upload,
size: 16,
color: UiColors.white,
),
const SizedBox(width: 8),
Text(
t.staff_certificates.card.upload_button,
style: UiTypography.body2m.copyWith( // 14px Medium
color: UiColors.white,
),
),
],
),
),
),
if (showComplete || isExpiring || isExpired) ...<Widget>[
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: onEditExpiry,
icon: const Icon(UiIcons.edit, size: 16),
label: Text(t.staff_certificates.card.edit_expiry),
style: OutlinedButton.styleFrom(
foregroundColor: UiColors.textPrimary,
side: const BorderSide(color: UiColors.border),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
padding: const EdgeInsets.symmetric(vertical: 12),
),
),
),
const SizedBox(height: 8),
SizedBox(
width: double.infinity,
child: TextButton.icon(
onPressed: onRemove,
icon: const Icon(UiIcons.delete, size: 16),
label: Text(t.staff_certificates.card.remove),
style: TextButton.styleFrom(
foregroundColor: UiColors.destructive,
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
),
],
],
),
),
],
),
),
],
),
);
}
Widget _buildCompleteStatus(DateTime? expiryDate) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
Row(
children: <Widget>[
Container(
width: 8,
height: 8,
decoration: const BoxDecoration(
color: UiColors.primary,
shape: BoxShape.circle,
),
),
const SizedBox(width: 8),
Text(
t.staff_certificates.card.verified,
style: UiTypography.body2m.copyWith(
color: UiColors.primary,
),
),
],
),
if (expiryDate != null)
Text(
t.staff_certificates.card.exp(date: DateFormat('MMM d, yyyy').format(expiryDate)),
style: UiTypography.body3r.copyWith(color: UiColors.textSecondary),
),
],
);
}
Widget _buildExpiringStatus(BuildContext context, DateTime? expiryDate) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Row(
children: <Widget>[
Container(
width: 8,
height: 8,
decoration: const BoxDecoration(
color: UiColors.primary,
shape: BoxShape.circle,
),
),
const SizedBox(width: 8),
Text(
t.staff_certificates.card.expiring_soon,
style: UiTypography.body2m.copyWith(
color: UiColors.primary,
),
),
],
),
if (expiryDate != null)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
t.staff_certificates.card.exp(date: DateFormat('MMM d, yyyy').format(expiryDate)),
style: UiTypography.body3r.copyWith(
color: UiColors.textSecondary,
),
),
),
],
),
Row(
children: <Widget>[
_buildIconButton(UiIcons.eye, onView),
const SizedBox(width: 8),
_buildSmallOutlineButton(
t.staff_certificates.card.renew,
onUpload,
),
],
),
],
);
}
Widget _buildIconButton(IconData icon, VoidCallback? onTap) {
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(16),
child: Container(
width: 32,
height: 32,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.transparent,
border: Border.all(
color: Colors.transparent,
),
),
child: Center(
child: Icon(icon, size: 16, color: UiColors.textSecondary),
),
),
);
}
Widget _buildSmallOutlineButton(String label, VoidCallback? onTap) {
return OutlinedButton(
onPressed: onTap,
style: OutlinedButton.styleFrom(
side: const BorderSide(color: Color(0x660A39DF)), // Primary with opacity
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 0),
minimumSize: const Size(0, 32),
),
child: Text(
label,
style: UiTypography.body3m.copyWith(color: UiColors.primary),
),
);
}
bool _isExpiring(DateTime? expiry) {
if (expiry == null) return false;
final int days = expiry.difference(DateTime.now()).inDays;
return days >= 0 && days <= 30; // Close to expiry but not expired
}
bool _isExpired(DateTime? expiry) {
if (expiry == null) return false;
return expiry.difference(DateTime.now()).inDays < 0;
}
int _daysUntilExpiry(DateTime? expiry) {
if (expiry == null) return 0;
return expiry.difference(DateTime.now()).inDays;
}
// Mock mapping for UI props based on ID
_CertificateUiProps _getUiProps(String id) {
switch (id) {
case 'background':
return _CertificateUiProps(UiIcons.fileCheck, const Color(0xFF0A39DF));
case 'food_handler':
return _CertificateUiProps(UiIcons.utensils, const Color(0xFF0A39DF));
case 'rbs':
return _CertificateUiProps(UiIcons.wine, const Color(0xFF121826));
default:
// Default generic icon
return _CertificateUiProps(UiIcons.award, UiColors.primary);
}
}
}
class _CertificateUiProps {
final IconData icon;
final Color color;
_CertificateUiProps(this.icon, this.color);
}

View File

@@ -0,0 +1,158 @@
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
/// Modal for uploading or editing a certificate expiry.
class CertificateUploadModal extends StatelessWidget {
/// The document being edited, or null for a new upload.
// ignore: unused_field
final dynamic document; // Using dynamic for now as we don't import domain here to avoid direct coupling if possible, but actually we should import domain.
// Ideally, widgets should be dumb. Let's import domain.
final VoidCallback onSave;
final VoidCallback onCancel;
const CertificateUploadModal({
super.key,
this.document,
required this.onSave,
required this.onCancel,
});
@override
Widget build(BuildContext context) {
return Container(
height: MediaQuery.of(context).size.height * 0.75,
decoration: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.only(
topLeft: Radius.circular(24),
topRight: Radius.circular(24),
),
),
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
Text(
t.staff_certificates.upload_modal.title,
style: UiTypography.headline3m.copyWith(color: UiColors.textPrimary),
),
IconButton(
onPressed: onCancel,
icon: const Icon(UiIcons.close, size: 24),
),
],
),
const SizedBox(height: 32),
Text(
t.staff_certificates.upload_modal.expiry_label,
style: UiTypography.body1m,
),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
border: Border.all(color: UiColors.border),
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: <Widget>[
const Icon(UiIcons.calendar, size: 20, color: UiColors.textSecondary),
const SizedBox(width: 12),
Text(
t.staff_certificates.upload_modal.select_date,
style: UiTypography.body1m.copyWith(color: UiColors.textSecondary),
),
],
),
),
const SizedBox(height: 24),
Text(
t.staff_certificates.upload_modal.upload_file,
style: UiTypography.body1m,
),
const SizedBox(height: 8),
Expanded(
child: Container(
width: double.infinity,
decoration: BoxDecoration(
border: Border.all(
color: UiColors.border,
style: BorderStyle.solid,
),
borderRadius: BorderRadius.circular(16),
color: UiColors.background,
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Container(
padding: const EdgeInsets.all(16),
decoration: const BoxDecoration(
color: Color(0xFFEFF6FF), // Light blue
shape: BoxShape.circle,
),
child: const Icon(
UiIcons.uploadCloud,
size: 32,
color: UiColors.primary,
),
),
const SizedBox(height: 16),
Text(
t.staff_certificates.upload_modal.drag_drop,
style: UiTypography.body1m,
),
const SizedBox(height: 4),
Text(
t.staff_certificates.upload_modal.supported_formats,
style: UiTypography.body3r.copyWith(color: UiColors.textSecondary),
),
],
),
),
),
const SizedBox(height: 24),
Row(
children: <Widget>[
Expanded(
child: OutlinedButton(
onPressed: onCancel,
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
side: const BorderSide(color: UiColors.border),
),
child: Text(t.staff_certificates.upload_modal.cancel,
style: UiTypography.body1m.copyWith(color: UiColors.textPrimary)),
),
),
const SizedBox(width: 16),
Expanded(
child: ElevatedButton(
onPressed: onSave,
style: ElevatedButton.styleFrom(
backgroundColor: UiColors.primary,
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
elevation: 0,
),
child: Text(t.staff_certificates.upload_modal.save,
style: UiTypography.body1m.copyWith(color: Colors.white)),
),
),
],
),
],
),
);
}
}

View File

@@ -0,0 +1,131 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter_modular/flutter_modular.dart';
import 'package:core_localization/core_localization.dart';
class CertificatesHeader extends StatelessWidget {
final int completedCount;
final int totalCount;
const CertificatesHeader({
super.key,
required this.completedCount,
required this.totalCount,
});
@override
Widget build(BuildContext context) {
// Prevent division by zero
final double progressValue = totalCount == 0 ? 0 : completedCount / totalCount;
final int progressPercent = totalCount == 0 ? 0 : (progressValue * 100).round();
return Container(
padding: const EdgeInsets.fromLTRB(20, 60, 20, 80),
// Keeping gradient as per prototype layout requirement
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: <Color>[UiColors.primary, Color(0xFF1E40AF)], // Using Primary and a darker shade
),
),
child: Column(
children: <Widget>[
Row(
children: <Widget>[
GestureDetector(
onTap: () => Modular.to.pop(),
child: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: UiColors.white.withOpacity(0.1),
shape: BoxShape.circle,
),
child: const Icon(
UiIcons.chevronLeft, // Swapped LucideIcons
color: UiColors.white,
size: 20,
),
),
),
const SizedBox(width: 12),
Text(
t.staff_certificates.title,
style: UiTypography.headline3m.copyWith( // 18px Bold
color: UiColors.white,
),
),
],
),
const SizedBox(height: 32),
Row(
children: <Widget>[
SizedBox(
width: 96,
height: 96,
child: Stack(
fit: StackFit.expand,
children: <Widget>[
CircularProgressIndicator(
value: progressValue,
strokeWidth: 8,
backgroundColor: UiColors.white.withOpacity(0.2),
valueColor: const AlwaysStoppedAnimation<Color>(
Color(0xFFF9E547), // Yellow from prototype
),
),
Center(
child: Text(
'$progressPercent%',
style: UiTypography.display1b.copyWith( // 26px Bold
color: UiColors.white,
),
),
),
],
),
),
const SizedBox(width: 24),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
t.staff_certificates.progress.title,
style: UiTypography.body1b.copyWith( // 16px Bold
color: UiColors.white,
),
),
const SizedBox(height: 4),
Text(
t.staff_certificates.progress.verified_count(completed: completedCount, total: totalCount),
style: UiTypography.body3r.copyWith( // 12px Regular
color: UiColors.white.withOpacity(0.7),
),
),
const SizedBox(height: 8),
Row(
children: <Widget>[
const Icon(
UiIcons.shield,
color: Color(0xFFF9E547),
size: 16,
),
const SizedBox(width: 8),
Text(
t.staff_certificates.progress.active,
style: UiTypography.body3m.copyWith( // 12px Medium
color: const Color(0xFFF9E547),
),
),
],
),
],
),
],
),
],
),
);
}
}

View File

@@ -0,0 +1,28 @@
import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_data_connect/krow_data_connect.dart';
import 'data/repositories_impl/certificates_repository_impl.dart';
import 'domain/repositories/certificates_repository.dart';
import 'domain/usecases/get_certificates_usecase.dart';
import 'presentation/blocs/certificates/certificates_cubit.dart';
import 'presentation/pages/certificates_page.dart';
class StaffCertificatesModule extends Module {
@override
void binds(Injector i) {
i.addLazySingleton<CertificatesRepository>(
() => CertificatesRepositoryImpl(
dataConnect: i.get<ExampleConnector>(), // Assuming ExampleConnector is provided by parent module
firebaseAuth: FirebaseAuth.instance,
),
);
i.addLazySingleton(GetCertificatesUseCase.new);
i.addLazySingleton(CertificatesCubit.new);
}
@override
void routes(RouteManager r) {
r.child('/', child: (_) => const CertificatesPage());
}
}

View File

@@ -0,0 +1,4 @@
library staff_certificates;
export 'src/staff_certificates_module.dart';
export 'src/presentation/navigation/certificates_navigator.dart'; // Export navigator extension

View File

@@ -0,0 +1,35 @@
name: staff_certificates
description: Staff certificates feature
version: 0.0.1
publish_to: none
environment:
sdk: '>=3.0.0 <4.0.0'
dependencies:
flutter:
sdk: flutter
flutter_bloc: ^8.1.0
equatable: ^2.0.5
get_it: ^7.6.0
flutter_modular: ^6.3.0
# KROW Dependencies
design_system:
path: ../../../../../design_system
core_localization:
path: ../../../../../core_localization
krow_domain:
path: ../../../../../domain
krow_core:
path: ../../../../../core
krow_data_connect:
path: ../../../../../data_connect
firebase_auth: ^5.1.0
firebase_data_connect: ^0.1.0
dev_dependencies:
flutter_test:
sdk: flutter
bloc_test: ^9.1.0
mocktail: ^1.0.0

View File

@@ -20,6 +20,70 @@ class BankAccountRepositoryImpl implements BankAccountRepository {
final auth.User? user = firebaseAuth.currentUser; final auth.User? user = firebaseAuth.currentUser;
if (user == null) throw Exception('User not authenticated'); if (user == null) throw Exception('User not authenticated');
// return some mock data for now
return [
BankAccount(
id: '1',
userId: user.uid,
bankName: 'Mock Bank',
accountNumber: '****1234',
accountName: 'My Checking Account',
type: BankAccountType.checking,
last4: '1234',
isPrimary: true,
),
BankAccount(
id: '2',
userId: user.uid,
bankName: 'Mock Bank',
accountNumber: '****5678',
accountName: 'My Savings Account',
type: BankAccountType.savings,
last4: '5678',
isPrimary: false,
),
BankAccount(
id: '3',
userId: user.uid,
bankName: 'Mock Bank',
accountNumber: '****1234',
accountName: 'My Checking Account',
type: BankAccountType.checking,
last4: '1234',
isPrimary: true,
),
BankAccount(
id: '4',
userId: user.uid,
bankName: 'Mock Bank',
accountNumber: '****5678',
accountName: 'My Savings Account',
type: BankAccountType.savings,
last4: '5678',
isPrimary: false,
),
BankAccount(
id: '5',
userId: user.uid,
bankName: 'Mock Bank',
accountNumber: '****1234',
accountName: 'My Checking Account',
type: BankAccountType.checking,
last4: '1234',
isPrimary: true,
),
BankAccount(
id: '6',
userId: user.uid,
bankName: 'Mock Bank',
accountNumber: '****5678',
accountName: 'My Savings Account',
type: BankAccountType.savings,
last4: '5678',
isPrimary: false,
),
];
final QueryResult<GetAccountsByOwnerIdData, GetAccountsByOwnerIdVariables> result = await dataConnect.getAccountsByOwnerId(ownerId: user.uid).execute(); final QueryResult<GetAccountsByOwnerIdData, GetAccountsByOwnerIdVariables> result = await dataConnect.getAccountsByOwnerId(ownerId: user.uid).execute();
return result.data.accounts.map((GetAccountsByOwnerIdAccounts account) { return result.data.accounts.map((GetAccountsByOwnerIdAccounts account) {

View File

@@ -82,6 +82,9 @@ class BankAccountPage extends StatelessWidget {
accountNumber: account, accountNumber: account,
type: type); type: type);
}, },
onCancel: () {
cubit.toggleForm(false);
}
), ),
], ],
// Add extra padding at bottom // Add extra padding at bottom

View File

@@ -6,8 +6,9 @@ import '../blocs/bank_account_cubit.dart';
class AddAccountForm extends StatefulWidget { class AddAccountForm extends StatefulWidget {
final dynamic strings; final dynamic strings;
final Function(String routing, String account, String type) onSubmit; final Function(String routing, String account, String type) onSubmit;
final VoidCallback onCancel;
const AddAccountForm({super.key, required this.strings, required this.onSubmit}); const AddAccountForm({super.key, required this.strings, required this.onSubmit, required this.onCancel});
@override @override
State<AddAccountForm> createState() => _AddAccountFormState(); State<AddAccountForm> createState() => _AddAccountFormState();
@@ -79,9 +80,7 @@ class _AddAccountFormState extends State<AddAccountForm> {
Expanded( Expanded(
child: UiButton.text( child: UiButton.text(
text: widget.strings.cancel, text: widget.strings.cancel,
onPressed: () { onPressed: () => widget.onCancel(),
Modular.get<BankAccountCubit>().toggleForm(false);
},
), ),
), ),
const SizedBox(width: UiConstants.space2), const SizedBox(width: UiConstants.space2),