feat: Implement document editing functionality with permissions and attachment handling

This commit is contained in:
Vaibhav Surve 2025-09-08 18:00:07 +05:30
parent bf84ef4786
commit 99bd26942c
7 changed files with 1020 additions and 85 deletions

View File

@ -68,6 +68,10 @@ class DocumentUploadController extends GetxController {
}
}
Future<String?> fetchPresignedUrl(String versionId) async {
return await ApiService.getPresignedUrlApi(versionId);
}
/// Fetch available document tags
Future<void> fetchTags() async {
try {
@ -188,4 +192,48 @@ class DocumentUploadController extends GetxController {
isUploading.value = false;
}
}
Future<bool> editDocument(Map<String, dynamic> 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<Map<String, dynamic>>(),
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;
}
}
}

View File

@ -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<Map<String, dynamic>> tags = const [],
Map<String, dynamic>? 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 {

View File

@ -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";
}

View File

@ -130,19 +130,30 @@ class UploadedBy {
jobRoleName: json['jobRoleName'] ?? '',
);
}
Map<String, dynamic> 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<String, dynamic> 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<String, dynamic> toJson() {
return {
'id': id,
'name': name,
'description': description,
'entityTypeId': entityTypeId,
};
}
}
class DocumentTag {
@ -210,4 +245,11 @@ class DocumentTag {
isActive: json['isActive'] ?? false,
);
}
Map<String, dynamic> toJson() {
return {
'name': name,
'isActive': isActive,
};
}
}

View File

@ -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<String, dynamic> documentData;
final Function(Map<String, dynamic>) onSubmit;
const DocumentEditBottomSheet({
Key? key,
required this.documentData,
required this.onSubmit,
}) : super(key: key);
@override
State<DocumentEditBottomSheet> createState() =>
_DocumentEditBottomSheetState();
}
class _DocumentEditBottomSheetState extends State<DocumentEditBottomSheet> {
final _formKey = GlobalKey<FormState>();
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<String>.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<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;
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<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"),
],
),
),
),
],
);
}

View File

@ -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<DocumentDetailsPage> {
final DocumentDetailsController controller =
Get.put(DocumentDetailsController());
final PermissionController permissionController =
Get.find<PermissionController>();
@override
void initState() {
super.initState();
@ -72,8 +76,7 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
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<DocumentDetailsPage> {
],
),
),
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<DocumentDetailsPage> {
"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,
);
},
);

View File

@ -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<UserDocumentsPage> {
final DocumentController docController = Get.put(DocumentController());
final PermissionController permissionController =
Get.find<PermissionController>();
String get entityTypeId => widget.isEmployee
? Permissions.employeeEntity
@ -143,6 +146,7 @@ class _UserDocumentsPageState extends State<UserDocumentsPage> {
icon: const Icon(Icons.more_vert, color: Colors.black54),
onSelected: (value) async {
if (value == "delete") {
// existing delete flow (unchanged)
final result = await showDialog<bool>(
context: context,
builder: (_) => ConfirmDialog(
@ -184,6 +188,7 @@ class _UserDocumentsPageState extends State<UserDocumentsPage> {
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<UserDocumentsPage> {
}
},
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<UserDocumentsPage> {
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<UserDocumentsPage> {
}
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<UserDocumentsPage> {
child: Column(
children: [
_buildFilterRow(context),
// 👇 Add this
_buildStatusHeader(),
Expanded(
child: MyRefreshIndicator(
onRefresh: () async {
@ -523,7 +552,6 @@ class _UserDocumentsPageState extends State<UserDocumentsPage> {
@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<UserDocumentsPage> {
)
: 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,
);
}