feat: Implement certificate upload form with metadata fields, expiry date selection, and file upload functionality

This commit is contained in:
Achintha Isuru
2026-03-01 20:35:22 -05:00
parent 5795f7c45d
commit b0abd68c2e
7 changed files with 400 additions and 297 deletions

View File

@@ -1,17 +1,17 @@
import 'dart:io';
import 'package:core_localization/core_localization.dart';
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_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import 'package:core_localization/core_localization.dart';
import 'package:intl/intl.dart';
import '../../domain/usecases/upload_certificate_usecase.dart';
import '../blocs/certificate_upload/certificate_upload_cubit.dart';
import '../blocs/certificate_upload/certificate_upload_state.dart';
import '../../domain/usecases/upload_certificate_usecase.dart';
import '../widgets/certificate_upload_page/index.dart';
/// Page for uploading a certificate with metadata (expiry, issuer, etc).
class CertificateUploadPage extends StatefulWidget {
@@ -179,62 +179,16 @@ class _CertificateUploadPageState extends State<CertificateUploadPage> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
_PdfFileTypesBanner(
PdfFileTypesBanner(
message: t.staff_documents.upload.pdf_banner,
),
const SizedBox(height: UiConstants.space6),
// Name Field
Text(
t.staff_certificates.upload_modal.name_label,
style: UiTypography.body2m.textPrimary,
),
const SizedBox(height: UiConstants.space2),
TextField(
controller: _nameController,
enabled: _isNewCertificate,
decoration: InputDecoration(
hintText: t.staff_certificates.upload_modal.name_hint,
border: OutlineInputBorder(
borderRadius: UiConstants.radiusLg,
),
),
),
const SizedBox(height: UiConstants.space4),
// Issuer Field
Text(
t.staff_certificates.upload_modal.issuer_label,
style: UiTypography.body2m.textPrimary,
),
const SizedBox(height: UiConstants.space2),
TextField(
controller: _issuerController,
enabled: _isNewCertificate,
decoration: InputDecoration(
hintText: t.staff_certificates.upload_modal.issuer_hint,
border: OutlineInputBorder(
borderRadius: UiConstants.radiusLg,
),
),
),
const SizedBox(height: UiConstants.space4),
// Certificate Number Field
Text(
'Certificate Number',
style: UiTypography.body2m.textPrimary,
),
const SizedBox(height: UiConstants.space2),
TextField(
controller: _numberController,
enabled: _isNewCertificate,
decoration: InputDecoration(
hintText: 'Enter number if applicable',
border: OutlineInputBorder(
borderRadius: UiConstants.radiusLg,
),
),
CertificateMetadataFields(
nameController: _nameController,
issuerController: _issuerController,
numberController: _numberController,
isNewCertificate: _isNewCertificate,
),
const SizedBox(height: UiConstants.space6),
@@ -242,44 +196,9 @@ class _CertificateUploadPageState extends State<CertificateUploadPage> {
const SizedBox(height: UiConstants.space6),
// Expiry Date Field
Text(
t.staff_certificates.upload_modal.expiry_label,
style: UiTypography.body2m.textPrimary,
),
const SizedBox(height: UiConstants.space2),
InkWell(
ExpiryDateField(
selectedDate: _selectedExpiryDate,
onTap: _selectDate,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: UiConstants.space4,
vertical: UiConstants.space3,
),
decoration: BoxDecoration(
border: Border.all(color: UiColors.border),
borderRadius: UiConstants.radiusLg,
),
child: Row(
children: <Widget>[
const Icon(
UiIcons.calendar,
size: 20,
color: UiColors.textSecondary,
),
const SizedBox(width: UiConstants.space3),
Text(
_selectedExpiryDate != null
? DateFormat(
'MMM dd, yyyy',
).format(_selectedExpiryDate!)
: t.staff_certificates.upload_modal.select_date,
style: _selectedExpiryDate != null
? UiTypography.body1m.textPrimary
: UiTypography.body1m.textSecondary,
),
],
),
),
),
const SizedBox(height: UiConstants.space4),
@@ -289,7 +208,7 @@ class _CertificateUploadPageState extends State<CertificateUploadPage> {
style: UiTypography.body2m.textPrimary,
),
const SizedBox(height: UiConstants.space2),
_FileSelector(
FileSelector(
selectedFilePath: _selectedFilePath,
onTap: _pickFile,
),
@@ -299,110 +218,40 @@ class _CertificateUploadPageState extends State<CertificateUploadPage> {
bottomNavigationBar: SafeArea(
child: Padding(
padding: const EdgeInsets.all(UiConstants.space5),
child: Column(
mainAxisSize: MainAxisSize.min,
spacing: UiConstants.space4,
children: <Widget>[
// Attestation
Row(
children: <Widget>[
Checkbox(
value: state.isAttested,
onChanged: (bool? val) =>
BlocProvider.of<CertificateUploadCubit>(
context,
).setAttested(val ?? false),
activeColor: UiColors.primary,
),
Expanded(
child: Text(
t.staff_documents.upload.attestation,
style: UiTypography.body3r.textSecondary,
),
),
],
),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed:
(_selectedFilePath != null &&
state.isAttested &&
_nameController.text.isNotEmpty)
? () {
final String? err = _validatePdfFile(
context,
_selectedFilePath!,
);
if (err != null) {
UiSnackbar.show(
context,
message: err,
type: UiSnackbarType.error,
margin: const EdgeInsets.all(
UiConstants.space4,
),
);
return;
}
BlocProvider.of<CertificateUploadCubit>(
context,
).uploadCertificate(
UploadCertificateParams(
certificationType: _selectedType!,
name: _nameController.text,
filePath: _selectedFilePath!,
expiryDate: _selectedExpiryDate,
issuer: _issuerController.text,
certificateNumber: _numberController.text,
),
);
}
: null,
style: ElevatedButton.styleFrom(
backgroundColor: UiColors.primary,
padding: const EdgeInsets.symmetric(
vertical: UiConstants.space4,
),
shape: RoundedRectangleBorder(
borderRadius: UiConstants.radiusLg,
),
),
child: state.status == CertificateUploadStatus.uploading
? const CircularProgressIndicator(
color: Colors.white,
)
: Text(
t.staff_certificates.upload_modal.save,
style: UiTypography.body1m.white,
),
child: CertificateUploadActions(
isAttested: state.isAttested,
isFormValid: _selectedFilePath != null &&
state.isAttested &&
_nameController.text.isNotEmpty,
isUploading: state.status == CertificateUploadStatus.uploading,
hasExistingCertificate: widget.certificate != null,
onUploadPressed: () {
final String? err = _validatePdfFile(
context,
_selectedFilePath!,
);
if (err != null) {
UiSnackbar.show(
context,
message: err,
type: UiSnackbarType.error,
margin: const EdgeInsets.all(UiConstants.space4),
);
return;
}
BlocProvider.of<CertificateUploadCubit>(context)
.uploadCertificate(
UploadCertificateParams(
certificationType: _selectedType!,
name: _nameController.text,
filePath: _selectedFilePath!,
expiryDate: _selectedExpiryDate,
issuer: _issuerController.text,
certificateNumber: _numberController.text,
),
),
// Remove Button (only if existing)
if (widget.certificate != null) ...<Widget>[
SizedBox(
width: double.infinity,
child: TextButton.icon(
onPressed: () => _showRemoveConfirmation(context),
icon: const Icon(UiIcons.delete, size: 20),
label: Text(t.staff_certificates.card.remove),
style: TextButton.styleFrom(
foregroundColor: UiColors.destructive,
padding: const EdgeInsets.symmetric(
vertical: UiConstants.space4,
),
shape: RoundedRectangleBorder(
borderRadius: UiConstants.radiusLg,
side: const BorderSide(
color: UiColors.destructive,
),
),
),
),
),
],
],
);
},
onRemovePressed: () => _showRemoveConfirmation(context),
),
),
),
@@ -412,104 +261,3 @@ class _CertificateUploadPageState extends State<CertificateUploadPage> {
);
}
}
/// Banner displaying accepted file types and size limit for PDF upload.
class _PdfFileTypesBanner extends StatelessWidget {
const _PdfFileTypesBanner({required this.message});
final String message;
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(
horizontal: UiConstants.space4,
vertical: UiConstants.space3,
),
decoration: BoxDecoration(
color: UiColors.primaryForeground,
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
border: Border.all(color: UiColors.primary.withValues(alpha: 0.3)),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
const Icon(UiIcons.info, size: 20, color: UiColors.primary),
const SizedBox(width: UiConstants.space3),
Expanded(
child: Text(message, style: UiTypography.body2r.textSecondary),
),
],
),
);
}
}
class _FileSelector extends StatelessWidget {
const _FileSelector({this.selectedFilePath, required this.onTap});
final String? selectedFilePath;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
if (selectedFilePath != null) {
return InkWell(
onTap: onTap,
child: Container(
padding: const EdgeInsets.all(UiConstants.space4),
decoration: BoxDecoration(
border: Border.all(color: UiColors.primary),
borderRadius: UiConstants.radiusLg,
),
child: Row(
children: <Widget>[
const Icon(UiIcons.certificate, color: UiColors.primary),
const SizedBox(width: UiConstants.space3),
Expanded(
child: Text(
selectedFilePath!.split('/').last,
style: UiTypography.body1m.primary,
overflow: TextOverflow.ellipsis,
),
),
Text(
t.staff_documents.upload.replace,
style: UiTypography.body3m.primary,
),
],
),
),
);
}
return InkWell(
onTap: onTap,
child: Container(
height: 120,
width: double.infinity,
decoration: BoxDecoration(
border: Border.all(color: UiColors.border, style: BorderStyle.solid),
borderRadius: UiConstants.radiusLg,
color: UiColors.background,
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const Icon(UiIcons.uploadCloud, size: 32, color: UiColors.primary),
const SizedBox(height: UiConstants.space2),
Text(
t.staff_certificates.upload_modal.drag_drop,
style: UiTypography.body2m,
),
Text(
t.staff_certificates.upload_modal.supported_formats,
style: UiTypography.body3r.textSecondary,
),
],
),
),
);
}
}

View File

@@ -0,0 +1,79 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:core_localization/core_localization.dart';
/// Widget for certificate metadata input fields (name, issuer, number).
class CertificateMetadataFields extends StatelessWidget {
const CertificateMetadataFields({
required this.nameController,
required this.issuerController,
required this.numberController,
required this.isNewCertificate,
});
final TextEditingController nameController;
final TextEditingController issuerController;
final TextEditingController numberController;
final bool isNewCertificate;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
// Name Field
Text(
t.staff_certificates.upload_modal.name_label,
style: UiTypography.body2m.textPrimary,
),
const SizedBox(height: UiConstants.space2),
TextField(
controller: nameController,
enabled: isNewCertificate,
decoration: InputDecoration(
hintText: t.staff_certificates.upload_modal.name_hint,
border: OutlineInputBorder(
borderRadius: UiConstants.radiusLg,
),
),
),
const SizedBox(height: UiConstants.space4),
// Issuer Field
Text(
t.staff_certificates.upload_modal.issuer_label,
style: UiTypography.body2m.textPrimary,
),
const SizedBox(height: UiConstants.space2),
TextField(
controller: issuerController,
enabled: isNewCertificate,
decoration: InputDecoration(
hintText: t.staff_certificates.upload_modal.issuer_hint,
border: OutlineInputBorder(
borderRadius: UiConstants.radiusLg,
),
),
),
const SizedBox(height: UiConstants.space4),
// Certificate Number Field
Text(
'Certificate Number',
style: UiTypography.body2m.textPrimary,
),
const SizedBox(height: UiConstants.space2),
TextField(
controller: numberController,
enabled: isNewCertificate,
decoration: InputDecoration(
hintText: 'Enter number if applicable',
border: OutlineInputBorder(
borderRadius: UiConstants.radiusLg,
),
),
),
],
);
}
}

View File

@@ -0,0 +1,101 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:core_localization/core_localization.dart';
import '../../blocs/certificate_upload/certificate_upload_cubit.dart';
/// Widget for attestation checkbox and action buttons in certificate upload form.
class CertificateUploadActions extends StatelessWidget {
const CertificateUploadActions({
required this.isAttested,
required this.isFormValid,
required this.isUploading,
required this.hasExistingCertificate,
required this.onUploadPressed,
required this.onRemovePressed,
});
final bool isAttested;
final bool isFormValid;
final bool isUploading;
final bool hasExistingCertificate;
final VoidCallback onUploadPressed;
final VoidCallback onRemovePressed;
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
spacing: UiConstants.space4,
children: <Widget>[
// Attestation
Row(
children: <Widget>[
Checkbox(
value: isAttested,
onChanged: (bool? val) =>
BlocProvider.of<CertificateUploadCubit>(context).setAttested(
val ?? false,
),
activeColor: UiColors.primary,
),
Expanded(
child: Text(
t.staff_documents.upload.attestation,
style: UiTypography.body3r.textSecondary,
),
),
],
),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: isFormValid ? onUploadPressed : null,
style: ElevatedButton.styleFrom(
backgroundColor: UiColors.primary,
padding: const EdgeInsets.symmetric(
vertical: UiConstants.space4,
),
shape: RoundedRectangleBorder(
borderRadius: UiConstants.radiusLg,
),
),
child: isUploading
? const CircularProgressIndicator(
color: Colors.white,
)
: Text(
t.staff_certificates.upload_modal.save,
style: UiTypography.body1m.white,
),
),
),
// Remove Button (only if existing)
if (hasExistingCertificate) ...<Widget>[
SizedBox(
width: double.infinity,
child: TextButton.icon(
onPressed: onRemovePressed,
icon: const Icon(UiIcons.delete, size: 20),
label: Text(t.staff_certificates.card.remove),
style: TextButton.styleFrom(
foregroundColor: UiColors.destructive,
padding: const EdgeInsets.symmetric(
vertical: UiConstants.space4,
),
shape: RoundedRectangleBorder(
borderRadius: UiConstants.radiusLg,
side: const BorderSide(
color: UiColors.destructive,
),
),
),
),
),
],
],
);
}
}

View File

@@ -0,0 +1,60 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:core_localization/core_localization.dart';
/// Widget for selecting certificate expiry date.
class ExpiryDateField extends StatelessWidget {
const ExpiryDateField({
required this.selectedDate,
required this.onTap,
});
final DateTime? selectedDate;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
t.staff_certificates.upload_modal.expiry_label,
style: UiTypography.body2m.textPrimary,
),
const SizedBox(height: UiConstants.space2),
InkWell(
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: UiConstants.space4,
vertical: UiConstants.space3,
),
decoration: BoxDecoration(
border: Border.all(color: UiColors.border),
borderRadius: UiConstants.radiusLg,
),
child: Row(
children: <Widget>[
const Icon(
UiIcons.calendar,
size: 20,
color: UiColors.textSecondary,
),
const SizedBox(width: UiConstants.space3),
Text(
selectedDate != null
? DateFormat('MMM dd, yyyy').format(selectedDate!)
: t.staff_certificates.upload_modal.select_date,
style: selectedDate != null
? UiTypography.body1m.textPrimary
: UiTypography.body1m.textSecondary,
),
],
),
),
),
],
);
}
}

View File

@@ -0,0 +1,75 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:core_localization/core_localization.dart';
/// Widget for selecting certificate file.
class FileSelector extends StatelessWidget {
const FileSelector({
required this.selectedFilePath,
required this.onTap,
});
final String? selectedFilePath;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
if (selectedFilePath != null) {
return InkWell(
onTap: onTap,
child: Container(
padding: const EdgeInsets.all(UiConstants.space4),
decoration: BoxDecoration(
border: Border.all(color: UiColors.primary),
borderRadius: UiConstants.radiusLg,
),
child: Row(
children: <Widget>[
const Icon(UiIcons.certificate, color: UiColors.primary),
const SizedBox(width: UiConstants.space3),
Expanded(
child: Text(
selectedFilePath!.split('/').last,
style: UiTypography.body1m.primary,
overflow: TextOverflow.ellipsis,
),
),
Text(
t.staff_documents.upload.replace,
style: UiTypography.body3m.primary,
),
],
),
),
);
}
return InkWell(
onTap: onTap,
child: Container(
height: 120,
width: double.infinity,
decoration: BoxDecoration(
border: Border.all(color: UiColors.border, style: BorderStyle.solid),
borderRadius: UiConstants.radiusLg,
color: UiColors.background,
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const Icon(UiIcons.uploadCloud, size: 32, color: UiColors.primary),
const SizedBox(height: UiConstants.space2),
Text(
t.staff_certificates.upload_modal.drag_drop,
style: UiTypography.body2m,
),
Text(
t.staff_certificates.upload_modal.supported_formats,
style: UiTypography.body3r.textSecondary,
),
],
),
),
);
}
}

View File

@@ -0,0 +1,5 @@
export 'certificate_metadata_fields.dart';
export 'certificate_upload_actions.dart';
export 'expiry_date_field.dart';
export 'file_selector.dart';
export 'pdf_file_types_banner.dart';

View File

@@ -0,0 +1,35 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
/// Banner displaying accepted file types and size limit for PDF upload.
class PdfFileTypesBanner extends StatelessWidget {
const PdfFileTypesBanner({required this.message});
final String message;
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(
horizontal: UiConstants.space4,
vertical: UiConstants.space3,
),
decoration: BoxDecoration(
color: UiColors.primaryForeground,
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
border: Border.all(color: UiColors.primary.withValues(alpha: 0.3)),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
const Icon(UiIcons.info, size: 20, color: UiColors.primary),
const SizedBox(width: UiConstants.space3),
Expanded(
child: Text(message, style: UiTypography.body2r.textSecondary),
),
],
),
);
}
}