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:
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.'),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
include: ../../../../../../analysis_options.yaml
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
library staff_certificates;
|
||||||
|
|
||||||
|
export 'src/staff_certificates_module.dart';
|
||||||
|
export 'src/presentation/navigation/certificates_navigator.dart'; // Export navigator extension
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
Reference in New Issue
Block a user