From 99bd26942c28798ea6f7a84fbb1a2dff1a6bb047 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Mon, 8 Sep 2025 18:00:07 +0530 Subject: [PATCH] feat: Implement document editing functionality with permissions and attachment handling --- .../document/document_upload_controller.dart | 48 ++ lib/helpers/services/api_service.dart | 16 +- lib/helpers/utils/permission_constants.dart | 19 + .../document/document_details_model.dart | 46 +- .../document/document_edit_bottom_sheet.dart | 753 ++++++++++++++++++ lib/view/document/document_details_page.dart | 101 ++- lib/view/document/user_document_screen.dart | 122 +-- 7 files changed, 1020 insertions(+), 85 deletions(-) create mode 100644 lib/model/document/document_edit_bottom_sheet.dart diff --git a/lib/controller/document/document_upload_controller.dart b/lib/controller/document/document_upload_controller.dart index f698d41..c7e33c7 100644 --- a/lib/controller/document/document_upload_controller.dart +++ b/lib/controller/document/document_upload_controller.dart @@ -68,6 +68,10 @@ class DocumentUploadController extends GetxController { } } + Future fetchPresignedUrl(String versionId) async { + return await ApiService.getPresignedUrlApi(versionId); + } + /// Fetch available document tags Future fetchTags() async { try { @@ -188,4 +192,48 @@ class DocumentUploadController extends GetxController { isUploading.value = false; } } + + Future editDocument(Map payload) async { + try { + isUploading.value = true; + + final attachment = payload["attachment"]; + + final success = await ApiService.editDocumentApi( + id: payload["id"], + name: payload["name"], + documentId: payload["documentId"], + description: payload["description"], + tags: (payload["tags"] as List).cast>(), + attachment: attachment, + ); + + if (success) { + showAppSnackbar( + title: "Success", + message: "Document updated successfully", + type: SnackbarType.success, + ); + } else { + showAppSnackbar( + title: "Error", + message: "Failed to update document", + type: SnackbarType.error, + ); + } + + return success; + } catch (e, stack) { + logSafe("Edit error: $e", level: LogLevel.error); + logSafe("Stacktrace: $stack", level: LogLevel.debug); + showAppSnackbar( + title: "Error", + message: "An unexpected error occurred", + type: SnackbarType.error, + ); + return false; + } finally { + isUploading.value = false; + } + } } diff --git a/lib/helpers/services/api_service.dart b/lib/helpers/services/api_service.dart index d120a6c..5d1a1e8 100644 --- a/lib/helpers/services/api_service.dart +++ b/lib/helpers/services/api_service.dart @@ -332,13 +332,8 @@ class ApiService { required String name, required String documentId, String? description, - required String fileName, - required String base64Data, - required String contentType, - required int fileSize, - String? fileDescription, - bool isActive = true, List> tags = const [], + Map? attachment, // 👈 can be null }) async { final endpoint = "${ApiEndpoints.editDocument}/$id"; logSafe("Editing document with id: $id"); @@ -348,19 +343,12 @@ class ApiService { "name": name, "documentId": documentId, "description": description ?? "", - "attachment": { - "fileName": fileName, - "base64Data": base64Data, - "contentType": contentType, - "fileSize": fileSize, - "description": fileDescription ?? "", - "isActive": isActive, - }, "tags": tags.isNotEmpty ? tags : [ {"name": "default", "isActive": true} ], + "attachment": attachment, // 👈 null or object }; try { diff --git a/lib/helpers/utils/permission_constants.dart b/lib/helpers/utils/permission_constants.dart index 05d80ff..f257cfc 100644 --- a/lib/helpers/utils/permission_constants.dart +++ b/lib/helpers/utils/permission_constants.dart @@ -98,4 +98,23 @@ class Permissions { /// Entity ID for employee documents static const String employeeEntity = "dbb9555a-7a0c-40f2-a9ed-f0463f1ceed7"; + + // ------------------- Document Permissions ---------------------------- + /// Permission to view documents + static const String viewDocument = "71189504-f1c8-4ca5-8db6-810497be2854"; + + /// Permission to upload documents + static const String uploadDocument = "3f6d1f67-6fa5-4b7c-b17b-018d4fe4aab8"; + + /// Permission to modify documents + static const String modifyDocument = "c423fd81-6273-4b9d-bb5e-76a0fb343833"; + + /// Permission to delete documents + static const String deleteDocument = "40863a13-5a66-469d-9b48-135bc5dbf486"; + + /// Permission to download documents + static const String downloadDocument = "404373d0-860f-490e-a575-1c086ffbce1d"; + + /// Permission to verify documents + static const String verifyDocument = "13a1f30f-38d1-41bf-8e7a-b75189aab8e0"; } diff --git a/lib/model/document/document_details_model.dart b/lib/model/document/document_details_model.dart index 055e3db..06e52eb 100644 --- a/lib/model/document/document_details_model.dart +++ b/lib/model/document/document_details_model.dart @@ -130,19 +130,30 @@ class UploadedBy { jobRoleName: json['jobRoleName'] ?? '', ); } + + Map toJson() { + return { + 'id': id, + 'firstName': firstName, + 'lastName': lastName, + 'photo': photo, + 'jobRoleId': jobRoleId, + 'jobRoleName': jobRoleName, + }; + } } class DocumentType { final String id; final String name; - final String? regexExpression; // nullable + final String? regexExpression; final String allowedContentType; final int maxSizeAllowedInMB; final bool isValidationRequired; final bool isMandatory; final bool isSystem; final bool isActive; - final DocumentCategory? documentCategory; // nullable + final DocumentCategory? documentCategory; DocumentType({ required this.id, @@ -173,6 +184,21 @@ class DocumentType { : null, ); } + + Map toJson() { + return { + 'id': id, + 'name': name, + 'regexExpression': regexExpression, + 'allowedContentType': allowedContentType, + 'maxSizeAllowedInMB': maxSizeAllowedInMB, + 'isValidationRequired': isValidationRequired, + 'isMandatory': isMandatory, + 'isSystem': isSystem, + 'isActive': isActive, + 'documentCategory': documentCategory?.toJson(), + }; + } } class DocumentCategory { @@ -196,6 +222,15 @@ class DocumentCategory { entityTypeId: json['entityTypeId'] ?? '', ); } + + Map toJson() { + return { + 'id': id, + 'name': name, + 'description': description, + 'entityTypeId': entityTypeId, + }; + } } class DocumentTag { @@ -210,4 +245,11 @@ class DocumentTag { isActive: json['isActive'] ?? false, ); } + + Map toJson() { + return { + 'name': name, + 'isActive': isActive, + }; + } } diff --git a/lib/model/document/document_edit_bottom_sheet.dart b/lib/model/document/document_edit_bottom_sheet.dart new file mode 100644 index 0000000..fa375dd --- /dev/null +++ b/lib/model/document/document_edit_bottom_sheet.dart @@ -0,0 +1,753 @@ +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"), + ], + ), + ), + ), + ], + ); +} diff --git a/lib/view/document/document_details_page.dart b/lib/view/document/document_details_page.dart index 8775c34..d7cea7b 100644 --- a/lib/view/document/document_details_page.dart +++ b/lib/view/document/document_details_page.dart @@ -10,6 +10,9 @@ import 'package:marco/model/document/document_details_model.dart'; import 'package:marco/controller/document/document_details_controller.dart'; import 'package:marco/helpers/widgets/custom_app_bar.dart'; import 'package:marco/helpers/widgets/my_snackbar.dart'; +import 'package:marco/model/document/document_edit_bottom_sheet.dart'; +import 'package:marco/controller/permission_controller.dart'; +import 'package:marco/helpers/utils/permission_constants.dart'; class DocumentDetailsPage extends StatefulWidget { final String documentId; @@ -23,7 +26,8 @@ class DocumentDetailsPage extends StatefulWidget { class _DocumentDetailsPageState extends State { final DocumentDetailsController controller = Get.put(DocumentDetailsController()); - + final PermissionController permissionController = + Get.find(); @override void initState() { super.initState(); @@ -72,8 +76,7 @@ class _DocumentDetailsPageState extends State { return MyRefreshIndicator( onRefresh: _onRefresh, child: SingleChildScrollView( - physics: - const AlwaysScrollableScrollPhysics(), + physics: const AlwaysScrollableScrollPhysics(), padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -148,12 +151,58 @@ class _DocumentDetailsPageState extends State { ], ), ), - IconButton( - icon: const Icon(Icons.edit, color: Colors.red), - onPressed: () { - - }, - ), + if (permissionController + .hasPermission(Permissions.modifyDocument)) + IconButton( + icon: const Icon(Icons.edit, color: Colors.red), + onPressed: () async { + // existing bottom sheet flow + await controller + .fetchDocumentVersions(doc.parentAttachmentId); + + final latestVersion = controller.versions.isNotEmpty + ? controller.versions.reduce((a, b) => + a.uploadedAt.isAfter(b.uploadedAt) ? a : b) + : null; + + final documentData = { + "id": doc.id, + "documentId": doc.documentId, + "name": doc.name, + "description": doc.description, + "tags": doc.tags + .map((t) => {"name": t.name, "isActive": t.isActive}) + .toList(), + "category": doc.documentType.documentCategory?.toJson(), + "type": doc.documentType.toJson(), + "attachment": latestVersion != null + ? { + "id": latestVersion.id, + "fileName": latestVersion.name, + "contentType": latestVersion.contentType, + "fileSize": latestVersion.fileSize, + } + : null, + }; + + showModalBottomSheet( + context: context, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: + BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (_) { + return DocumentEditBottomSheet( + documentData: documentData, + onSubmit: (updatedData) async { + await _fetchDetails(); + }, + ); + }, + ); + }, + ) ], ), MySpacing.height(12), @@ -220,21 +269,25 @@ class _DocumentDetailsPageState extends State { "Uploaded by ${version.uploadedBy.firstName} ${version.uploadedBy.lastName} • $uploadDate", color: Colors.grey.shade600, ), - trailing: IconButton( - icon: const Icon(Icons.open_in_new, color: Colors.blue), - onPressed: () async { - final url = await controller.fetchPresignedUrl(version.id); - if (url != null) { - _openDocument(url); - } else { - showAppSnackbar( - title: "Error", - message: "Failed to fetch document link", - type: SnackbarType.error, - ); - } - }, - ), + trailing: + permissionController.hasPermission(Permissions.viewDocument) + ? IconButton( + icon: const Icon(Icons.open_in_new, color: Colors.blue), + onPressed: () async { + final url = + await controller.fetchPresignedUrl(version.id); + if (url != null) { + _openDocument(url); + } else { + showAppSnackbar( + title: "Error", + message: "Failed to fetch document link", + type: SnackbarType.error, + ); + } + }, + ) + : null, ); }, ); diff --git a/lib/view/document/user_document_screen.dart b/lib/view/document/user_document_screen.dart index 2b87ecf..5a56e6e 100644 --- a/lib/view/document/user_document_screen.dart +++ b/lib/view/document/user_document_screen.dart @@ -16,6 +16,7 @@ import 'package:marco/view/document/document_details_page.dart'; import 'package:marco/helpers/widgets/custom_app_bar.dart'; import 'package:marco/helpers/widgets/my_confirmation_dialog.dart'; import 'package:marco/helpers/widgets/my_snackbar.dart'; +import 'package:marco/controller/permission_controller.dart'; class UserDocumentsPage extends StatefulWidget { final String? entityId; @@ -33,6 +34,8 @@ class UserDocumentsPage extends StatefulWidget { class _UserDocumentsPageState extends State { final DocumentController docController = Get.put(DocumentController()); + final PermissionController permissionController = + Get.find(); String get entityTypeId => widget.isEmployee ? Permissions.employeeEntity @@ -143,6 +146,7 @@ class _UserDocumentsPageState extends State { icon: const Icon(Icons.more_vert, color: Colors.black54), onSelected: (value) async { if (value == "delete") { + // existing delete flow (unchanged) final result = await showDialog( context: context, builder: (_) => ConfirmDialog( @@ -184,6 +188,7 @@ class _UserDocumentsPageState extends State { debugPrint("✅ Document deleted and removed from list"); } } else if (value == "activate") { + // existing activate flow (unchanged) final success = await docController.toggleDocumentActive( doc.id, isActive: true, @@ -207,12 +212,16 @@ class _UserDocumentsPageState extends State { } }, itemBuilder: (context) => [ - if (doc.isActive) + if (doc.isActive && + permissionController + .hasPermission(Permissions.deleteDocument)) const PopupMenuItem( value: "delete", child: Text("Delete"), ) - else + else if (!doc.isActive && + permissionController + .hasPermission(Permissions.modifyDocument)) const PopupMenuItem( value: "activate", child: Text("Activate"), @@ -322,7 +331,7 @@ class _UserDocumentsPageState extends State { borderRadius: BorderRadius.circular(10), ), child: IconButton( - padding: EdgeInsets.zero, + padding: EdgeInsets.zero, constraints: BoxConstraints(), icon: Icon( Icons.tune, @@ -455,6 +464,29 @@ class _UserDocumentsPageState extends State { } Widget _buildBody(BuildContext context) { + // 🔒 Check for viewDocument permission + if (!permissionController.hasPermission(Permissions.viewDocument)) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.lock_outline, size: 60, color: Colors.grey), + MySpacing.height(18), + MyText.titleMedium( + 'Access Denied', + fontWeight: 600, + color: Colors.grey, + ), + MySpacing.height(10), + MyText.bodySmall( + 'You do not have permission to view documents.', + color: Colors.grey, + ), + ], + ), + ); + } + return Obx(() { if (docController.isLoading.value && docController.documents.isEmpty) { return SingleChildScrollView( @@ -468,10 +500,7 @@ class _UserDocumentsPageState extends State { child: Column( children: [ _buildFilterRow(context), - - // 👇 Add this _buildStatusHeader(), - Expanded( child: MyRefreshIndicator( onRefresh: () async { @@ -523,7 +552,6 @@ class _UserDocumentsPageState extends State { @override Widget build(BuildContext context) { - // Conditionally show AppBar (example: hide if employee view) final bool showAppBar = !widget.isEmployee; return Scaffold( @@ -537,47 +565,51 @@ class _UserDocumentsPageState extends State { ) : null, body: _buildBody(context), - floatingActionButton: FloatingActionButton.extended( - onPressed: () { - final uploadController = Get.put(DocumentUploadController()); + floatingActionButton: permissionController + .hasPermission(Permissions.uploadDocument) + ? FloatingActionButton.extended( + onPressed: () { + final uploadController = Get.put(DocumentUploadController()); - showModalBottomSheet( - context: context, - isScrollControlled: true, - backgroundColor: Colors.transparent, - builder: (_) => DocumentUploadBottomSheet( - onSubmit: (data) async { - final success = await uploadController.uploadDocument( - name: data["name"], - description: data["description"], - documentId: data["documentId"], - entityId: resolvedEntityId, - documentTypeId: data["documentTypeId"], - fileName: data["attachment"]["fileName"], - base64Data: data["attachment"]["base64Data"], - contentType: data["attachment"]["contentType"], - fileSize: data["attachment"]["fileSize"], + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (_) => DocumentUploadBottomSheet( + onSubmit: (data) async { + final success = await uploadController.uploadDocument( + name: data["name"], + description: data["description"], + documentId: data["documentId"], + entityId: resolvedEntityId, + documentTypeId: data["documentTypeId"], + fileName: data["attachment"]["fileName"], + base64Data: data["attachment"]["base64Data"], + contentType: data["attachment"]["contentType"], + fileSize: data["attachment"]["fileSize"], + ); + + if (success) { + Navigator.pop(context); + docController.fetchDocuments( + entityTypeId: entityTypeId, + entityId: resolvedEntityId, + reset: true, + ); + } else { + Get.snackbar( + "Error", "Upload failed, please try again"); + } + }, + ), ); - - if (success) { - Navigator.pop(context); - docController.fetchDocuments( - entityTypeId: entityTypeId, - entityId: resolvedEntityId, - reset: true, - ); - } else { - Get.snackbar("Error", "Upload failed, please try again"); - } }, - ), - ); - }, - icon: const Icon(Icons.add, color: Colors.white), - label: MyText.bodyMedium("Add Document", - color: Colors.white, fontWeight: 600), - backgroundColor: Colors.red, - ), + icon: const Icon(Icons.add, color: Colors.white), + label: MyText.bodyMedium("Add Document", + color: Colors.white, fontWeight: 600), + backgroundColor: Colors.red, + ) + : null, floatingActionButtonLocation: FloatingActionButtonLocation.endFloat, ); }