marco.pms.mobileapp/lib/model/document/document_upload_bottom_sheet.dart

784 lines
26 KiB
Dart
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'dart:convert';
import 'dart:io';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:marco/controller/document/document_upload_controller.dart';
import 'package:marco/helpers/utils/base_bottom_sheet.dart';
import 'package:marco/helpers/widgets/my_spacing.dart';
import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/helpers/widgets/my_text_style.dart';
import 'package:marco/helpers/widgets/image_viewer_dialog.dart';
import 'package:marco/helpers/widgets/my_snackbar.dart';
class DocumentUploadBottomSheet extends StatefulWidget {
final Function(Map<String, dynamic>) onSubmit;
final bool isEmployee;
const DocumentUploadBottomSheet({
Key? key,
required this.onSubmit,
this.isEmployee = false,
}) : super(key: key);
@override
State<DocumentUploadBottomSheet> createState() =>
_DocumentUploadBottomSheetState();
}
class _DocumentUploadBottomSheetState extends State<DocumentUploadBottomSheet> {
final _formKey = GlobalKey<FormState>();
final controller = Get.put(DocumentUploadController());
final TextEditingController _docIdController = TextEditingController();
final TextEditingController _docNameController = TextEditingController();
final TextEditingController _descriptionController = TextEditingController();
File? selectedFile;
@override
void dispose() {
_docIdController.dispose();
_docNameController.dispose();
_descriptionController.dispose();
super.dispose();
}
void _handleSubmit() {
final formState = _formKey.currentState;
// 1⃣ Validate form fields
if (!(formState?.validate() ?? false)) {
// Collect first validation error
final errorFields = [
{"label": "Document ID", "value": _docIdController.text.trim()},
{"label": "Document Name", "value": _docNameController.text.trim()},
{"label": "Description", "value": _descriptionController.text.trim()},
];
for (var field in errorFields) {
if (field["value"] == null || (field["value"] as String).isEmpty) {
showAppSnackbar(
title: "Error",
message: "${field["label"]} is required",
type: SnackbarType.error,
);
return;
}
}
return;
}
// 2⃣ Validate file attachment
if (selectedFile == null) {
showAppSnackbar(
title: "Error",
message: "Please attach a document",
type: SnackbarType.error,
);
return;
}
// 3⃣ Validate document category based on employee/project
if (controller.selectedCategory != null) {
final selectedCategoryName = controller.selectedCategory!.name;
if (widget.isEmployee && selectedCategoryName != 'Employee Documents') {
showAppSnackbar(
title: "Error",
message:
"Only 'Employee Documents' can be uploaded from the Employee screen. Please select the correct document type.",
type: SnackbarType.error,
);
return;
} else if (!widget.isEmployee &&
selectedCategoryName != 'Project Documents') {
showAppSnackbar(
title: "Error",
message:
"Only 'Project Documents' can be uploaded from the Project screen. Please select the correct document type.",
type: SnackbarType.error,
);
return;
}
} else {
showAppSnackbar(
title: "Error",
message: "Please select a Document Category before uploading.",
type: SnackbarType.error,
);
return;
}
// 4⃣ Validate file size
final maxSizeMB = controller.selectedType?.maxSizeAllowedInMB;
if (maxSizeMB != null && controller.selectedFileSize != null) {
final fileSizeMB = controller.selectedFileSize! / (1024 * 1024);
if (fileSizeMB > maxSizeMB) {
showAppSnackbar(
title: "Error",
message: "File size exceeds $maxSizeMB MB limit",
type: SnackbarType.error,
);
return;
}
}
// 5⃣ Validate file type
final allowedType = controller.selectedType?.allowedContentType;
if (allowedType != null && controller.selectedFileContentType != null) {
if (!allowedType
.toLowerCase()
.contains(controller.selectedFileContentType!.toLowerCase())) {
showAppSnackbar(
title: "Error",
message: "Only $allowedType files are allowed for this type",
type: SnackbarType.error,
);
return;
}
}
// 6⃣ Prepare payload
final payload = {
"documentId": _docIdController.text.trim(),
"name": _docNameController.text.trim(),
"description": _descriptionController.text.trim(),
"documentTypeId": controller.selectedType?.id,
"attachment": {
"fileName": controller.selectedFileName,
"base64Data": controller.selectedFileBase64,
"contentType": controller.selectedFileContentType,
"fileSize": controller.selectedFileSize,
"isActive": true,
},
"tags": controller.enteredTags
.map((t) => {"name": t, "isActive": true})
.toList(),
};
// 7⃣ Submit
widget.onSubmit(payload);
// 8⃣ Show success message
showAppSnackbar(
title: "Success",
message: "Document submitted successfully",
type: SnackbarType.success,
);
}
Future<void> _pickFile() async {
final result = await FilePicker.platform.pickFiles(
type: FileType.custom,
allowedExtensions: ['pdf', 'jpg', 'png', 'jpeg'],
);
if (result != null && result.files.single.path != null) {
final file = File(result.files.single.path!);
final fileName = result.files.single.name;
final fileBytes = await file.readAsBytes();
final base64Data = base64Encode(fileBytes);
setState(() {
selectedFile = file;
controller.selectedFileName = fileName;
controller.selectedFileBase64 = base64Data;
controller.selectedFileContentType =
result.files.single.extension?.toLowerCase() == "pdf"
? "application/pdf"
: "image/${result.files.single.extension?.toLowerCase()}";
controller.selectedFileSize = (fileBytes.length / 1024).round();
});
}
}
@override
Widget build(BuildContext context) {
final sheetTitle = widget.isEmployee
? "Upload Employee Document"
: "Upload Project Document";
return BaseBottomSheet(
title: sheetTitle,
onCancel: () => Navigator.pop(context),
onSubmit: _handleSubmit,
child: Form(
key: _formKey,
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MySpacing.height(16),
/// Document Category
Obx(() {
if (controller.isLoading.value &&
controller.categories.isEmpty) {
return const Center(child: CircularProgressIndicator());
}
return LabeledDropdown(
label: "Document Category",
hint: "Select Category",
value: controller.selectedCategory?.name,
items: controller.categories.map((c) => c.name).toList(),
onChanged: (selected) async {
final category = controller.categories
.firstWhere((c) => c.name == selected);
setState(() => controller.selectedCategory = category);
await controller.fetchDocumentTypes(category.id);
},
isRequired: true,
);
}),
MySpacing.height(16),
/// Document Type
Obx(() {
if (controller.documentTypes.isEmpty) {
return const SizedBox.shrink();
}
return LabeledDropdown(
label: "Document Type",
hint: "Select Type",
value: controller.selectedType?.name,
items: controller.documentTypes.map((t) => t.name).toList(),
onChanged: (selected) {
final type = controller.documentTypes
.firstWhere((t) => t.name == selected);
setState(() => controller.selectedType = type);
},
isRequired: true,
);
}),
MySpacing.height(12),
/// Document ID
LabeledInput(
label: "Document ID",
hint: "Enter Document ID",
controller: _docIdController,
validator: (value) {
if (value == null || value.trim().isEmpty) {
return "Required";
}
// ✅ Regex validation if enabled
final selectedType = controller.selectedType;
if (selectedType != null &&
selectedType.isValidationRequired &&
selectedType.regexExpression != null &&
selectedType.regexExpression!.isNotEmpty) {
final regExp = RegExp(selectedType.regexExpression!);
if (!regExp.hasMatch(value.trim())) {
return "Invalid ${selectedType.name} format";
}
}
return null;
},
isRequired: true,
),
MySpacing.height(16),
/// Document Name
LabeledInput(
label: "Document Name",
hint: "e.g., PAN Card",
controller: _docNameController,
validator: (value) =>
value == null || value.trim().isEmpty ? "Required" : null,
isRequired: true,
),
MySpacing.height(16),
/// Single Attachment Section
AttachmentSectionSingle(
attachment: selectedFile,
onPick: _pickFile,
onRemove: () => setState(() {
selectedFile = null;
controller.selectedFileName = null;
controller.selectedFileBase64 = null;
controller.selectedFileContentType = null;
controller.selectedFileSize = null;
}),
),
if (controller.selectedType?.maxSizeAllowedInMB != null)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
"Max file size: ${controller.selectedType!.maxSizeAllowedInMB} MB",
style: const TextStyle(color: Colors.grey, fontSize: 12),
),
),
MySpacing.height(16),
/// Tags
MyText.labelMedium("Tags"),
MySpacing.height(8),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
height: 56,
child: TextFormField(
controller: controller.tagCtrl,
onChanged: controller.filterSuggestions,
onFieldSubmitted: (v) {
controller.addEnteredTag(v);
controller.tagCtrl.clear();
controller.clearSuggestions();
},
decoration: InputDecoration(
hintText: "Start typing to add tags",
hintStyle: MyTextStyle.bodySmall(xMuted: true),
filled: true,
fillColor: Colors.grey.shade100,
border: _inputBorder(),
enabledBorder: _inputBorder(),
focusedBorder: _inputFocusedBorder(),
contentPadding: MySpacing.all(16),
),
),
),
Obx(() => controller.filteredSuggestions.isEmpty
? const SizedBox.shrink()
: Container(
margin: const EdgeInsets.only(top: 4),
decoration: BoxDecoration(
color: Colors.white,
border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(8),
boxShadow: const [
BoxShadow(color: Colors.black12, blurRadius: 4),
],
),
child: ListView.builder(
shrinkWrap: true,
itemCount: controller.filteredSuggestions.length,
itemBuilder: (_, i) {
final suggestion =
controller.filteredSuggestions[i];
return ListTile(
dense: true,
title: Text(suggestion),
onTap: () {
controller.addEnteredTag(suggestion);
controller.tagCtrl.clear();
controller.clearSuggestions();
},
);
},
),
)),
MySpacing.height(8),
Obx(() => Wrap(
spacing: 8,
children: controller.enteredTags
.map((tag) => Chip(
label: Text(tag),
onDeleted: () =>
controller.removeEnteredTag(tag),
))
.toList(),
)),
],
),
MySpacing.height(16),
/// Description
LabeledInput(
label: "Description",
hint: "Enter short description",
controller: _descriptionController,
validator: (value) =>
value == null || value.trim().isEmpty ? "Required" : null,
isRequired: true,
maxLines: 3,
),
],
),
),
),
);
}
OutlineInputBorder _inputBorder() => OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.grey.shade300),
);
OutlineInputBorder _inputFocusedBorder() => const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
borderSide: BorderSide(color: Colors.blueAccent, width: 1.5),
);
}
/// ---------------- Single Attachment Widget ----------------
class AttachmentSectionSingle extends StatelessWidget {
final File? attachment;
final VoidCallback onPick;
final VoidCallback? onRemove;
const AttachmentSectionSingle({
Key? key,
this.attachment,
required this.onPick,
this.onRemove,
}) : super(key: key);
@override
Widget build(BuildContext context) {
final allowedImageExtensions = ['jpg', 'jpeg', 'png'];
Widget buildTile(File file) {
final isImage = allowedImageExtensions
.contains(file.path.split('.').last.toLowerCase());
final fileName = file.path.split('/').last;
IconData fileIcon = Icons.insert_drive_file;
Color iconColor = Colors.blueGrey;
if (!isImage) {
final ext = fileName.split('.').last.toLowerCase();
switch (ext) {
case 'pdf':
fileIcon = Icons.picture_as_pdf;
iconColor = Colors.redAccent;
break;
case 'doc':
case 'docx':
fileIcon = Icons.description;
iconColor = Colors.blueAccent;
break;
case 'xls':
case 'xlsx':
fileIcon = Icons.table_chart;
iconColor = Colors.green;
break;
case 'txt':
fileIcon = Icons.article;
iconColor = Colors.grey;
break;
}
}
return Stack(
clipBehavior: Clip.none,
children: [
GestureDetector(
onTap: () {
if (isImage) {
showDialog(
context: context,
builder: (_) => ImageViewerDialog(
imageSources: [file],
initialIndex: 0,
),
);
}
},
child: Container(
width: 100,
height: 100,
decoration: BoxDecoration(
border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(8),
color: Colors.grey.shade100,
),
child: isImage
? ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.file(file, fit: BoxFit.cover),
)
: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(fileIcon, color: iconColor, size: 30),
const SizedBox(height: 4),
Text(
fileName.split('.').last.toUpperCase(),
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: iconColor),
),
],
),
),
),
if (onRemove != null)
Positioned(
top: -6,
right: -6,
child: IconButton(
icon: const Icon(Icons.close, color: Colors.red, size: 18),
onPressed: onRemove,
),
),
],
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: const [
Text("Attachment", style: TextStyle(fontWeight: FontWeight.w600)),
Text(" *",
style:
TextStyle(color: Colors.red, fontWeight: FontWeight.bold))
],
),
const SizedBox(height: 8),
Row(
children: [
if (attachment != null)
buildTile(attachment!)
else
GestureDetector(
onTap: onPick,
child: Container(
width: 100,
height: 100,
decoration: BoxDecoration(
border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(8),
color: Colors.grey.shade100,
),
child: const Icon(Icons.add, size: 40, color: Colors.grey),
),
),
],
),
],
);
}
}
// ---- Reusable Widgets ----
class LabeledInput extends StatelessWidget {
final String label;
final String hint;
final TextEditingController controller;
final String? Function(String?) validator;
final bool isRequired;
final int maxLines;
const LabeledInput({
Key? key,
required this.label,
required this.hint,
required this.controller,
required this.validator,
this.isRequired = false,
this.maxLines = 1,
}) : super(key: key);
@override
Widget build(BuildContext context) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
MyText.labelMedium(label),
if (isRequired)
const Text(
" *",
style:
TextStyle(color: Colors.red, fontWeight: FontWeight.bold),
),
],
),
MySpacing.height(8),
TextFormField(
controller: controller,
validator: validator,
decoration: _inputDecoration(context, hint),
maxLines: maxLines,
),
],
);
InputDecoration _inputDecoration(BuildContext context, String hint) =>
InputDecoration(
hintText: hint,
hintStyle: MyTextStyle.bodySmall(xMuted: true),
filled: true,
fillColor: Colors.grey.shade100,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.grey.shade300),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.grey.shade300),
),
focusedBorder: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
borderSide: BorderSide(color: Colors.blueAccent, width: 1.5),
),
contentPadding: MySpacing.all(16),
);
}
class LabeledDropdown extends StatefulWidget {
final String label;
final String hint;
final String? value;
final List<String> items;
final ValueChanged<String> onChanged;
final bool isRequired;
const LabeledDropdown({
Key? key,
required this.label,
required this.hint,
required this.value,
required this.items,
required this.onChanged,
this.isRequired = false,
}) : super(key: key);
@override
State<LabeledDropdown> createState() => _LabeledDropdownState();
}
class _LabeledDropdownState extends State<LabeledDropdown> {
final GlobalKey _dropdownKey = GlobalKey();
@override
Widget build(BuildContext context) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
MyText.labelMedium(widget.label),
if (widget.isRequired)
const Text(
" *",
style:
TextStyle(color: Colors.red, fontWeight: FontWeight.bold),
),
],
),
MySpacing.height(8),
GestureDetector(
key: _dropdownKey,
onTap: () async {
final RenderBox renderBox =
_dropdownKey.currentContext!.findRenderObject() as RenderBox;
final Offset offset = renderBox.localToGlobal(Offset.zero);
final Size size = renderBox.size;
final RelativeRect position = RelativeRect.fromLTRB(
offset.dx,
offset.dy + size.height,
offset.dx + size.width,
offset.dy,
);
final selected = await showMenu<String>(
context: context,
position: position,
items: widget.items
.map((item) => PopupMenuItem<String>(
value: item,
child: Text(item),
))
.toList(),
);
if (selected != null) widget.onChanged(selected);
},
child: AbsorbPointer(
child: TextFormField(
readOnly: true,
controller: TextEditingController(text: widget.value ?? ""),
validator: (value) =>
widget.isRequired && (value == null || value.isEmpty)
? "Required"
: null,
decoration: _inputDecoration(context, widget.hint).copyWith(
suffixIcon: const Icon(Icons.expand_more),
),
),
),
),
],
);
InputDecoration _inputDecoration(BuildContext context, String hint) =>
InputDecoration(
hintText: hint,
hintStyle: MyTextStyle.bodySmall(xMuted: true),
filled: true,
fillColor: Colors.grey.shade100,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.grey.shade300),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.grey.shade300),
),
focusedBorder: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
borderSide: BorderSide(color: Colors.blueAccent, width: 1.5),
),
contentPadding: MySpacing.all(16),
);
}
class FilePickerTile extends StatelessWidget {
final String? pickedFile;
final VoidCallback onTap;
final bool isRequired;
const FilePickerTile({
Key? key,
required this.pickedFile,
required this.onTap,
this.isRequired = false,
}) : super(key: key);
@override
Widget build(BuildContext context) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
MyText.labelMedium("Attachments"),
if (isRequired)
const Text(
" *",
style:
TextStyle(color: Colors.red, fontWeight: FontWeight.bold),
),
],
),
MySpacing.height(8),
GestureDetector(
onTap: onTap,
child: Container(
padding: MySpacing.all(16),
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.grey.shade300),
),
child: Row(
children: [
const Icon(Icons.upload_file, color: Colors.blueAccent),
const SizedBox(width: 12),
Text(pickedFile ?? "Choose File"),
],
),
),
),
],
);
}