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) onSubmit; final bool isEmployee; const DocumentUploadBottomSheet({ Key? key, required this.onSubmit, this.isEmployee = false, }) : super(key: key); @override State createState() => _DocumentUploadBottomSheetState(); } class _DocumentUploadBottomSheetState extends State { final _formKey = GlobalKey(); 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 _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 items; final ValueChanged 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 createState() => _LabeledDropdownState(); } class _LabeledDropdownState extends State { 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( context: context, position: position, items: widget.items .map((item) => PopupMenuItem( 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"), ], ), ), ), ], ); }