feat: Implement document editing functionality with permissions and attachment handling
This commit is contained in:
parent
bf84ef4786
commit
99bd26942c
@ -68,6 +68,10 @@ class DocumentUploadController extends GetxController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<String?> fetchPresignedUrl(String versionId) async {
|
||||||
|
return await ApiService.getPresignedUrlApi(versionId);
|
||||||
|
}
|
||||||
|
|
||||||
/// Fetch available document tags
|
/// Fetch available document tags
|
||||||
Future<void> fetchTags() async {
|
Future<void> fetchTags() async {
|
||||||
try {
|
try {
|
||||||
@ -188,4 +192,48 @@ class DocumentUploadController extends GetxController {
|
|||||||
isUploading.value = false;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -332,13 +332,8 @@ class ApiService {
|
|||||||
required String name,
|
required String name,
|
||||||
required String documentId,
|
required String documentId,
|
||||||
String? description,
|
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 [],
|
List<Map<String, dynamic>> tags = const [],
|
||||||
|
Map<String, dynamic>? attachment, // 👈 can be null
|
||||||
}) async {
|
}) async {
|
||||||
final endpoint = "${ApiEndpoints.editDocument}/$id";
|
final endpoint = "${ApiEndpoints.editDocument}/$id";
|
||||||
logSafe("Editing document with id: $id");
|
logSafe("Editing document with id: $id");
|
||||||
@ -348,19 +343,12 @@ class ApiService {
|
|||||||
"name": name,
|
"name": name,
|
||||||
"documentId": documentId,
|
"documentId": documentId,
|
||||||
"description": description ?? "",
|
"description": description ?? "",
|
||||||
"attachment": {
|
|
||||||
"fileName": fileName,
|
|
||||||
"base64Data": base64Data,
|
|
||||||
"contentType": contentType,
|
|
||||||
"fileSize": fileSize,
|
|
||||||
"description": fileDescription ?? "",
|
|
||||||
"isActive": isActive,
|
|
||||||
},
|
|
||||||
"tags": tags.isNotEmpty
|
"tags": tags.isNotEmpty
|
||||||
? tags
|
? tags
|
||||||
: [
|
: [
|
||||||
{"name": "default", "isActive": true}
|
{"name": "default", "isActive": true}
|
||||||
],
|
],
|
||||||
|
"attachment": attachment, // 👈 null or object
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
@ -98,4 +98,23 @@ class Permissions {
|
|||||||
|
|
||||||
/// Entity ID for employee documents
|
/// Entity ID for employee documents
|
||||||
static const String employeeEntity = "dbb9555a-7a0c-40f2-a9ed-f0463f1ceed7";
|
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";
|
||||||
}
|
}
|
||||||
|
@ -130,19 +130,30 @@ class UploadedBy {
|
|||||||
jobRoleName: json['jobRoleName'] ?? '',
|
jobRoleName: json['jobRoleName'] ?? '',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'id': id,
|
||||||
|
'firstName': firstName,
|
||||||
|
'lastName': lastName,
|
||||||
|
'photo': photo,
|
||||||
|
'jobRoleId': jobRoleId,
|
||||||
|
'jobRoleName': jobRoleName,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class DocumentType {
|
class DocumentType {
|
||||||
final String id;
|
final String id;
|
||||||
final String name;
|
final String name;
|
||||||
final String? regexExpression; // nullable
|
final String? regexExpression;
|
||||||
final String allowedContentType;
|
final String allowedContentType;
|
||||||
final int maxSizeAllowedInMB;
|
final int maxSizeAllowedInMB;
|
||||||
final bool isValidationRequired;
|
final bool isValidationRequired;
|
||||||
final bool isMandatory;
|
final bool isMandatory;
|
||||||
final bool isSystem;
|
final bool isSystem;
|
||||||
final bool isActive;
|
final bool isActive;
|
||||||
final DocumentCategory? documentCategory; // nullable
|
final DocumentCategory? documentCategory;
|
||||||
|
|
||||||
DocumentType({
|
DocumentType({
|
||||||
required this.id,
|
required this.id,
|
||||||
@ -173,6 +184,21 @@ class DocumentType {
|
|||||||
: null,
|
: 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 {
|
class DocumentCategory {
|
||||||
@ -196,6 +222,15 @@ class DocumentCategory {
|
|||||||
entityTypeId: json['entityTypeId'] ?? '',
|
entityTypeId: json['entityTypeId'] ?? '',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'id': id,
|
||||||
|
'name': name,
|
||||||
|
'description': description,
|
||||||
|
'entityTypeId': entityTypeId,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class DocumentTag {
|
class DocumentTag {
|
||||||
@ -210,4 +245,11 @@ class DocumentTag {
|
|||||||
isActive: json['isActive'] ?? false,
|
isActive: json['isActive'] ?? false,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'name': name,
|
||||||
|
'isActive': isActive,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
753
lib/model/document/document_edit_bottom_sheet.dart
Normal file
753
lib/model/document/document_edit_bottom_sheet.dart
Normal 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"),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
@ -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/controller/document/document_details_controller.dart';
|
||||||
import 'package:marco/helpers/widgets/custom_app_bar.dart';
|
import 'package:marco/helpers/widgets/custom_app_bar.dart';
|
||||||
import 'package:marco/helpers/widgets/my_snackbar.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 {
|
class DocumentDetailsPage extends StatefulWidget {
|
||||||
final String documentId;
|
final String documentId;
|
||||||
@ -23,7 +26,8 @@ class DocumentDetailsPage extends StatefulWidget {
|
|||||||
class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
|
class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
|
||||||
final DocumentDetailsController controller =
|
final DocumentDetailsController controller =
|
||||||
Get.put(DocumentDetailsController());
|
Get.put(DocumentDetailsController());
|
||||||
|
final PermissionController permissionController =
|
||||||
|
Get.find<PermissionController>();
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
@ -72,8 +76,7 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
|
|||||||
return MyRefreshIndicator(
|
return MyRefreshIndicator(
|
||||||
onRefresh: _onRefresh,
|
onRefresh: _onRefresh,
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
physics:
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
const AlwaysScrollableScrollPhysics(),
|
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@ -148,12 +151,58 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
IconButton(
|
if (permissionController
|
||||||
icon: const Icon(Icons.edit, color: Colors.red),
|
.hasPermission(Permissions.modifyDocument))
|
||||||
onPressed: () {
|
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),
|
MySpacing.height(12),
|
||||||
@ -220,21 +269,25 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
|
|||||||
"Uploaded by ${version.uploadedBy.firstName} ${version.uploadedBy.lastName} • $uploadDate",
|
"Uploaded by ${version.uploadedBy.firstName} ${version.uploadedBy.lastName} • $uploadDate",
|
||||||
color: Colors.grey.shade600,
|
color: Colors.grey.shade600,
|
||||||
),
|
),
|
||||||
trailing: IconButton(
|
trailing:
|
||||||
icon: const Icon(Icons.open_in_new, color: Colors.blue),
|
permissionController.hasPermission(Permissions.viewDocument)
|
||||||
onPressed: () async {
|
? IconButton(
|
||||||
final url = await controller.fetchPresignedUrl(version.id);
|
icon: const Icon(Icons.open_in_new, color: Colors.blue),
|
||||||
if (url != null) {
|
onPressed: () async {
|
||||||
_openDocument(url);
|
final url =
|
||||||
} else {
|
await controller.fetchPresignedUrl(version.id);
|
||||||
showAppSnackbar(
|
if (url != null) {
|
||||||
title: "Error",
|
_openDocument(url);
|
||||||
message: "Failed to fetch document link",
|
} else {
|
||||||
type: SnackbarType.error,
|
showAppSnackbar(
|
||||||
);
|
title: "Error",
|
||||||
}
|
message: "Failed to fetch document link",
|
||||||
},
|
type: SnackbarType.error,
|
||||||
),
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
: null,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
@ -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/custom_app_bar.dart';
|
||||||
import 'package:marco/helpers/widgets/my_confirmation_dialog.dart';
|
import 'package:marco/helpers/widgets/my_confirmation_dialog.dart';
|
||||||
import 'package:marco/helpers/widgets/my_snackbar.dart';
|
import 'package:marco/helpers/widgets/my_snackbar.dart';
|
||||||
|
import 'package:marco/controller/permission_controller.dart';
|
||||||
|
|
||||||
class UserDocumentsPage extends StatefulWidget {
|
class UserDocumentsPage extends StatefulWidget {
|
||||||
final String? entityId;
|
final String? entityId;
|
||||||
@ -33,6 +34,8 @@ class UserDocumentsPage extends StatefulWidget {
|
|||||||
|
|
||||||
class _UserDocumentsPageState extends State<UserDocumentsPage> {
|
class _UserDocumentsPageState extends State<UserDocumentsPage> {
|
||||||
final DocumentController docController = Get.put(DocumentController());
|
final DocumentController docController = Get.put(DocumentController());
|
||||||
|
final PermissionController permissionController =
|
||||||
|
Get.find<PermissionController>();
|
||||||
|
|
||||||
String get entityTypeId => widget.isEmployee
|
String get entityTypeId => widget.isEmployee
|
||||||
? Permissions.employeeEntity
|
? Permissions.employeeEntity
|
||||||
@ -143,6 +146,7 @@ class _UserDocumentsPageState extends State<UserDocumentsPage> {
|
|||||||
icon: const Icon(Icons.more_vert, color: Colors.black54),
|
icon: const Icon(Icons.more_vert, color: Colors.black54),
|
||||||
onSelected: (value) async {
|
onSelected: (value) async {
|
||||||
if (value == "delete") {
|
if (value == "delete") {
|
||||||
|
// existing delete flow (unchanged)
|
||||||
final result = await showDialog<bool>(
|
final result = await showDialog<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (_) => ConfirmDialog(
|
builder: (_) => ConfirmDialog(
|
||||||
@ -184,6 +188,7 @@ class _UserDocumentsPageState extends State<UserDocumentsPage> {
|
|||||||
debugPrint("✅ Document deleted and removed from list");
|
debugPrint("✅ Document deleted and removed from list");
|
||||||
}
|
}
|
||||||
} else if (value == "activate") {
|
} else if (value == "activate") {
|
||||||
|
// existing activate flow (unchanged)
|
||||||
final success = await docController.toggleDocumentActive(
|
final success = await docController.toggleDocumentActive(
|
||||||
doc.id,
|
doc.id,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
@ -207,12 +212,16 @@ class _UserDocumentsPageState extends State<UserDocumentsPage> {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
itemBuilder: (context) => [
|
itemBuilder: (context) => [
|
||||||
if (doc.isActive)
|
if (doc.isActive &&
|
||||||
|
permissionController
|
||||||
|
.hasPermission(Permissions.deleteDocument))
|
||||||
const PopupMenuItem(
|
const PopupMenuItem(
|
||||||
value: "delete",
|
value: "delete",
|
||||||
child: Text("Delete"),
|
child: Text("Delete"),
|
||||||
)
|
)
|
||||||
else
|
else if (!doc.isActive &&
|
||||||
|
permissionController
|
||||||
|
.hasPermission(Permissions.modifyDocument))
|
||||||
const PopupMenuItem(
|
const PopupMenuItem(
|
||||||
value: "activate",
|
value: "activate",
|
||||||
child: Text("Activate"),
|
child: Text("Activate"),
|
||||||
@ -322,7 +331,7 @@ class _UserDocumentsPageState extends State<UserDocumentsPage> {
|
|||||||
borderRadius: BorderRadius.circular(10),
|
borderRadius: BorderRadius.circular(10),
|
||||||
),
|
),
|
||||||
child: IconButton(
|
child: IconButton(
|
||||||
padding: EdgeInsets.zero,
|
padding: EdgeInsets.zero,
|
||||||
constraints: BoxConstraints(),
|
constraints: BoxConstraints(),
|
||||||
icon: Icon(
|
icon: Icon(
|
||||||
Icons.tune,
|
Icons.tune,
|
||||||
@ -455,6 +464,29 @@ class _UserDocumentsPageState extends State<UserDocumentsPage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildBody(BuildContext context) {
|
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(() {
|
return Obx(() {
|
||||||
if (docController.isLoading.value && docController.documents.isEmpty) {
|
if (docController.isLoading.value && docController.documents.isEmpty) {
|
||||||
return SingleChildScrollView(
|
return SingleChildScrollView(
|
||||||
@ -468,10 +500,7 @@ class _UserDocumentsPageState extends State<UserDocumentsPage> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
_buildFilterRow(context),
|
_buildFilterRow(context),
|
||||||
|
|
||||||
// 👇 Add this
|
|
||||||
_buildStatusHeader(),
|
_buildStatusHeader(),
|
||||||
|
|
||||||
Expanded(
|
Expanded(
|
||||||
child: MyRefreshIndicator(
|
child: MyRefreshIndicator(
|
||||||
onRefresh: () async {
|
onRefresh: () async {
|
||||||
@ -523,7 +552,6 @@ class _UserDocumentsPageState extends State<UserDocumentsPage> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
// Conditionally show AppBar (example: hide if employee view)
|
|
||||||
final bool showAppBar = !widget.isEmployee;
|
final bool showAppBar = !widget.isEmployee;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
@ -537,47 +565,51 @@ class _UserDocumentsPageState extends State<UserDocumentsPage> {
|
|||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
body: _buildBody(context),
|
body: _buildBody(context),
|
||||||
floatingActionButton: FloatingActionButton.extended(
|
floatingActionButton: permissionController
|
||||||
onPressed: () {
|
.hasPermission(Permissions.uploadDocument)
|
||||||
final uploadController = Get.put(DocumentUploadController());
|
? FloatingActionButton.extended(
|
||||||
|
onPressed: () {
|
||||||
|
final uploadController = Get.put(DocumentUploadController());
|
||||||
|
|
||||||
showModalBottomSheet(
|
showModalBottomSheet(
|
||||||
context: context,
|
context: context,
|
||||||
isScrollControlled: true,
|
isScrollControlled: true,
|
||||||
backgroundColor: Colors.transparent,
|
backgroundColor: Colors.transparent,
|
||||||
builder: (_) => DocumentUploadBottomSheet(
|
builder: (_) => DocumentUploadBottomSheet(
|
||||||
onSubmit: (data) async {
|
onSubmit: (data) async {
|
||||||
final success = await uploadController.uploadDocument(
|
final success = await uploadController.uploadDocument(
|
||||||
name: data["name"],
|
name: data["name"],
|
||||||
description: data["description"],
|
description: data["description"],
|
||||||
documentId: data["documentId"],
|
documentId: data["documentId"],
|
||||||
entityId: resolvedEntityId,
|
entityId: resolvedEntityId,
|
||||||
documentTypeId: data["documentTypeId"],
|
documentTypeId: data["documentTypeId"],
|
||||||
fileName: data["attachment"]["fileName"],
|
fileName: data["attachment"]["fileName"],
|
||||||
base64Data: data["attachment"]["base64Data"],
|
base64Data: data["attachment"]["base64Data"],
|
||||||
contentType: data["attachment"]["contentType"],
|
contentType: data["attachment"]["contentType"],
|
||||||
fileSize: data["attachment"]["fileSize"],
|
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),
|
||||||
icon: const Icon(Icons.add, color: Colors.white),
|
backgroundColor: Colors.red,
|
||||||
label: MyText.bodyMedium("Add Document",
|
)
|
||||||
color: Colors.white, fontWeight: 600),
|
: null,
|
||||||
backgroundColor: Colors.red,
|
|
||||||
),
|
|
||||||
floatingActionButtonLocation: FloatingActionButtonLocation.endFloat,
|
floatingActionButtonLocation: FloatingActionButtonLocation.endFloat,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user