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 /// 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;
}
}
} }

View File

@ -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 {

View File

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

View File

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

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/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,
); );
}, },
); );

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/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,
); );
} }