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_snackbar.dart'; import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/helpers/widgets/image_viewer_dialog.dart'; import 'package:marco/model/document/master_document_type_model.dart'; import 'package:marco/helpers/widgets/my_text_style.dart'; import 'package:url_launcher/url_launcher.dart'; class DocumentEditBottomSheet extends StatefulWidget { final Map documentData; final Function(Map) onSubmit; const DocumentEditBottomSheet({ Key? key, required this.documentData, required this.onSubmit, }) : super(key: key); @override State createState() => _DocumentEditBottomSheetState(); } class _DocumentEditBottomSheetState extends State { final _formKey = GlobalKey(); final controller = Get.put(DocumentUploadController()); final TextEditingController _docIdController = TextEditingController(); final TextEditingController _docNameController = TextEditingController(); final TextEditingController _descriptionController = TextEditingController(); String? latestVersionUrl; File? selectedFile; bool fileChanged = false; @override void initState() { super.initState(); _docIdController.text = widget.documentData["documentId"] ?? ""; _docNameController.text = widget.documentData["name"] ?? ""; _descriptionController.text = widget.documentData["description"] ?? ""; // Tags if (widget.documentData["tags"] != null) { controller.enteredTags.assignAll( List.from( (widget.documentData["tags"] as List) .map((t) => t is String ? t : t["name"]), ), ); } // --- Convert category map to DocumentType --- if (widget.documentData["category"] != null) { controller.selectedCategory = DocumentType.fromJson(widget.documentData["category"]); } // Type (if separate) if (widget.documentData["type"] != null) { controller.selectedType = DocumentType.fromJson(widget.documentData["type"]); } // Fetch latest version URL if attachment exists final latestVersion = widget.documentData["attachment"]; if (latestVersion != null) { WidgetsBinding.instance.addPostFrameCallback((_) async { final url = await controller.fetchPresignedUrl(latestVersion["id"]); if (url != null) { setState(() { latestVersionUrl = url; }); } }); } } @override void dispose() { _docIdController.dispose(); _docNameController.dispose(); _descriptionController.dispose(); super.dispose(); } void _handleSubmit() async { if (!(_formKey.currentState?.validate() ?? false)) return; // ✅ Validate only if user picked a new file if (fileChanged && selectedFile != null) { 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; } } } final payload = { "id": widget.documentData["id"], "documentId": _docIdController.text.trim(), "name": _docNameController.text.trim(), "description": _descriptionController.text.trim(), "documentTypeId": controller.selectedType?.id, "tags": controller.enteredTags .map((t) => {"name": t, "isActive": true}) .toList(), }; // ✅ Always include attachment logic if (fileChanged) { if (selectedFile != null) { // User picked new file payload["attachment"] = { "fileName": controller.selectedFileName, "base64Data": controller.selectedFileBase64, "contentType": controller.selectedFileContentType, "fileSize": controller.selectedFileSize, "isActive": true, }; } else { // User explicitly removed file payload["attachment"] = null; } } else { // ✅ User did NOT touch the attachment → send null explicitly payload["attachment"] = null; } // else: do nothing → existing attachment remains as is final success = await controller.editDocument(payload); if (success) { widget.onSubmit(payload); Navigator.pop(context); } } 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; fileChanged = true; 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) { return BaseBottomSheet( title: "Edit Document", onCancel: () => Navigator.pop(context), onSubmit: _handleSubmit, child: Form( key: _formKey, child: SingleChildScrollView( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ MySpacing.height(16), /// Document ID LabeledInput( label: "Document ID", hint: "Enter Document ID", controller: _docIdController, validator: (v) => v == null || v.trim().isEmpty ? "Required" : null, isRequired: true, ), MySpacing.height(16), /// Document Name LabeledInput( label: "Document Name", hint: "e.g., PAN Card", controller: _docNameController, validator: (v) => v == null || v.trim().isEmpty ? "Required" : null, isRequired: true, ), MySpacing.height(16), /// Document Category (Read-only, non-editable) LabeledInput( label: "Document Category", hint: "", controller: TextEditingController( text: controller.selectedCategory?.name ?? ""), validator: (_) => null, isRequired: false, // Disable interaction readOnly: true, ), MySpacing.height(16), /// Document Type (Read-only, non-editable) LabeledInput( label: "Document Type", hint: "", controller: TextEditingController( text: controller.selectedType?.name ?? ""), validator: (_) => null, isRequired: false, readOnly: true, ), MySpacing.height(24), /// Attachment Section AttachmentSectionSingle( attachmentFile: selectedFile, attachmentUrl: latestVersionUrl, onPick: _pickFile, onRemove: () => setState(() { selectedFile = null; fileChanged = true; controller.selectedFileName = null; controller.selectedFileBase64 = null; controller.selectedFileContentType = null; controller.selectedFileSize = null; latestVersionUrl = 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 Section 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: (v) => v == null || v.trim().isEmpty ? "Required" : null, isRequired: true, ), ], ), ), ), ); } } 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 (Rewritten) ---------------- class AttachmentSectionSingle extends StatelessWidget { final File? attachmentFile; // Local file final String? attachmentUrl; // Online latest version URL final VoidCallback onPick; final VoidCallback? onRemove; const AttachmentSectionSingle({ Key? key, this.attachmentFile, this.attachmentUrl, required this.onPick, this.onRemove, }) : super(key: key); @override Widget build(BuildContext context) { final allowedImageExtensions = ['jpg', 'jpeg', 'png']; Widget buildTile({File? file, String? url}) { final isImage = file != null ? allowedImageExtensions .contains(file.path.split('.').last.toLowerCase()) : url != null ? allowedImageExtensions .contains(url.split('.').last.toLowerCase()) : false; final fileName = file != null ? file.path.split('/').last : url != null ? url.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: () async { if (isImage && file != null) { showDialog( context: context, builder: (_) => ImageViewerDialog( imageSources: [file], initialIndex: 0, ), ); } else if (url != null) { final uri = Uri.parse(url); if (await canLaunchUrl(uri)) { await launchUrl(uri, mode: LaunchMode.externalApplication); } else { showAppSnackbar( title: "Error", message: "Could not open document", type: SnackbarType.error, ); } } }, child: Container( width: 100, height: 100, margin: const EdgeInsets.only(right: 8), decoration: BoxDecoration( border: Border.all(color: Colors.grey.shade300), borderRadius: BorderRadius.circular(8), color: Colors.grey.shade100, ), child: isImage && file != null ? 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), ], ), ), ), 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, mainAxisSize: MainAxisSize.min, // prevent overflow children: [ Row( children: const [ Text("Attachment", style: TextStyle(fontWeight: FontWeight.w600)), Text(" *", style: TextStyle(color: Colors.red, fontWeight: FontWeight.bold)) ], ), const SizedBox(height: 8), SingleChildScrollView( scrollDirection: Axis.horizontal, child: Row( children: [ if (attachmentFile != null) buildTile(file: attachmentFile) else if (attachmentUrl != null) buildTile(url: attachmentUrl) 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 bool readOnly; // <-- Add this const LabeledInput({ Key? key, required this.label, required this.hint, required this.controller, required this.validator, this.isRequired = false, this.readOnly = false, // default false }) : 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, readOnly: readOnly, // <-- Use the new property here decoration: _inputDecoration(context, hint), ), ], ); 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"), ], ), ), ), ], ); }