From 334023bf1b492eca42759fecd02edd908ecf02a5 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Thu, 4 Sep 2025 16:56:49 +0530 Subject: [PATCH] feat: Add document management features including document listing, details, and filtering - Implemented DocumentsResponse and related models for handling document data. - Created UserDocumentsPage for displaying user-specific documents with filtering options. - Developed DocumentDetailsPage to show detailed information about a selected document. - Added functionality for uploading documents with DocumentUploadBottomSheet. - Integrated document filtering through UserDocumentFilterBottomSheet. - Enhanced dashboard to include navigation to the document management section. - Updated user profile right bar to provide quick access to user documents. --- .../document/document_details_controller.dart | 58 ++ .../document/document_upload_controller.dart | 191 +++++ .../document/user_document_controller.dart | 98 +++ lib/helpers/services/api_endpoints.dart | 28 +- lib/helpers/services/api_service.dart | 341 ++++++++ lib/helpers/utils/permission_constants.dart | 9 +- lib/helpers/widgets/custom_app_bar.dart | 89 ++ lib/helpers/widgets/my_custom_skeleton.dart | 256 ++++++ .../document/document_details_model.dart | 213 +++++ lib/model/document/document_filter_model.dart | 105 +++ .../document_upload_bottom_sheet.dart | 773 ++++++++++++++++++ .../document/document_version_model.dart | 138 ++++ lib/model/document/documents_list_model.dart | 273 +++++++ .../master_document_category_list_model.dart | 31 + lib/model/document/master_document_tags.dart | 69 ++ .../document/master_document_type_model.dart | 94 +++ .../user_document_filter_bottom_sheet.dart | 179 ++++ lib/routes.dart | 6 + lib/view/dashboard/dashboard_screen.dart | 122 +-- lib/view/document/document_details_page.dart | 287 +++++++ .../document/project_document_screen.dart | 0 lib/view/document/user_document_screen.dart | 317 +++++++ lib/view/layouts/user_profile_right_bar.dart | 15 + 23 files changed, 3631 insertions(+), 61 deletions(-) create mode 100644 lib/controller/document/document_details_controller.dart create mode 100644 lib/controller/document/document_upload_controller.dart create mode 100644 lib/controller/document/user_document_controller.dart create mode 100644 lib/helpers/widgets/custom_app_bar.dart create mode 100644 lib/model/document/document_details_model.dart create mode 100644 lib/model/document/document_filter_model.dart create mode 100644 lib/model/document/document_upload_bottom_sheet.dart create mode 100644 lib/model/document/document_version_model.dart create mode 100644 lib/model/document/documents_list_model.dart create mode 100644 lib/model/document/master_document_category_list_model.dart create mode 100644 lib/model/document/master_document_tags.dart create mode 100644 lib/model/document/master_document_type_model.dart create mode 100644 lib/model/document/user_document_filter_bottom_sheet.dart create mode 100644 lib/view/document/document_details_page.dart create mode 100644 lib/view/document/project_document_screen.dart create mode 100644 lib/view/document/user_document_screen.dart diff --git a/lib/controller/document/document_details_controller.dart b/lib/controller/document/document_details_controller.dart new file mode 100644 index 0000000..6abd95f --- /dev/null +++ b/lib/controller/document/document_details_controller.dart @@ -0,0 +1,58 @@ +import 'package:get/get.dart'; +import 'package:marco/helpers/services/api_service.dart'; +import 'package:marco/model/document/document_details_model.dart'; +import 'package:marco/model/document/document_version_model.dart'; + +class DocumentDetailsController extends GetxController { + /// Observables + var isLoading = false.obs; + var documentDetails = Rxn(); + + var versions = [].obs; + var isVersionsLoading = false.obs; + + /// Fetch document details by id + Future fetchDocumentDetails(String documentId) async { + try { + isLoading.value = true; + final response = await ApiService.getDocumentDetailsApi(documentId); + + if (response != null) { + documentDetails.value = response; + } else { + documentDetails.value = null; + } + } finally { + isLoading.value = false; + } + } + + /// Fetch document versions by parentAttachmentId + Future fetchDocumentVersions(String parentAttachmentId) async { + try { + isVersionsLoading.value = true; + final response = await ApiService.getDocumentVersionsApi( + parentAttachmentId: parentAttachmentId, + ); + + if (response != null) { + versions.assignAll(response.data.data); + } else { + versions.clear(); + } + } finally { + isVersionsLoading.value = false; + } + } + + /// Fetch Pre-Signed URL for a given version + Future fetchPresignedUrl(String versionId) async { + return await ApiService.getPresignedUrlApi(versionId); + } + + /// Clear data when leaving the screen + void clearDetails() { + documentDetails.value = null; + versions.clear(); + } +} diff --git a/lib/controller/document/document_upload_controller.dart b/lib/controller/document/document_upload_controller.dart new file mode 100644 index 0000000..f698d41 --- /dev/null +++ b/lib/controller/document/document_upload_controller.dart @@ -0,0 +1,191 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:marco/helpers/services/app_logger.dart'; +import 'package:marco/helpers/services/api_service.dart'; +import 'package:marco/model/document/master_document_type_model.dart'; +import 'package:marco/model/document/master_document_tags.dart'; +import 'package:marco/helpers/widgets/my_snackbar.dart'; + +class DocumentUploadController extends GetxController { + // Observables + var isLoading = false.obs; + var isUploading = false.obs; + + var categories = [].obs; + var tags = [].obs; + + DocumentType? selectedCategory; + + /// --- FILE HANDLING --- + String? selectedFileName; + String? selectedFileBase64; + String? selectedFileContentType; + int? selectedFileSize; + + /// --- TAG HANDLING --- + final tagCtrl = TextEditingController(); + final enteredTags = [].obs; + final filteredSuggestions = [].obs; + var documentTypes = [].obs; + DocumentType? selectedType; + @override + void onInit() { + super.onInit(); + fetchCategories(); + fetchTags(); + } + + /// Fetch available document categories + Future fetchCategories() async { + try { + isLoading.value = true; + final response = await ApiService.getMasterDocumentTypesApi(); + if (response != null && response.data.isNotEmpty) { + categories.assignAll(response.data); + logSafe("Fetched categories: ${categories.length}"); + } else { + logSafe("No categories fetched", level: LogLevel.warning); + } + } finally { + isLoading.value = false; + } + } + + Future fetchDocumentTypes(String categoryId) async { + try { + isLoading.value = true; + final response = + await ApiService.getDocumentTypesByCategoryApi(categoryId); + if (response != null && response.data.isNotEmpty) { + documentTypes.assignAll(response.data); + selectedType = null; // reset previous type + } else { + documentTypes.clear(); + selectedType = null; + } + } finally { + isLoading.value = false; + } + } + + /// Fetch available document tags + Future fetchTags() async { + try { + isLoading.value = true; + final response = await ApiService.getMasterDocumentTagsApi(); + if (response != null) { + tags.assignAll(response.data); + logSafe("Fetched tags: ${tags.length}"); + } else { + logSafe("No tags fetched", level: LogLevel.warning); + } + } finally { + isLoading.value = false; + } + } + + /// --- TAG LOGIC --- + void filterSuggestions(String query) { + if (query.isEmpty) { + filteredSuggestions.clear(); + return; + } + filteredSuggestions.assignAll( + tags.map((t) => t.name).where( + (tag) => tag.toLowerCase().contains(query.toLowerCase()), + ), + ); + } + + void addEnteredTag(String tag) { + if (tag.trim().isEmpty) return; + if (!enteredTags.contains(tag.trim())) { + enteredTags.add(tag.trim()); + } + } + + void removeEnteredTag(String tag) { + enteredTags.remove(tag); + } + + void clearSuggestions() { + filteredSuggestions.clear(); + } + + /// Upload document + Future uploadDocument({ + required String documentId, + required String name, + required String entityId, + required String documentTypeId, + required String fileName, + required String base64Data, + required String contentType, + required int fileSize, + String? description, + }) async { + try { + isUploading.value = true; + + final payloadTags = + enteredTags.map((t) => {"name": t, "isActive": true}).toList(); + + final payload = { + "documentId": documentId, + "name": name, + "description": description, + "entityId": entityId, + "documentTypeId": documentTypeId, + "fileName": fileName, + "base64Data": + base64Data.isNotEmpty ? "" : null, + "contentType": contentType, + "fileSize": fileSize, + "tags": payloadTags, + }; + + // Log the payload (hide long base64 string for readability) + logSafe("Upload payload: $payload"); + + final success = await ApiService.uploadDocumentApi( + documentId: documentId, + name: name, + description: description, + entityId: entityId, + documentTypeId: documentTypeId, + fileName: fileName, + base64Data: base64Data, + contentType: contentType, + fileSize: fileSize, + tags: payloadTags, + ); + + if (success) { + showAppSnackbar( + title: "Success", + message: "Document uploaded successfully", + type: SnackbarType.success, + ); + } else { + showAppSnackbar( + title: "Error", + message: "Could not upload document", + type: SnackbarType.error, + ); + } + + return success; + } catch (e, stack) { + logSafe("Upload error: $e", level: LogLevel.error); + logSafe("Stacktrace: $stack", level: LogLevel.debug); + showAppSnackbar( + title: "Error", + message: "An unexpected error occurred", + type: SnackbarType.error, + ); + return false; + } finally { + isUploading.value = false; + } + } +} diff --git a/lib/controller/document/user_document_controller.dart b/lib/controller/document/user_document_controller.dart new file mode 100644 index 0000000..8b4045b --- /dev/null +++ b/lib/controller/document/user_document_controller.dart @@ -0,0 +1,98 @@ +import 'package:get/get.dart'; +import 'package:marco/helpers/services/api_service.dart'; +import 'package:marco/model/document/document_filter_model.dart'; +import 'package:marco/model/document/documents_list_model.dart'; + +class DocumentController extends GetxController { + // ------------------ Observables --------------------- + var isLoading = false.obs; + var documents = [].obs; + var filters = Rxn(); + var selectedFilter = "".obs; + var selectedUploadedBy = "".obs; + var selectedCategory = "".obs; + var selectedType = "".obs; + var selectedTag = "".obs; + // Pagination state + var pageNumber = 1.obs; + final int pageSize = 20; + var hasMore = true.obs; + + // Error message + var errorMessage = "".obs; + + // ------------------ API Calls ----------------------- + + /// Fetch Document Filters for an Entity + Future fetchFilters(String entityTypeId) async { + try { + isLoading.value = true; + final response = await ApiService.getDocumentFilters(entityTypeId); + + if (response != null && response.success) { + filters.value = response.data; + } else { + errorMessage.value = response?.message ?? "Failed to fetch filters"; + } + } catch (e) { + errorMessage.value = "Error fetching filters: $e"; + } finally { + isLoading.value = false; + } + } + + /// Fetch Documents for an entity + Future fetchDocuments({ + required String entityTypeId, + required String entityId, + String filter = "", + String searchString = "", + bool reset = false, + }) async { + try { + if (reset) { + pageNumber.value = 1; + documents.clear(); + hasMore.value = true; + } + + if (!hasMore.value) return; + + isLoading.value = true; + + final response = await ApiService.getDocumentListApi( + entityTypeId: entityTypeId, + entityId: entityId, + filter: filter, + searchString: searchString, + pageNumber: pageNumber.value, + pageSize: pageSize, + ); + + if (response != null && response.success) { + if (response.data.data.isNotEmpty) { + documents.addAll(response.data.data); + pageNumber.value++; + } else { + hasMore.value = false; + } + } else { + errorMessage.value = response?.message ?? "Failed to fetch documents"; + } + } catch (e) { + errorMessage.value = "Error fetching documents: $e"; + } finally { + isLoading.value = false; + } + } + + // ------------------ Helpers ----------------------- + + /// Clear selected filters + void clearFilters() { + selectedUploadedBy.value = ""; + selectedCategory.value = ""; + selectedType.value = ""; + selectedTag.value = ""; + } +} diff --git a/lib/helpers/services/api_endpoints.dart b/lib/helpers/services/api_endpoints.dart index 9bf8e59..0bf1092 100644 --- a/lib/helpers/services/api_endpoints.dart +++ b/lib/helpers/services/api_endpoints.dart @@ -1,15 +1,15 @@ class ApiEndpoints { - // static const String baseUrl = "https://stageapi.marcoaiot.com/api"; - static const String baseUrl = "https://api.marcoaiot.com/api"; - - // Dashboard Module API Endpoints - static const String getDashboardAttendanceOverview = "/dashboard/attendance-overview"; + static const String baseUrl = "https://stageapi.marcoaiot.com/api"; + // static const String baseUrl = "https://api.marcoaiot.com/api"; +// + // Dashboard Module API Endpoints + static const String getDashboardAttendanceOverview = + "/dashboard/attendance-overview"; static const String getDashboardProjectProgress = "/dashboard/progression"; static const String getDashboardTasks = "/dashboard/tasks"; static const String getDashboardTeams = "/dashboard/teams"; static const String getDashboardProjects = "/dashboard/projects"; - // Attendance Module API Endpoints static const String getProjects = "/project/list"; static const String getGlobalProjects = "/project/list/basic"; @@ -45,7 +45,8 @@ class ApiEndpoints { static const String getDirectoryContacts = "/directory"; static const String getDirectoryBucketList = "/directory/buckets"; static const String getDirectoryContactDetail = "/directory/notes"; - static const String getDirectoryContactCategory = "/master/contact-categories"; + static const String getDirectoryContactCategory = + "/master/contact-categories"; static const String getDirectoryContactTags = "/master/contact-tags"; static const String getDirectoryOrganization = "/directory/organization"; static const String createContact = "/directory"; @@ -70,4 +71,17 @@ class ApiEndpoints { ////// Dynamic Menu Module API Endpoints static const String getDynamicMenu = "/appmenu/get/menu-mobile"; + + ///// Document Module API Endpoints + static const String getMasterDocumentCategories = + "/master/document-category/list"; + static const String getMasterDocumentTags = "/document/get/tags"; + static const String getDocumentList = "/document/list"; + static const String getDocumentDetails = "/document/get/details"; + static const String uploadDocument = "/document/upload"; + static const String deleteDocument = "/document/delete"; + static const String getDocumentFilter = "/document/get/filter"; + static const String getDocumentTypesByCategory = "/master/document-type/list"; + static const String getDocumentVersion = "/document/get/version"; + static const String getDocumentVersions = "/document/list/versions"; } diff --git a/lib/helpers/services/api_service.dart b/lib/helpers/services/api_service.dart index 4c64820..078be53 100644 --- a/lib/helpers/services/api_service.dart +++ b/lib/helpers/services/api_service.dart @@ -12,6 +12,13 @@ import 'package:marco/model/dashboard/project_progress_model.dart'; import 'package:marco/model/dashboard/dashboard_tasks_model.dart'; import 'package:marco/model/dashboard/dashboard_teams_model.dart'; import 'package:marco/helpers/services/app_logger.dart'; +import 'package:marco/model/document/document_filter_model.dart'; +import 'package:marco/model/document/documents_list_model.dart'; +import 'package:marco/model/document/master_document_tags.dart'; +import 'package:marco/model/document/master_document_type_model.dart'; +import 'package:marco/model/document/document_details_model.dart'; +import 'package:marco/model/document/document_version_model.dart'; + class ApiService { static const Duration timeout = Duration(seconds: 30); @@ -240,6 +247,340 @@ class ApiService { return null; } } + /// Get Pre-Signed URL for Old Version + static Future getPresignedUrlApi(String versionId) async { + final endpoint = "${ApiEndpoints.getDocumentVersion}/$versionId"; + logSafe("Fetching Pre-Signed URL for versionId: $versionId"); + + try { + final response = await _getRequest(endpoint); + + if (response == null) { + logSafe("Pre-Signed URL request failed: null response", + level: LogLevel.error); + return null; + } + + final jsonResponse = + _parseResponseForAllData(response, label: "Pre-Signed URL"); + + if (jsonResponse != null) { + return jsonResponse['data'] as String?; + } + } catch (e, stack) { + logSafe("Exception during getPresignedUrlApi: $e", + level: LogLevel.error); + logSafe("StackTrace: $stack", level: LogLevel.debug); + } + + return null; + } + + /// Get List of Versions by ParentAttachmentId + static Future getDocumentVersionsApi({ + required String parentAttachmentId, + int pageNumber = 1, + int pageSize = 20, + }) async { + final endpoint = + "${ApiEndpoints.getDocumentVersions}/$parentAttachmentId"; + final queryParams = { + "pageNumber": pageNumber.toString(), + "pageSize": pageSize.toString(), + }; + + logSafe( + "Fetching document versions for parentAttachmentId: $parentAttachmentId"); + + try { + final response = + await _getRequest(endpoint, queryParams: queryParams); + + if (response == null) { + logSafe("Document versions request failed: null response", + level: LogLevel.error); + return null; + } + + final jsonResponse = + _parseResponseForAllData(response, label: "Document Versions"); + + if (jsonResponse != null) { + return DocumentVersionsResponse.fromJson(jsonResponse); + } + } catch (e, stack) { + logSafe("Exception during getDocumentVersionsApi: $e", + level: LogLevel.error); + logSafe("StackTrace: $stack", level: LogLevel.debug); + } + + return null; + } + + /// Get Document Details by ID + static Future getDocumentDetailsApi( + String documentId) async { + final endpoint = "${ApiEndpoints.getDocumentDetails}/$documentId"; + logSafe("Fetching document details for id: $documentId"); + + try { + final response = await _getRequest(endpoint); + + if (response == null) { + logSafe("Document details request failed: null response", + level: LogLevel.error); + return null; + } + + final jsonResponse = + _parseResponseForAllData(response, label: "Document Details"); + + if (jsonResponse != null) { + return DocumentDetailsResponse.fromJson(jsonResponse); + } + } catch (e, stack) { + logSafe("Exception during getDocumentDetailsApi: $e", + level: LogLevel.error); + logSafe("StackTrace: $stack", level: LogLevel.debug); + } + + return null; + } + + /// Get Document Types by CategoryId + static Future getDocumentTypesByCategoryApi( + String documentCategoryId) async { + const endpoint = ApiEndpoints.getDocumentTypesByCategory; + + logSafe("Fetching document types for category: $documentCategoryId"); + + try { + final response = await _getRequest( + endpoint, + queryParams: {"documentCategoryId": documentCategoryId}, + ); + + if (response == null) { + logSafe("Document types by category request failed: null response", + level: LogLevel.error); + return null; + } + + final jsonResponse = _parseResponseForAllData(response, + label: "Document Types by Category"); + + if (jsonResponse != null) { + return DocumentTypesResponse.fromJson(jsonResponse); + } + } catch (e, stack) { + logSafe("Exception during getDocumentTypesByCategoryApi: $e", + level: LogLevel.error); + logSafe("StackTrace: $stack", level: LogLevel.debug); + } + + return null; + } + + /// Get Master Document Types (Category Types) + static Future getMasterDocumentTypesApi() async { + const endpoint = ApiEndpoints.getMasterDocumentCategories; + logSafe("Fetching master document types..."); + + try { + final response = await _getRequest(endpoint); + + if (response == null) { + logSafe("Document types request failed: null response", + level: LogLevel.error); + return null; + } + + final jsonResponse = + _parseResponseForAllData(response, label: "Master Document Types"); + + if (jsonResponse != null) { + return DocumentTypesResponse.fromJson(jsonResponse); + } + } catch (e, stack) { + logSafe("Exception during getMasterDocumentTypesApi: $e", + level: LogLevel.error); + logSafe("StackTrace: $stack", level: LogLevel.debug); + } + + return null; + } + + /// Upload Document API + static Future uploadDocumentApi({ + required String name, + String? documentId, + String? description, + required String entityId, + required String documentTypeId, + required String fileName, + required String base64Data, + required String contentType, + required int fileSize, + String? fileDescription, + bool isActive = true, + List> tags = const [], + }) async { + const endpoint = ApiEndpoints.uploadDocument; + logSafe("Uploading document: $name for entity: $entityId"); + + final Map payload = { + "name": name, + "documentId": documentId ?? "", + "description": description ?? "", + "entityId": entityId, + "documentTypeId": documentTypeId, + "attachment": { + "fileName": fileName, + "base64Data": base64Data, + "contentType": contentType, + "fileSize": fileSize, + "description": fileDescription ?? "", + "isActive": isActive, + }, + "tags": tags.isNotEmpty + ? tags + : [ + {"name": "default", "isActive": true} + ], + }; + + try { + final response = + await _postRequest(endpoint, payload, customTimeout: extendedTimeout); + + if (response == null) { + logSafe("Upload document failed: null response", level: LogLevel.error); + return false; + } + + logSafe("Upload document response status: ${response.statusCode}"); + logSafe("Upload document response body: ${response.body}"); + + final json = jsonDecode(response.body); + if (json['success'] == true) { + logSafe("Document uploaded successfully: ${json['data']}"); + return true; + } else { + logSafe( + "Failed to upload document: ${json['message'] ?? 'Unknown error'}", + level: LogLevel.warning, + ); + } + } catch (e, stack) { + logSafe("Exception during uploadDocumentApi: $e", level: LogLevel.error); + logSafe("StackTrace: $stack", level: LogLevel.debug); + } + + return false; + } + + /// Get Master Document Tags + static Future getMasterDocumentTagsApi() async { + const endpoint = ApiEndpoints.getMasterDocumentTags; + logSafe("Fetching master document tags..."); + + try { + final response = await _getRequest(endpoint); + + if (response == null) { + logSafe("Tags request failed: null response", level: LogLevel.error); + return null; + } + + final jsonResponse = + _parseResponseForAllData(response, label: "Master Document Tags"); + + if (jsonResponse != null) { + return TagResponse.fromJson(jsonResponse); + } + } catch (e, stack) { + logSafe("Exception during getMasterDocumentTagsApi: $e", + level: LogLevel.error); + logSafe("StackTrace: $stack", level: LogLevel.debug); + } + + return null; + } + + /// Get Document List by EntityTypeId and EntityId + static Future getDocumentListApi({ + required String entityTypeId, + required String entityId, + String filter = "", + String searchString = "", + bool isActive = true, + int pageNumber = 1, + int pageSize = 20, + }) async { + final endpoint = + "${ApiEndpoints.getDocumentList}/$entityTypeId/entity/$entityId"; + final queryParams = { + "filter": filter, + "searchString": searchString, + "isActive": isActive.toString(), + "pageNumber": pageNumber.toString(), + "pageSize": pageSize.toString(), + }; + + logSafe( + "Fetching document list for entityTypeId: $entityTypeId, entityId: $entityId"); + + try { + final response = await _getRequest(endpoint, queryParams: queryParams); + + if (response == null) { + logSafe("Document list request failed: null response", + level: LogLevel.error); + return null; + } + + final jsonResponse = + _parseResponseForAllData(response, label: "Document List"); + + if (jsonResponse != null) { + return DocumentsResponse.fromJson(jsonResponse); + } + } catch (e, stack) { + logSafe("Exception during getDocumentListApi: $e", level: LogLevel.error); + logSafe("StackTrace: $stack", level: LogLevel.debug); + } + + return null; + } + + /// Get Document Filters by EntityTypeId + static Future getDocumentFilters( + String entityTypeId) async { + final endpoint = "${ApiEndpoints.getDocumentFilter}/$entityTypeId"; + logSafe("Fetching document filters for entityTypeId: $entityTypeId"); + + try { + final response = await _getRequest(endpoint, queryParams: null); + + if (response == null) { + logSafe("Document filter request failed: null response", + level: LogLevel.error); + return null; + } + + final jsonResponse = + _parseResponseForAllData(response, label: "Document Filters"); + + if (jsonResponse != null) { + return DocumentFiltersResponse.fromJson(jsonResponse); + } + } catch (e, stack) { + logSafe("Exception during getDocumentFilters: $e", level: LogLevel.error); + logSafe("StackTrace: $stack", level: LogLevel.debug); + } + + return null; + } // === Menu APIs === // diff --git a/lib/helpers/utils/permission_constants.dart b/lib/helpers/utils/permission_constants.dart index c576f37..05d80ff 100644 --- a/lib/helpers/utils/permission_constants.dart +++ b/lib/helpers/utils/permission_constants.dart @@ -1,4 +1,4 @@ -/// Contains all role and permission UUIDs used for access control across the application. +/// Contains all role, permission, and entity UUIDs used for access control across the application. class Permissions { // ------------------- Project Management ------------------------------ /// Permission to manage master data (like dropdowns, configurations) @@ -91,4 +91,11 @@ class Permissions { // ------------------- Application Roles ------------------------------- /// Application role ID for users with full expense management rights static const String expenseManagement = "a4e25142-449b-4334-a6e5-22f70e4732d7"; + + // ------------------- Document Entities ------------------------------- + /// Entity ID for project documents + static const String projectEntity = "c8fe7115-aa27-43bc-99f4-7b05fabe436e"; + + /// Entity ID for employee documents + static const String employeeEntity = "dbb9555a-7a0c-40f2-a9ed-f0463f1ceed7"; } diff --git a/lib/helpers/widgets/custom_app_bar.dart b/lib/helpers/widgets/custom_app_bar.dart new file mode 100644 index 0000000..fdd6d5c --- /dev/null +++ b/lib/helpers/widgets/custom_app_bar.dart @@ -0,0 +1,89 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:marco/helpers/widgets/my_text.dart'; +import 'package:marco/controller/project_controller.dart'; + +class CustomAppBar extends StatelessWidget implements PreferredSizeWidget { + final String title; + final VoidCallback? onBackPressed; + + const CustomAppBar({ + super.key, + required this.title, + this.onBackPressed, + }); + + @override + Widget build(BuildContext context) { + return PreferredSize( + preferredSize: const Size.fromHeight(72), + child: Container( + decoration: BoxDecoration( + color: const Color(0xFFF5F5F5), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.15), + blurRadius: 0.5, + offset: const Offset(0, 0.5), + ) + ], + ), + child: SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: [ + IconButton( + icon: const Icon(Icons.arrow_back_ios_new, + color: Colors.black, size: 20), + onPressed: onBackPressed ?? Get.back, + splashRadius: 24, + ), + const SizedBox(width: 8), + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.titleLarge( + title, + fontWeight: 700, + color: Colors.black, + ), + const SizedBox(height: 2), + GetBuilder( + builder: (projectController) { + final projectName = + projectController.selectedProject?.name ?? + 'Select Project'; + return Row( + children: [ + const Icon(Icons.work_outline, + size: 14, color: Colors.grey), + const SizedBox(width: 4), + Expanded( + child: MyText.bodySmall( + projectName, + fontWeight: 600, + overflow: TextOverflow.ellipsis, + color: Colors.grey[700], + ), + ), + ], + ); + }, + ), + ], + ), + ), + ], + ), + ), + ), + ), + ); + } + + @override + Size get preferredSize => const Size.fromHeight(72); +} diff --git a/lib/helpers/widgets/my_custom_skeleton.dart b/lib/helpers/widgets/my_custom_skeleton.dart index e9a8be9..1d48e89 100644 --- a/lib/helpers/widgets/my_custom_skeleton.dart +++ b/lib/helpers/widgets/my_custom_skeleton.dart @@ -113,6 +113,262 @@ class SkeletonLoaders { ); } +// Document List Skeleton Loader + static Widget documentSkeletonLoader() { + return Column( + children: List.generate(5, (index) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Date placeholder + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), + child: Container( + height: 12, + width: 80, + decoration: BoxDecoration( + color: Colors.grey.shade300, + borderRadius: BorderRadius.circular(6), + ), + ), + ), + + // Document Card Skeleton + Container( + margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Icon Placeholder + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: Colors.grey.shade300, + borderRadius: BorderRadius.circular(8), + ), + child: const Icon(Icons.description, + color: Colors.transparent), // invisible icon + ), + const SizedBox(width: 12), + + // Text placeholders + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + height: 12, + width: 80, + color: Colors.grey.shade300, + ), + MySpacing.height(6), + Container( + height: 14, + width: double.infinity, + color: Colors.grey.shade300, + ), + MySpacing.height(6), + Container( + height: 12, + width: 100, + color: Colors.grey.shade300, + ), + ], + ), + ), + + // Action icon placeholder + Container( + width: 20, + height: 20, + decoration: BoxDecoration( + color: Colors.grey.shade300, + shape: BoxShape.circle, + ), + ), + ], + ), + ), + ], + ); + }), + ); + } + +// Document Details Card Skeleton Loader + static Widget documentDetailsSkeletonLoader() { + return SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Details Card + Container( + constraints: const BoxConstraints(maxWidth: 460), + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(10), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.06), + blurRadius: 16, + offset: const Offset(0, 4), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header + Row( + children: [ + Container( + width: 56, + height: 56, + decoration: BoxDecoration( + color: Colors.grey.shade300, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + height: 16, + width: 180, + color: Colors.grey.shade300, + ), + const SizedBox(height: 8), + Container( + height: 12, + width: 120, + color: Colors.grey.shade300, + ), + ], + ), + ), + ], + ), + const SizedBox(height: 12), + + // Tags placeholder + Wrap( + spacing: 6, + runSpacing: 6, + children: List.generate(3, (index) { + return Container( + height: 20, + width: 60, + decoration: BoxDecoration( + color: Colors.grey.shade300, + borderRadius: BorderRadius.circular(12), + ), + ); + }), + ), + const SizedBox(height: 16), + + // Info rows placeholders + Column( + children: List.generate(10, (index) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Row( + children: [ + Container( + height: 12, + width: 120, + color: Colors.grey.shade300, + ), + const SizedBox(width: 12), + Expanded( + child: Container( + height: 12, + color: Colors.grey.shade300, + ), + ), + ], + ), + ); + }), + ), + ], + ), + ), + + const SizedBox(height: 20), + + // Versions section skeleton + Container( + margin: const EdgeInsets.symmetric(horizontal: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: List.generate(3, (index) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Row( + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: Colors.grey.shade300, + borderRadius: BorderRadius.circular(8), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + height: 12, + width: 180, + color: Colors.grey.shade300, + ), + const SizedBox(height: 6), + Container( + height: 10, + width: 120, + color: Colors.grey.shade300, + ), + ], + ), + ), + Container( + width: 24, + height: 24, + decoration: BoxDecoration( + color: Colors.grey.shade300, + shape: BoxShape.circle, + ), + ), + ], + ), + ); + }), + ), + ), + ], + ), + ); + } + // Employee List - Card Style static Widget employeeListSkeletonLoader() { return Column( diff --git a/lib/model/document/document_details_model.dart b/lib/model/document/document_details_model.dart new file mode 100644 index 0000000..055e3db --- /dev/null +++ b/lib/model/document/document_details_model.dart @@ -0,0 +1,213 @@ +class DocumentDetailsResponse { + final bool success; + final String message; + final DocumentDetails? data; + final dynamic errors; + final int statusCode; + final DateTime timestamp; + + DocumentDetailsResponse({ + required this.success, + required this.message, + required this.data, + required this.errors, + required this.statusCode, + required this.timestamp, + }); + + factory DocumentDetailsResponse.fromJson(Map json) { + return DocumentDetailsResponse( + success: json['success'] ?? false, + message: json['message'] ?? '', + data: json['data'] != null ? DocumentDetails.fromJson(json['data']) : null, + errors: json['errors'], + statusCode: json['statusCode'] ?? 0, + timestamp: DateTime.tryParse(json['timestamp'] ?? '') ?? DateTime.now(), + ); + } +} + +class DocumentDetails { + final String id; + final String name; + final String documentId; + final String? description; + final int version; + final bool isCurrentVersion; + final String parentAttachmentId; + final DateTime uploadedAt; + final UploadedBy uploadedBy; + final DateTime? updatedAt; + final UploadedBy? updatedBy; + final DateTime? verifiedAt; + final bool? isVerified; + final UploadedBy? verifiedBy; + final String entityId; + final DocumentType documentType; + final List tags; + final bool isActive; + + DocumentDetails({ + required this.id, + required this.name, + required this.documentId, + this.description, + required this.version, + required this.isCurrentVersion, + required this.parentAttachmentId, + required this.uploadedAt, + required this.uploadedBy, + this.updatedAt, + this.updatedBy, + this.verifiedAt, + this.isVerified, + this.verifiedBy, + required this.entityId, + required this.documentType, + required this.tags, + required this.isActive, + }); + + factory DocumentDetails.fromJson(Map json) { + return DocumentDetails( + id: json['id'] ?? '', + name: json['name'] ?? '', + documentId: json['documentId'] ?? '', + description: json['description'], + version: json['version'] ?? 0, + isCurrentVersion: json['isCurrentVersion'] ?? false, + parentAttachmentId: json['parentAttachmentId'] ?? '', + uploadedAt: DateTime.tryParse(json['uploadedAt'] ?? '') ?? DateTime.now(), + uploadedBy: UploadedBy.fromJson(json['uploadedBy'] ?? {}), + updatedAt: json['updatedAt'] != null + ? DateTime.tryParse(json['updatedAt']) + : null, + updatedBy: json['updatedBy'] != null + ? UploadedBy.fromJson(json['updatedBy']) + : null, + verifiedAt: json['verifiedAt'] != null + ? DateTime.tryParse(json['verifiedAt']) + : null, + isVerified: json['isVerified'], + verifiedBy: json['verifiedBy'] != null + ? UploadedBy.fromJson(json['verifiedBy']) + : null, + entityId: json['entityId'] ?? '', + documentType: DocumentType.fromJson(json['documentType'] ?? {}), + tags: (json['tags'] as List?) + ?.map((tag) => DocumentTag.fromJson(tag)) + .toList() ?? + [], + isActive: json['isActive'] ?? false, + ); + } +} + +class UploadedBy { + final String id; + final String firstName; + final String? lastName; + final String? photo; + final String jobRoleId; + final String jobRoleName; + + UploadedBy({ + required this.id, + required this.firstName, + this.lastName, + this.photo, + required this.jobRoleId, + required this.jobRoleName, + }); + + factory UploadedBy.fromJson(Map json) { + return UploadedBy( + id: json['id'] ?? '', + firstName: json['firstName'] ?? '', + lastName: json['lastName'], + photo: json['photo'], + jobRoleId: json['jobRoleId'] ?? '', + jobRoleName: json['jobRoleName'] ?? '', + ); + } +} + +class DocumentType { + final String id; + final String name; + final String? regexExpression; // nullable + final String allowedContentType; + final int maxSizeAllowedInMB; + final bool isValidationRequired; + final bool isMandatory; + final bool isSystem; + final bool isActive; + final DocumentCategory? documentCategory; // nullable + + DocumentType({ + required this.id, + required this.name, + this.regexExpression, + required this.allowedContentType, + required this.maxSizeAllowedInMB, + required this.isValidationRequired, + required this.isMandatory, + required this.isSystem, + required this.isActive, + this.documentCategory, + }); + + factory DocumentType.fromJson(Map json) { + return DocumentType( + id: json['id'] ?? '', + name: json['name'] ?? '', + regexExpression: json['regexExpression'], + allowedContentType: json['allowedContentType'] ?? '', + maxSizeAllowedInMB: json['maxSizeAllowedInMB'] ?? 0, + isValidationRequired: json['isValidationRequired'] ?? false, + isMandatory: json['isMandatory'] ?? false, + isSystem: json['isSystem'] ?? false, + isActive: json['isActive'] ?? false, + documentCategory: json['documentCategory'] != null + ? DocumentCategory.fromJson(json['documentCategory']) + : null, + ); + } +} + +class DocumentCategory { + final String id; + final String name; + final String? description; + final String entityTypeId; + + DocumentCategory({ + required this.id, + required this.name, + this.description, + required this.entityTypeId, + }); + + factory DocumentCategory.fromJson(Map json) { + return DocumentCategory( + id: json['id'] ?? '', + name: json['name'] ?? '', + description: json['description'], + entityTypeId: json['entityTypeId'] ?? '', + ); + } +} + +class DocumentTag { + final String name; + final bool isActive; + + DocumentTag({required this.name, required this.isActive}); + + factory DocumentTag.fromJson(Map json) { + return DocumentTag( + name: json['name'] ?? '', + isActive: json['isActive'] ?? false, + ); + } +} diff --git a/lib/model/document/document_filter_model.dart b/lib/model/document/document_filter_model.dart new file mode 100644 index 0000000..3a30a23 --- /dev/null +++ b/lib/model/document/document_filter_model.dart @@ -0,0 +1,105 @@ +class DocumentFiltersResponse { + final bool success; + final String message; + final DocumentFiltersData? data; + final dynamic errors; + final int statusCode; + final DateTime timestamp; + + DocumentFiltersResponse({ + required this.success, + required this.message, + this.data, + this.errors, + required this.statusCode, + required this.timestamp, + }); + + factory DocumentFiltersResponse.fromJson(Map json) { + return DocumentFiltersResponse( + success: json['success'], + message: json['message'], + data: json['data'] != null + ? DocumentFiltersData.fromJson(json['data']) + : null, + errors: json['errors'], + statusCode: json['statusCode'], + timestamp: DateTime.parse(json['timestamp']), + ); + } + + Map toJson() { + return { + "success": success, + "message": message, + "data": data?.toJson(), + "errors": errors, + "statusCode": statusCode, + "timestamp": timestamp.toIso8601String(), + }; + } +} + +class DocumentFiltersData { + final List uploadedBy; + final List documentCategory; + final List documentType; + final List documentTag; + + DocumentFiltersData({ + required this.uploadedBy, + required this.documentCategory, + required this.documentType, + required this.documentTag, + }); + + factory DocumentFiltersData.fromJson(Map json) { + return DocumentFiltersData( + uploadedBy: (json['uploadedBy'] as List) + .map((e) => FilterItem.fromJson(e)) + .toList(), + documentCategory: (json['documentCategory'] as List) + .map((e) => FilterItem.fromJson(e)) + .toList(), + documentType: (json['documentType'] as List) + .map((e) => FilterItem.fromJson(e)) + .toList(), + documentTag: (json['documentTag'] as List) + .map((e) => FilterItem.fromJson(e)) + .toList(), + ); + } + + Map toJson() { + return { + "uploadedBy": uploadedBy.map((e) => e.toJson()).toList(), + "documentCategory": documentCategory.map((e) => e.toJson()).toList(), + "documentType": documentType.map((e) => e.toJson()).toList(), + "documentTag": documentTag.map((e) => e.toJson()).toList(), + }; + } +} + +class FilterItem { + final String id; + final String name; + + FilterItem({ + required this.id, + required this.name, + }); + + factory FilterItem.fromJson(Map json) { + return FilterItem( + id: json['id'], + name: json['name'], + ); + } + + Map toJson() { + return { + "id": id, + "name": name, + }; + } +} diff --git a/lib/model/document/document_upload_bottom_sheet.dart b/lib/model/document/document_upload_bottom_sheet.dart new file mode 100644 index 0000000..94b9d24 --- /dev/null +++ b/lib/model/document/document_upload_bottom_sheet.dart @@ -0,0 +1,773 @@ +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_text.dart'; +import 'package:marco/helpers/widgets/my_text_style.dart'; +import 'package:marco/helpers/widgets/image_viewer_dialog.dart'; +import 'package:marco/helpers/widgets/my_snackbar.dart'; + +class DocumentUploadBottomSheet extends StatefulWidget { + final Function(Map) onSubmit; + final Map? initialData; + + const DocumentUploadBottomSheet({ + Key? key, + required this.onSubmit, + this.initialData, + }) : super(key: key); + + @override + State createState() => + _DocumentUploadBottomSheetState(); +} + +class _DocumentUploadBottomSheetState extends State { + final _formKey = GlobalKey(); + final controller = Get.put(DocumentUploadController()); + + final TextEditingController _docIdController = TextEditingController(); + final TextEditingController _docNameController = TextEditingController(); + final TextEditingController _descriptionController = TextEditingController(); + + File? selectedFile; + String? existingFileName; + + @override + void initState() { + super.initState(); + + // ✅ Pre-fill if editing + if (widget.initialData != null) { + final data = widget.initialData!; + _docIdController.text = data["documentId"] ?? ""; + _docNameController.text = data["name"] ?? ""; + _descriptionController.text = data["description"] ?? ""; + + existingFileName = data["fileName"]; + + // Preselect category & type + WidgetsBinding.instance.addPostFrameCallback((_) async { + if (data["categoryName"] != null) { + final category = controller.categories.firstWhereOrNull( + (c) => c.name == data["categoryName"], + ); + if (category != null) { + setState(() => controller.selectedCategory = category); + await controller.fetchDocumentTypes(category.id); + + if (data["documentTypeName"] != null) { + final type = controller.documentTypes.firstWhereOrNull( + (t) => t.name == data["documentTypeName"], + ); + if (type != null) { + setState(() => controller.selectedType = type); + } + } + } + } + }); + + // Prefill tags + if (data["tags"] != null) { + controller.enteredTags.value = + List.from(data["tags"].map((t) => t["name"])); + } + + // Prefill file info + controller.selectedFileName = data["fileName"]; + controller.selectedFileContentType = data["contentType"]; + controller.selectedFileSize = data["fileSize"]; + } + } + + @override + void dispose() { + _docIdController.dispose(); + _docNameController.dispose(); + _descriptionController.dispose(); + super.dispose(); + } + + void _handleSubmit() { + if (!(_formKey.currentState?.validate() ?? false)) return; + + if (selectedFile == null && existingFileName == null) { + showAppSnackbar( + title: "Error", + message: "Please attach a document", + type: SnackbarType.error, + ); + return; + } + + // ✅ Validate file size + 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; + } + } + + // ✅ Validate file type + final allowedType = controller.selectedType?.allowedContentType; + if (allowedType != null && controller.selectedFileContentType != null) { + if (!allowedType + .toLowerCase() + .contains(controller.selectedFileContentType!.toLowerCase())) { + showAppSnackbar( + title: "Error", + message: "Only $allowedType files are allowed for this type", + type: SnackbarType.error, + ); + return; + } + } + + final payload = { + "documentId": _docIdController.text.trim(), + "name": _docNameController.text.trim(), + "description": _descriptionController.text.trim(), + "documentTypeId": controller.selectedType?.id, + "attachment": { + "fileName": controller.selectedFileName, + "base64Data": controller.selectedFileBase64, + "contentType": controller.selectedFileContentType, + "fileSize": controller.selectedFileSize, + "isActive": true, + }, + "tags": controller.enteredTags + .map((t) => {"name": t, "isActive": true}) + .toList(), + }; + + widget.onSubmit(payload); + showAppSnackbar( + title: "Success", + message: "Document submitted successfully", + type: SnackbarType.success, + ); + } + + Future _pickFile() async { + final result = await FilePicker.platform.pickFiles( + type: FileType.custom, + allowedExtensions: ['pdf', 'jpg', 'png', 'jpeg'], + ); + + if (result != null && result.files.single.path != null) { + final file = File(result.files.single.path!); + final fileName = result.files.single.name; + final fileBytes = await file.readAsBytes(); + final base64Data = base64Encode(fileBytes); + + setState(() { + selectedFile = file; + existingFileName = null; + 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: widget.initialData == null ? "Upload Document" : "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: (value) => + value == null || value.trim().isEmpty ? "Required" : null, + isRequired: true, + ), + MySpacing.height(16), + + /// Document Name + LabeledInput( + label: "Document Name", + hint: "e.g., PAN Card", + controller: _docNameController, + validator: (value) => + value == null || value.trim().isEmpty ? "Required" : null, + isRequired: true, + ), + MySpacing.height(16), + + /// Document Category + Obx(() { + if (controller.isLoading.value && + controller.categories.isEmpty) { + return const Center(child: CircularProgressIndicator()); + } + + return LabeledDropdown( + label: "Document Category", + hint: "Select Category", + value: controller.selectedCategory?.name, + items: controller.categories.map((c) => c.name).toList(), + onChanged: (selected) async { + final category = controller.categories + .firstWhere((c) => c.name == selected); + setState(() => controller.selectedCategory = category); + await controller.fetchDocumentTypes(category.id); + }, + isRequired: true, + ); + }), + MySpacing.height(16), + + /// Document Type + Obx(() { + if (controller.documentTypes.isEmpty) { + return const SizedBox.shrink(); + } + return LabeledDropdown( + label: "Document Type", + hint: "Select Type", + value: controller.selectedType?.name, + items: controller.documentTypes.map((t) => t.name).toList(), + onChanged: (selected) { + final type = controller.documentTypes + .firstWhere((t) => t.name == selected); + setState(() => controller.selectedType = type); + }, + isRequired: true, + ); + }), + MySpacing.height(24), + + /// Single Attachment Section + AttachmentSectionSingle( + attachment: selectedFile, + existingFileName: existingFileName, + onPick: _pickFile, + onRemove: () => setState(() { + selectedFile = null; + existingFileName = null; + controller.selectedFileName = null; + controller.selectedFileBase64 = null; + controller.selectedFileContentType = null; + controller.selectedFileSize = 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 + 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: (value) => + value == null || value.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 ---------------- +class AttachmentSectionSingle extends StatelessWidget { + final File? attachment; + final String? existingFileName; // ✅ new + final VoidCallback onPick; + final VoidCallback? onRemove; + + const AttachmentSectionSingle({ + Key? key, + this.attachment, + this.existingFileName, + required this.onPick, + this.onRemove, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + if (attachment == null && existingFileName != null) { + return Row( + children: [ + const Icon(Icons.insert_drive_file, color: Colors.blueAccent), + const SizedBox(width: 8), + Expanded( + child: Text(existingFileName!, + style: const TextStyle(fontSize: 14, color: Colors.black87)), + ), + if (onRemove != null) + IconButton( + icon: const Icon(Icons.close, color: Colors.red), + onPressed: onRemove, + ), + ], + ); + } + + final allowedImageExtensions = ['jpg', 'jpeg', 'png']; + + Widget buildTile(File file) { + final isImage = allowedImageExtensions + .contains(file.path.split('.').last.toLowerCase()); + + final fileName = file.path.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: () { + if (isImage) { + showDialog( + context: context, + builder: (_) => ImageViewerDialog( + imageSources: [file], + initialIndex: 0, + ), + ); + } + }, + child: Container( + width: 100, + height: 100, + decoration: BoxDecoration( + border: Border.all(color: Colors.grey.shade300), + borderRadius: BorderRadius.circular(8), + color: Colors.grey.shade100, + ), + child: isImage + ? 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), + Text( + fileName.split('.').last.toUpperCase(), + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.bold, + color: iconColor), + ), + ], + ), + ), + ), + 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, + children: [ + Row( + children: const [ + Text("Attachment", style: TextStyle(fontWeight: FontWeight.w600)), + Text(" *", + style: + TextStyle(color: Colors.red, fontWeight: FontWeight.bold)) + ], + ), + const SizedBox(height: 8), + Row( + children: [ + if (attachment != null) + buildTile(attachment!) + 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; + + const LabeledInput({ + Key? key, + required this.label, + required this.hint, + required this.controller, + required this.validator, + this.isRequired = 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, + decoration: _inputDecoration(context, hint), + ), + ], + ); + + InputDecoration _inputDecoration(BuildContext context, String hint) => + InputDecoration( + hintText: hint, + hintStyle: MyTextStyle.bodySmall(xMuted: true), + filled: true, + fillColor: Colors.grey.shade100, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + focusedBorder: const OutlineInputBorder( + borderRadius: BorderRadius.all(Radius.circular(12)), + borderSide: BorderSide(color: Colors.blueAccent, width: 1.5), + ), + contentPadding: MySpacing.all(16), + ); +} + +class LabeledDropdown extends StatefulWidget { + final String label; + final String hint; + final String? value; + final List items; + final ValueChanged onChanged; + final bool isRequired; + + const LabeledDropdown({ + Key? key, + required this.label, + required this.hint, + required this.value, + required this.items, + required this.onChanged, + this.isRequired = false, + }) : super(key: key); + + @override + State createState() => _LabeledDropdownState(); +} + +class _LabeledDropdownState extends State { + final GlobalKey _dropdownKey = GlobalKey(); + + @override + Widget build(BuildContext context) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + MyText.labelMedium(widget.label), + if (widget.isRequired) + const Text( + " *", + style: + TextStyle(color: Colors.red, fontWeight: FontWeight.bold), + ), + ], + ), + MySpacing.height(8), + GestureDetector( + key: _dropdownKey, + onTap: () async { + final RenderBox renderBox = + _dropdownKey.currentContext!.findRenderObject() as RenderBox; + final Offset offset = renderBox.localToGlobal(Offset.zero); + final Size size = renderBox.size; + final RelativeRect position = RelativeRect.fromLTRB( + offset.dx, + offset.dy + size.height, + offset.dx + size.width, + offset.dy, + ); + final selected = await showMenu( + context: context, + position: position, + items: widget.items + .map((item) => PopupMenuItem( + value: item, + child: Text(item), + )) + .toList(), + ); + if (selected != null) widget.onChanged(selected); + }, + child: AbsorbPointer( + child: TextFormField( + readOnly: true, + controller: TextEditingController(text: widget.value ?? ""), + validator: (value) => + widget.isRequired && (value == null || value.isEmpty) + ? "Required" + : null, + decoration: _inputDecoration(context, widget.hint).copyWith( + suffixIcon: const Icon(Icons.expand_more), + ), + ), + ), + ), + ], + ); + + InputDecoration _inputDecoration(BuildContext context, String hint) => + InputDecoration( + hintText: hint, + hintStyle: MyTextStyle.bodySmall(xMuted: true), + filled: true, + fillColor: Colors.grey.shade100, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + focusedBorder: const OutlineInputBorder( + borderRadius: BorderRadius.all(Radius.circular(12)), + borderSide: BorderSide(color: Colors.blueAccent, width: 1.5), + ), + contentPadding: MySpacing.all(16), + ); +} + +class FilePickerTile extends StatelessWidget { + final String? pickedFile; + final VoidCallback onTap; + final bool isRequired; + + const FilePickerTile({ + Key? key, + required this.pickedFile, + required this.onTap, + this.isRequired = false, + }) : super(key: key); + + @override + Widget build(BuildContext context) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + MyText.labelMedium("Attachments"), + if (isRequired) + const Text( + " *", + style: + TextStyle(color: Colors.red, fontWeight: FontWeight.bold), + ), + ], + ), + MySpacing.height(8), + GestureDetector( + onTap: onTap, + child: Container( + padding: MySpacing.all(16), + decoration: BoxDecoration( + color: Colors.grey.shade100, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.grey.shade300), + ), + child: Row( + children: [ + const Icon(Icons.upload_file, color: Colors.blueAccent), + const SizedBox(width: 12), + Text(pickedFile ?? "Choose File"), + ], + ), + ), + ), + ], + ); +} diff --git a/lib/model/document/document_version_model.dart b/lib/model/document/document_version_model.dart new file mode 100644 index 0000000..543d1ce --- /dev/null +++ b/lib/model/document/document_version_model.dart @@ -0,0 +1,138 @@ +class DocumentVersionsResponse { + final bool success; + final String message; + final VersionDataWrapper data; + final dynamic errors; + final int statusCode; + final DateTime timestamp; + + DocumentVersionsResponse({ + required this.success, + required this.message, + required this.data, + this.errors, + required this.statusCode, + required this.timestamp, + }); + + factory DocumentVersionsResponse.fromJson(Map json) { + return DocumentVersionsResponse( + success: json['success'] ?? false, + message: json['message'] ?? "", + data: VersionDataWrapper.fromJson(json['data']), + errors: json['errors'], + statusCode: json['statusCode'] ?? 0, + timestamp: DateTime.parse(json['timestamp']), + ); + } +} + +class VersionDataWrapper { + final int currentPage; + final int totalPages; + final int totalEntites; + final List data; + + VersionDataWrapper({ + required this.currentPage, + required this.totalPages, + required this.totalEntites, + required this.data, + }); + + factory VersionDataWrapper.fromJson(Map json) { + return VersionDataWrapper( + currentPage: json['currentPage'] ?? 1, + totalPages: json['totalPages'] ?? 1, + totalEntites: json['totalEntites'] ?? 0, + data: (json['data'] as List?) + ?.map((e) => DocumentVersionItem.fromJson(e)) + .toList() ?? + [], + ); + } +} + +class DocumentVersionItem { + final String id; + final String name; + final String documentId; + final int version; + final int fileSize; + final String contentType; + final DateTime uploadedAt; + final UserInfo uploadedBy; + final DateTime? updatedAt; + final UserInfo? updatedBy; + final DateTime? verifiedAt; + final UserInfo? verifiedBy; + final bool? isVerified; + + DocumentVersionItem({ + required this.id, + required this.name, + required this.documentId, + required this.version, + required this.fileSize, + required this.contentType, + required this.uploadedAt, + required this.uploadedBy, + this.updatedAt, + this.updatedBy, + this.verifiedAt, + this.verifiedBy, + this.isVerified, + }); + + factory DocumentVersionItem.fromJson(Map json) { + return DocumentVersionItem( + id: json['id'] ?? "", + name: json['name'] ?? "", + documentId: json['documentId'] ?? "", + version: json['version'] ?? 0, + fileSize: json['fileSize'] ?? 0, + contentType: json['contentType'] ?? "", + uploadedAt: DateTime.parse(json['uploadedAt']), + uploadedBy: UserInfo.fromJson(json['uploadedBy']), + updatedAt: + json['updatedAt'] != null ? DateTime.parse(json['updatedAt']) : null, + updatedBy: + json['updatedBy'] != null ? UserInfo.fromJson(json['updatedBy']) : null, + verifiedAt: json['verifiedAt'] != null + ? DateTime.tryParse(json['verifiedAt']) + : null, + verifiedBy: + json['verifiedBy'] != null ? UserInfo.fromJson(json['verifiedBy']) : null, + isVerified: json['isVerified'], + ); + } +} + +class UserInfo { + final String id; + final String firstName; + final String lastName; + final String photo; + final String jobRoleId; + final String jobRoleName; + + UserInfo({ + required this.id, + required this.firstName, + required this.lastName, + required this.photo, + required this.jobRoleId, + required this.jobRoleName, + }); + + factory UserInfo.fromJson(Map json) { + return UserInfo( + id: json['id'] ?? "", + firstName: json['firstName'] ?? "", + lastName: json['lastName'] ?? "", + photo: json['photo'] ?? "", + jobRoleId: json['jobRoleId'] ?? "", + jobRoleName: json['jobRoleName'] ?? "", + ); + } +} diff --git a/lib/model/document/documents_list_model.dart b/lib/model/document/documents_list_model.dart new file mode 100644 index 0000000..f589362 --- /dev/null +++ b/lib/model/document/documents_list_model.dart @@ -0,0 +1,273 @@ +class DocumentsResponse { + final bool success; + final String message; + final DocumentDataWrapper data; + final dynamic errors; + final int statusCode; + final DateTime timestamp; + + DocumentsResponse({ + required this.success, + required this.message, + required this.data, + this.errors, + required this.statusCode, + required this.timestamp, + }); + + factory DocumentsResponse.fromJson(Map json) { + return DocumentsResponse( + success: json['success'] ?? false, + message: json['message'] ?? '', + data: DocumentDataWrapper.fromJson(json['data']), + errors: json['errors'], + statusCode: json['statusCode'] ?? 0, + timestamp: json['timestamp'] != null + ? DateTime.parse(json['timestamp']) + : DateTime.now(), + ); + } + + Map toJson() { + return { + 'success': success, + 'message': message, + 'data': data.toJson(), + 'errors': errors, + 'statusCode': statusCode, + 'timestamp': timestamp.toIso8601String(), + }; + } +} + +class DocumentDataWrapper { + final dynamic currentFilter; + final int currentPage; + final int totalPages; + final int totalEntites; + final List data; + + DocumentDataWrapper({ + this.currentFilter, + required this.currentPage, + required this.totalPages, + required this.totalEntites, + required this.data, + }); + + factory DocumentDataWrapper.fromJson(Map json) { + return DocumentDataWrapper( + currentFilter: json['currentFilter'], + currentPage: json['currentPage'] ?? 0, + totalPages: json['totalPages'] ?? 0, + totalEntites: json['totalEntites'] ?? 0, + data: (json['data'] as List? ?? []) + .map((e) => DocumentItem.fromJson(e)) + .toList(), + ); + } + + Map toJson() { + return { + 'currentFilter': currentFilter, + 'currentPage': currentPage, + 'totalPages': totalPages, + 'totalEntites': totalEntites, + 'data': data.map((e) => e.toJson()).toList(), + }; + } +} + +class DocumentItem { + final String id; + final String name; + final String documentId; + final String description; + final DateTime uploadedAt; + final String? parentAttachmentId; + final bool isCurrentVersion; + final int version; + final bool isActive; + final bool? isVerified; + final UploadedBy uploadedBy; + final DocumentType documentType; + + DocumentItem({ + required this.id, + required this.name, + required this.documentId, + required this.description, + required this.uploadedAt, + this.parentAttachmentId, + required this.isCurrentVersion, + required this.version, + required this.isActive, + this.isVerified, + required this.uploadedBy, + required this.documentType, + }); + + factory DocumentItem.fromJson(Map json) { + return DocumentItem( + id: json['id'] ?? '', + name: json['name'] ?? '', + documentId: json['documentId'] ?? '', + description: json['description'] ?? '', + uploadedAt: DateTime.parse(json['uploadedAt']), + parentAttachmentId: json['parentAttachmentId'], + isCurrentVersion: json['isCurrentVersion'] ?? false, + version: json['version'] ?? 0, + isActive: json['isActive'] ?? false, + isVerified: json['isVerified'], + uploadedBy: UploadedBy.fromJson(json['uploadedBy']), + documentType: DocumentType.fromJson(json['documentType']), + ); + } + + Map toJson() { + return { + 'id': id, + 'name': name, + 'documentId': documentId, + 'description': description, + 'uploadedAt': uploadedAt.toIso8601String(), + 'parentAttachmentId': parentAttachmentId, + 'isCurrentVersion': isCurrentVersion, + 'version': version, + 'isActive': isActive, + 'isVerified': isVerified, + 'uploadedBy': uploadedBy.toJson(), + 'documentType': documentType.toJson(), + }; + } +} + +class UploadedBy { + final String id; + final String firstName; + final String? lastName; + final String? photo; + final String jobRoleId; + final String jobRoleName; + + UploadedBy({ + required this.id, + required this.firstName, + this.lastName, + this.photo, + required this.jobRoleId, + required this.jobRoleName, + }); + + factory UploadedBy.fromJson(Map json) { + return UploadedBy( + id: json['id'] ?? '', + firstName: json['firstName'] ?? '', + lastName: json['lastName'], + photo: json['photo'], + jobRoleId: json['jobRoleId'] ?? '', + jobRoleName: json['jobRoleName'] ?? '', + ); + } + + Map toJson() { + return { + 'id': id, + 'firstName': firstName, + 'lastName': lastName, + 'photo': photo, + 'jobRoleId': jobRoleId, + 'jobRoleName': jobRoleName, + }; + } +} + +class DocumentType { + final String id; + final String name; + final String? regexExpression; + final String? allowedContentType; + final int? maxSizeAllowedInMB; + final bool isValidationRequired; + final bool isMandatory; + final bool isSystem; + final bool isActive; + final DocumentCategory? documentCategory; + + DocumentType({ + required this.id, + required this.name, + this.regexExpression, + this.allowedContentType, + this.maxSizeAllowedInMB, + required this.isValidationRequired, + required this.isMandatory, + required this.isSystem, + required this.isActive, + this.documentCategory, + }); + + factory DocumentType.fromJson(Map json) { + return DocumentType( + id: json['id'] ?? '', + name: json['name'] ?? '', + regexExpression: json['regexExpression'], // nullable + allowedContentType: json['allowedContentType'], + maxSizeAllowedInMB: json['maxSizeAllowedInMB'], + isValidationRequired: json['isValidationRequired'] ?? false, + isMandatory: json['isMandatory'] ?? false, + isSystem: json['isSystem'] ?? false, + isActive: json['isActive'] ?? false, + documentCategory: json['documentCategory'] != null + ? DocumentCategory.fromJson(json['documentCategory']) + : null, + ); + } + + Map toJson() { + return { + 'id': id, + 'name': name, + 'regexExpression': regexExpression, + 'allowedContentType': allowedContentType, + 'maxSizeAllowedInMB': maxSizeAllowedInMB, + 'isValidationRequired': isValidationRequired, + 'isMandatory': isMandatory, + 'isSystem': isSystem, + 'isActive': isActive, + 'documentCategory': documentCategory?.toJson(), + }; + } +} + +class DocumentCategory { + final String id; + final String name; + final String? description; + final String? entityTypeId; + + DocumentCategory({ + required this.id, + required this.name, + this.description, + this.entityTypeId, + }); + + factory DocumentCategory.fromJson(Map json) { + return DocumentCategory( + id: json['id'] ?? '', + name: json['name'] ?? '', + description: json['description'], + entityTypeId: json['entityTypeId'], + ); + } + + Map toJson() { + return { + 'id': id, + 'name': name, + 'description': description, + 'entityTypeId': entityTypeId, + }; + } +} diff --git a/lib/model/document/master_document_category_list_model.dart b/lib/model/document/master_document_category_list_model.dart new file mode 100644 index 0000000..4ddd1ad --- /dev/null +++ b/lib/model/document/master_document_category_list_model.dart @@ -0,0 +1,31 @@ +class MasterDocumentCategoryListModel { + final String id; + final String name; + final String description; + final String entityTypeId; + + MasterDocumentCategoryListModel({ + required this.id, + required this.name, + required this.description, + required this.entityTypeId, + }); + + factory MasterDocumentCategoryListModel.fromJson(Map json) { + return MasterDocumentCategoryListModel( + id: json['id'] as String, + name: json['name'] as String, + description: json['description'] as String, + entityTypeId: json['entityTypeId'] as String, + ); + } + + Map toJson() { + return { + 'id': id, + 'name': name, + 'description': description, + 'entityTypeId': entityTypeId, + }; + } +} diff --git a/lib/model/document/master_document_tags.dart b/lib/model/document/master_document_tags.dart new file mode 100644 index 0000000..2cdc8d1 --- /dev/null +++ b/lib/model/document/master_document_tags.dart @@ -0,0 +1,69 @@ +class TagItem { + final String id; + final String name; + final bool isActive; + + TagItem({ + required this.id, + required this.name, + this.isActive = true, + }); + + factory TagItem.fromJson(Map json) { + return TagItem( + id: json['id'] ?? '', + name: json['name'] ?? '', + isActive: json['isActive'] ?? true, + ); + } + + Map toJson() { + return { + 'id': id, + 'name': name, + 'isActive': isActive, + }; + } +} + +class TagResponse { + final bool success; + final String message; + final List data; + final dynamic errors; + final int statusCode; + final DateTime timestamp; + + TagResponse({ + required this.success, + required this.message, + required this.data, + this.errors, + required this.statusCode, + required this.timestamp, + }); + + factory TagResponse.fromJson(Map json) { + return TagResponse( + success: json['success'] ?? false, + message: json['message'] ?? '', + data: (json['data'] as List? ?? []) + .map((item) => TagItem.fromJson(item)) + .toList(), + errors: json['errors'], + statusCode: json['statusCode'] ?? 0, + timestamp: DateTime.tryParse(json['timestamp'] ?? '') ?? DateTime.now(), + ); + } + + Map toJson() { + return { + 'success': success, + 'message': message, + 'data': data.map((e) => e.toJson()).toList(), + 'errors': errors, + 'statusCode': statusCode, + 'timestamp': timestamp.toIso8601String(), + }; + } +} diff --git a/lib/model/document/master_document_type_model.dart b/lib/model/document/master_document_type_model.dart new file mode 100644 index 0000000..588ee72 --- /dev/null +++ b/lib/model/document/master_document_type_model.dart @@ -0,0 +1,94 @@ +class DocumentType { + final String id; + final String name; + final String? description; + final String? regexExpression; + final String? allowedContentType; + final int? maxSizeAllowedInMB; + final bool isValidationRequired; + final bool isMandatory; + final bool isSystem; + final bool isActive; + final dynamic documentCategory; + final String? entityTypeId; + + DocumentType({ + required this.id, + required this.name, + this.description, + this.regexExpression, + this.allowedContentType, + this.maxSizeAllowedInMB, + this.isValidationRequired = false, + this.isMandatory = false, + this.isSystem = false, + this.isActive = true, + this.documentCategory, + this.entityTypeId, + }); + + factory DocumentType.fromJson(Map json) { + return DocumentType( + id: json['id'] ?? '', + name: json['name'] ?? '', + description: json['description'], + regexExpression: json['regexExpression'], + allowedContentType: json['allowedContentType'], + maxSizeAllowedInMB: json['maxSizeAllowedInMB'], + isValidationRequired: json['isValidationRequired'] ?? false, + isMandatory: json['isMandatory'] ?? false, + isSystem: json['isSystem'] ?? false, + isActive: json['isActive'] ?? true, + documentCategory: json['documentCategory'], + entityTypeId: json['entityTypeId'], + ); + } + + Map toJson() { + return { + 'id': id, + 'name': name, + 'description': description, + 'regexExpression': regexExpression, + 'allowedContentType': allowedContentType, + 'maxSizeAllowedInMB': maxSizeAllowedInMB, + 'isValidationRequired': isValidationRequired, + 'isMandatory': isMandatory, + 'isSystem': isSystem, + 'isActive': isActive, + 'documentCategory': documentCategory, + 'entityTypeId': entityTypeId, + }; + } +} + +class DocumentTypesResponse { + final bool success; + final String? message; + final List data; + + DocumentTypesResponse({ + required this.success, + required this.data, + this.message, + }); + + factory DocumentTypesResponse.fromJson(Map json) { + return DocumentTypesResponse( + success: json['success'] ?? false, + message: json['message'], + data: (json['data'] as List?) + ?.map((item) => DocumentType.fromJson(item)) + .toList() ?? + [], + ); + } + + Map toJson() { + return { + 'success': success, + 'message': message, + 'data': data.map((e) => e.toJson()).toList(), + }; + } +} diff --git a/lib/model/document/user_document_filter_bottom_sheet.dart b/lib/model/document/user_document_filter_bottom_sheet.dart new file mode 100644 index 0000000..0eedf48 --- /dev/null +++ b/lib/model/document/user_document_filter_bottom_sheet.dart @@ -0,0 +1,179 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:marco/controller/document/user_document_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_text.dart'; +import 'package:marco/helpers/widgets/my_text_style.dart'; +import 'package:marco/model/document/document_filter_model.dart'; + +class UserDocumentFilterBottomSheet extends StatelessWidget { + final String entityId; + final String entityTypeId; + final DocumentController docController = Get.find(); + + UserDocumentFilterBottomSheet({ + super.key, + required this.entityId, + required this.entityTypeId, + }); + + @override + Widget build(BuildContext context) { + final filterData = docController.filters.value; + if (filterData == null) return const SizedBox.shrink(); + + final hasFilters = [ + filterData.uploadedBy, + filterData.documentCategory, + filterData.documentType, + filterData.documentTag, + ].any((list) => list.isNotEmpty); + + return BaseBottomSheet( + title: 'Filter Documents', + showButtons: hasFilters, + onCancel: () => Get.back(), + onSubmit: () { + final combinedFilter = { + 'uploadedBy': docController.selectedUploadedBy.value, + 'category': docController.selectedCategory.value, + 'type': docController.selectedType.value, + 'tag': docController.selectedTag.value, + }; + docController.fetchDocuments( + entityTypeId: entityTypeId, + entityId: entityId, + filter: combinedFilter.toString(), + reset: true, + ); + Get.back(); + }, + child: SingleChildScrollView( + child: hasFilters + ? Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Align( + alignment: Alignment.centerRight, + child: TextButton( + onPressed: docController.clearFilters, + child: MyText( + "Reset Filter", + style: MyTextStyle.labelMedium( + color: Colors.red, + fontWeight: 600, + ), + ), + ), + ), + MySpacing.height(8), + _buildDynamicField( + label: "Uploaded By", + items: filterData.uploadedBy, + fallback: "Select Uploaded By", + selectedValue: docController.selectedUploadedBy, + ), + _buildDynamicField( + label: "Category", + items: filterData.documentCategory, + fallback: "Select Category", + selectedValue: docController.selectedCategory, + ), + _buildDynamicField( + label: "Type", + items: filterData.documentType, + fallback: "Select Type", + selectedValue: docController.selectedType, + ), + _buildDynamicField( + label: "Tag", + items: filterData.documentTag, + fallback: "Select Tag", + selectedValue: docController.selectedTag, + ), + ].where((w) => w != null).cast().toList(), + ) + : Center( + child: Padding( + padding: const EdgeInsets.all(24.0), + child: MyText( + "No filters are available", + style: MyTextStyle.bodyMedium( + color: Colors.grey, + fontWeight: 500, + ), + ), + ), + ), + ), + ); + } + + Widget? _buildDynamicField({ + required String label, + required List items, + required String fallback, + required RxString selectedValue, + }) { + if (items.isEmpty) return null; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.labelMedium(label), + MySpacing.height(8), + _popupSelector(items, fallback, selectedValue: selectedValue), + MySpacing.height(16), + ], + ); + } + + Widget _popupSelector( + List items, + String fallback, { + required RxString selectedValue, + }) { + return Obx(() { + final currentValue = _getCurrentName(selectedValue.value, items, fallback); + return PopupMenuButton( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + onSelected: (val) => selectedValue.value = val, + itemBuilder: (context) => items + .map( + (f) => PopupMenuItem( + value: f.id, + child: MyText(f.name), + ), + ) + .toList(), + child: Container( + padding: MySpacing.all(12), + decoration: BoxDecoration( + color: Colors.grey.shade100, + border: Border.all(color: Colors.grey.shade300), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: MyText( + currentValue, + style: const TextStyle(color: Colors.black87), + overflow: TextOverflow.ellipsis, + ), + ), + const Icon(Icons.arrow_drop_down, color: Colors.grey), + ], + ), + ), + ); + }); + } + + String _getCurrentName(String selectedId, List list, String fallback) { + if (selectedId.isEmpty) return fallback; + final match = list.firstWhereOrNull((f) => f.id == selectedId); + return match?.name ?? fallback; + } +} diff --git a/lib/routes.dart b/lib/routes.dart index 3be20d3..70bd46d 100644 --- a/lib/routes.dart +++ b/lib/routes.dart @@ -18,6 +18,7 @@ import 'package:marco/view/auth/mpin_screen.dart'; import 'package:marco/view/auth/mpin_auth_screen.dart'; import 'package:marco/view/directory/directory_main_screen.dart'; import 'package:marco/view/expense/expense_screen.dart'; +import 'package:marco/view/document/user_document_screen.dart'; class AuthMiddleware extends GetMiddleware { @override @@ -70,6 +71,11 @@ getPageRoute() { GetPage( name: '/dashboard/expense-main-page', page: () => ExpenseMainScreen(), + middlewares: [AuthMiddleware()]), + // Documents + GetPage( + name: '/dashboard/document-main-page', + page: () => UserDocumentsPage(), middlewares: [AuthMiddleware()]), // Authentication GetPage(name: '/auth/login', page: () => LoginScreen()), diff --git a/lib/view/dashboard/dashboard_screen.dart b/lib/view/dashboard/dashboard_screen.dart index a6a0d3a..a169aad 100644 --- a/lib/view/dashboard/dashboard_screen.dart +++ b/lib/view/dashboard/dashboard_screen.dart @@ -31,6 +31,7 @@ class DashboardScreen extends StatefulWidget { "/dashboard/daily-task-progress"; static const String directoryMainPageRoute = "/dashboard/directory-main-page"; static const String expenseMainPageRoute = "/dashboard/expense-main-page"; + static const String documentMainPageRoute = "/dashboard/document-main-page"; @override State createState() => _DashboardScreenState(); @@ -79,7 +80,7 @@ class _DashboardScreenState extends State with UIMixin { */ _buildDashboardStats(context), MySpacing.height(24), - SizedBox( + SizedBox( width: double.infinity, child: DashboardOverviewWidgets.teamsOverview(), ), @@ -267,6 +268,8 @@ class _DashboardScreenState extends State with UIMixin { DashboardScreen.directoryMainPageRoute), _StatItem(LucideIcons.badge_dollar_sign, "Expense", contentTheme.info, DashboardScreen.expenseMainPageRoute), + _StatItem(LucideIcons.file_text, "Documents", contentTheme.info, + DashboardScreen.documentMainPageRoute), ]; final projectController = Get.find(); @@ -277,10 +280,16 @@ class _DashboardScreenState extends State with UIMixin { children: [ if (!isProjectSelected) _buildNoProjectMessage(), Wrap( - spacing: 6, // horizontal spacing - runSpacing: 6, // vertical spacing + spacing: 6, + runSpacing: 6, children: stats - .where((stat) => menuController.isMenuAllowed(stat.title)) + .where((stat) { + // ✅ Always allow Documents + if (stat.title == "Documents") return true; + + // For all other menus, respect sidebar permissions + return menuController.isMenuAllowed(stat.title); + }) .map((stat) => _buildStatCard(stat, isProjectSelected)) .toList(), ), @@ -290,49 +299,70 @@ class _DashboardScreenState extends State with UIMixin { } /// Stat Card (Compact with wrapping text) - Widget _buildStatCard(_StatItem statItem, bool isEnabled) { - const double cardWidth = 80; - const double cardHeight = 70; +/// Stat Card (Compact with wrapping text) +Widget _buildStatCard(_StatItem statItem, bool isProjectSelected) { + const double cardWidth = 80; + const double cardHeight = 70; - return Opacity( - opacity: isEnabled ? 1.0 : 0.4, - child: IgnorePointer( - ignoring: !isEnabled, - child: InkWell( - onTap: () => _handleStatCardTap(statItem, isEnabled), - borderRadius: BorderRadius.circular(8), - child: MyCard.bordered( - width: cardWidth, - height: cardHeight, - paddingAll: 4, - borderRadiusAll: 8, - border: Border.all(color: Colors.grey.withOpacity(0.15)), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - _buildStatCardIconCompact(statItem), - MySpacing.height(4), - Expanded( - child: Center( - child: Text( - statItem.title, - textAlign: TextAlign.center, - style: const TextStyle( - fontSize: 10, - overflow: TextOverflow.visible, - ), - maxLines: 2, - softWrap: true, + // ✅ Attendance should always be enabled + final bool isEnabled = statItem.title == "Attendance" || isProjectSelected; + + return Opacity( + opacity: isEnabled ? 1.0 : 0.4, + child: IgnorePointer( + ignoring: !isEnabled, + child: InkWell( + onTap: () => _handleStatCardTap(statItem, isEnabled), + borderRadius: BorderRadius.circular(8), + child: MyCard.bordered( + width: cardWidth, + height: cardHeight, + paddingAll: 4, + borderRadiusAll: 8, + border: Border.all(color: Colors.grey.withOpacity(0.15)), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _buildStatCardIconCompact(statItem), + MySpacing.height(4), + Expanded( + child: Center( + child: Text( + statItem.title, + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 10, + overflow: TextOverflow.visible, ), + maxLines: 2, + softWrap: true, ), ), - ], - ), + ), + ], ), ), ), + ), + ); +} + +/// Handle Tap +void _handleStatCardTap(_StatItem statItem, bool isEnabled) { + if (!isEnabled) { + Get.defaultDialog( + title: "No Project Selected", + middleText: "You need to select a project before accessing this section.", + confirm: ElevatedButton( + onPressed: () => Get.back(), + child: const Text("OK"), + ), ); + } else { + Get.toNamed(statItem.route); } +} + /// Compact Icon Widget _buildStatCardIconCompact(_StatItem statItem) { @@ -348,21 +378,7 @@ class _DashboardScreenState extends State with UIMixin { } /// Handle Tap - void _handleStatCardTap(_StatItem statItem, bool isEnabled) { - if (!isEnabled) { - Get.defaultDialog( - title: "No Project Selected", - middleText: - "You need to select a project before accessing this section.", - confirm: ElevatedButton( - onPressed: () => Get.back(), - child: const Text("OK"), - ), - ); - } else { - Get.toNamed(statItem.route); - } - } + } class _StatItem { diff --git a/lib/view/document/document_details_page.dart b/lib/view/document/document_details_page.dart new file mode 100644 index 0000000..8b714a6 --- /dev/null +++ b/lib/view/document/document_details_page.dart @@ -0,0 +1,287 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:intl/intl.dart'; +import 'package:url_launcher/url_launcher.dart'; +import 'package:marco/helpers/widgets/my_text.dart'; +import 'package:marco/helpers/widgets/my_spacing.dart'; +import 'package:marco/helpers/widgets/my_custom_skeleton.dart'; +import 'package:marco/helpers/widgets/my_refresh_indicator.dart'; +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'; + +class DocumentDetailsPage extends StatefulWidget { + final String documentId; + + const DocumentDetailsPage({super.key, required this.documentId}); + + @override + State createState() => _DocumentDetailsPageState(); +} + +class _DocumentDetailsPageState extends State { + final DocumentDetailsController controller = + Get.put(DocumentDetailsController()); + + @override + void initState() { + super.initState(); + _fetchDetails(); + } + + Future _fetchDetails() async { + await controller.fetchDocumentDetails(widget.documentId); + final parentId = controller.documentDetails.value?.data?.parentAttachmentId; + if (parentId != null && parentId.isNotEmpty) { + await controller.fetchDocumentVersions(parentId); + } + } + + Future _onRefresh() async { + await _fetchDetails(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: const Color(0xFFF1F1F1), + appBar: CustomAppBar( + title: 'Document Details', + onBackPressed: () { + Get.back(); + }, + ), + body: Obx(() { + if (controller.isLoading.value) { + return SkeletonLoaders.documentDetailsSkeletonLoader(); + } + + final docResponse = controller.documentDetails.value; + if (docResponse == null || docResponse.data == null) { + return Center( + child: MyText.bodyMedium( + "Failed to load document details.", + color: Colors.grey, + ), + ); + } + + final doc = docResponse.data!; + + return MyRefreshIndicator( + onRefresh: _onRefresh, + child: SingleChildScrollView( + physics: + const AlwaysScrollableScrollPhysics(), // ensures pull works + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildDetailsCard(doc), + const SizedBox(height: 20), + MyText.titleMedium("Versions", + fontWeight: 700, color: Colors.black), + const SizedBox(height: 10), + _buildVersionsSection(), + ], + ), + ), + ); + }), + ); + } + + /// ---------------- DOCUMENT DETAILS CARD ---------------- + Widget _buildDetailsCard(DocumentDetails doc) { + final uploadDate = + DateFormat("dd MMM yyyy, hh:mm a").format(doc.uploadedAt.toLocal()); + final updateDate = doc.updatedAt != null + ? DateFormat("dd MMM yyyy, hh:mm a").format(doc.updatedAt!.toLocal()) + : "-"; + + return Container( + constraints: const BoxConstraints(maxWidth: 460), + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(10), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.06), + blurRadius: 16, + offset: const Offset(0, 4), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header + Row( + children: [ + CircleAvatar( + backgroundColor: Colors.blue.shade50, + radius: 28, + child: + const Icon(Icons.description, color: Colors.blue, size: 28), + ), + MySpacing.width(16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.titleLarge(doc.name, + fontWeight: 700, color: Colors.black), + MyText.bodySmall( + doc.documentType.name, + color: Colors.blueGrey, + fontWeight: 600, + ), + ], + ), + ), + ], + ), + MySpacing.height(12), + + // Tags + if (doc.tags.isNotEmpty) + Wrap( + children: doc.tags.map((t) => _buildTagChip(t.name)).toList(), + ), + MySpacing.height(16), + + // Info rows + _buildDetailRow("Document ID", doc.documentId), + _buildDetailRow("Description", doc.description ?? "-"), + _buildDetailRow( + "Category", doc.documentType.documentCategory?.name ?? "-"), + _buildDetailRow("Version", "v${doc.version}"), + _buildDetailRow( + "Current Version", doc.isCurrentVersion ? "Yes" : "No"), + _buildDetailRow("Verified", + doc.isVerified == null ? '-' : (doc.isVerified! ? "Yes" : "No")), + _buildDetailRow("Uploaded By", + "${doc.uploadedBy.firstName} ${doc.uploadedBy.lastName}"), + _buildDetailRow("Uploaded On", uploadDate), + if (doc.updatedAt != null) + _buildDetailRow("Last Updated On", updateDate), + ], + ), + ); + } + + /// ---------------- VERSIONS SECTION ---------------- + Widget _buildVersionsSection() { + return Obx(() { + if (controller.isVersionsLoading.value) { + return const Center(child: CircularProgressIndicator()); + } + + if (controller.versions.isEmpty) { + return MyText.bodySmall("No versions found", color: Colors.grey); + } + + final sorted = [...controller.versions]; + sorted.sort((a, b) => b.uploadedAt.compareTo(a.uploadedAt)); + + return ListView.separated( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: sorted.length, + separatorBuilder: (_, __) => Divider(height: 1), + itemBuilder: (context, index) { + final version = sorted[index]; + final uploadDate = + DateFormat("dd MMM yyyy, hh:mm a").format(version.uploadedAt); + + return ListTile( + leading: const Icon(Icons.description, color: Colors.blue), + title: MyText.bodyMedium( + "${version.name} (v${version.version})", + fontWeight: 600, + color: Colors.black, + ), + subtitle: MyText.bodySmall( + "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, + ); + } + }, + ), + ); + }, + ); + }); + } + + /// ---------------- HELPERS ---------------- + Widget _buildTagChip(String label) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + margin: const EdgeInsets.only(right: 6, bottom: 6), + decoration: BoxDecoration( + color: Colors.blue.shade100, + borderRadius: BorderRadius.circular(12), + ), + child: MyText.bodySmall( + label, + color: Colors.blue.shade900, + fontWeight: 600, + ), + ); + } + + Widget _buildDetailRow(String title, String value) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Row( + children: [ + SizedBox( + width: 120, + child: MyText.bodySmall( + "$title:", + fontWeight: 600, + color: Colors.grey.shade800, + overflow: TextOverflow.ellipsis, + ), + ), + Expanded( + child: MyText.bodySmall( + value, + color: Colors.grey.shade600, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ); + } + + Future _openDocument(String url) async { + 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, + ); + } +} + +} diff --git a/lib/view/document/project_document_screen.dart b/lib/view/document/project_document_screen.dart new file mode 100644 index 0000000..e69de29 diff --git a/lib/view/document/user_document_screen.dart b/lib/view/document/user_document_screen.dart new file mode 100644 index 0000000..402704c --- /dev/null +++ b/lib/view/document/user_document_screen.dart @@ -0,0 +1,317 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:marco/controller/document/user_document_controller.dart'; +import 'package:marco/controller/project_controller.dart'; +import 'package:marco/helpers/widgets/my_spacing.dart'; +import 'package:marco/helpers/widgets/my_text.dart'; +import 'package:marco/helpers/widgets/my_refresh_indicator.dart'; +import 'package:marco/helpers/utils/permission_constants.dart'; +import 'package:marco/model/document/user_document_filter_bottom_sheet.dart'; +import 'package:marco/model/document/documents_list_model.dart'; +import 'package:intl/intl.dart'; +import 'package:marco/helpers/widgets/my_custom_skeleton.dart'; +import 'package:marco/model/document/document_upload_bottom_sheet.dart'; +import 'package:marco/controller/document/document_upload_controller.dart'; +import 'package:marco/view/document/document_details_page.dart'; +import 'package:marco/helpers/widgets/custom_app_bar.dart'; + +class UserDocumentsPage extends StatefulWidget { + final String? entityId; + final bool isEmployee; + + const UserDocumentsPage({ + super.key, + this.entityId, + this.isEmployee = false, + }); + + @override + State createState() => _UserDocumentsPageState(); +} + +class _UserDocumentsPageState extends State { + final DocumentController docController = Get.put(DocumentController()); + + String get entityTypeId => widget.isEmployee + ? Permissions.employeeEntity + : Permissions.projectEntity; + + String get resolvedEntityId => widget.isEmployee + ? widget.entityId ?? "" + : Get.find().selectedProject?.id ?? ""; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + docController.fetchFilters(entityTypeId); + docController.fetchDocuments( + entityTypeId: entityTypeId, + entityId: resolvedEntityId, + reset: true, + ); + }); + } + + @override + void dispose() { + docController.documents.clear(); + super.dispose(); + } + + Widget _buildDocumentTile(DocumentItem doc) { + final uploadDate = + DateFormat("dd MMM yyyy").format(doc.uploadedAt.toLocal()); + + final uploader = doc.uploadedBy.firstName.isNotEmpty + ? "Added by ${doc.uploadedBy.firstName} ${doc.uploadedBy.lastName}" + .trim() + : "Added by you"; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), + child: MyText.bodySmall( + uploadDate, + fontSize: 13, + fontWeight: 500, + color: Colors.grey, + ), + ), + InkWell( + onTap: () { + // 👉 Navigate to details page + Get.to(() => DocumentDetailsPage(documentId: doc.id)); + }, + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: Colors.blue.shade50, + borderRadius: BorderRadius.circular(8), + ), + child: const Icon(Icons.description, color: Colors.blue), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.bodySmall( + doc.documentType.name, + fontSize: 13, + fontWeight: 600, + color: Colors.grey, + ), + MySpacing.height(2), + MyText.bodyMedium( + doc.name, + fontSize: 15, + fontWeight: 600, + color: Colors.black, + ), + MySpacing.height(2), + MyText.bodySmall( + uploader, + fontSize: 13, + color: Colors.grey, + ), + ], + ), + ), + IconButton( + icon: const Icon(Icons.arrow_forward_ios, color: Colors.black54), + onPressed: () {/* future actions */}, + ), + ], + ), + ), + ), + ], + ); + } + + Widget _buildEmptyState() { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.inbox_outlined, size: 60, color: Colors.grey), + MySpacing.height(18), + MyText.titleMedium( + 'No documents found.', + fontWeight: 600, + color: Colors.grey, + ), + MySpacing.height(10), + MyText.bodySmall( + 'Try adjusting your filters or refresh to reload.', + color: Colors.grey, + ), + ], + ), + ); + } + + Widget _buildFilterRow(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + alignment: Alignment.centerRight, + child: IconButton( + icon: const Icon(Icons.tune, color: Colors.black), + onPressed: () { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (_) => UserDocumentFilterBottomSheet( + entityId: resolvedEntityId, + entityTypeId: entityTypeId, + ), + ); + }, + ), + ); + } + + Widget _buildBody(BuildContext context) { + return Obx(() { + if (docController.isLoading.value && docController.documents.isEmpty) { + return SingleChildScrollView( + physics: const NeverScrollableScrollPhysics(), + child: SkeletonLoaders.documentSkeletonLoader(), + ); + } + + final docs = docController.documents; + return SafeArea( + child: Column( + children: [ + _buildFilterRow(context), + Expanded( + child: MyRefreshIndicator( + onRefresh: () async { + await docController.fetchDocuments( + entityTypeId: entityTypeId, + entityId: resolvedEntityId, + filter: docController.selectedFilter.value, + reset: true, + ); + }, + child: ListView( + physics: const AlwaysScrollableScrollPhysics(), + padding: docs.isEmpty + ? null + : const EdgeInsets.fromLTRB(0, 0, 0, 80), + children: docs.isEmpty + ? [ + SizedBox( + height: MediaQuery.of(context).size.height * 0.6, + child: _buildEmptyState(), + ), + ] + : [ + ...docs.map(_buildDocumentTile), + if (docController.isLoading.value) + const Padding( + padding: EdgeInsets.all(12), + child: Center(child: CircularProgressIndicator()), + ), + if (!docController.hasMore.value) + Padding( + padding: const EdgeInsets.all(12), + child: Center( + child: MyText.bodySmall( + "No more documents", + color: Colors.grey, + ), + ), + ), + ], + ), + ), + ), + ], + ), + ); + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: const Color(0xFFF1F1F1), + appBar: CustomAppBar( + title: 'Documents', + onBackPressed: () { + Get.back(); + }, + ), + body: _buildBody(context), + floatingActionButton: 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"], + ); + + if (success) { + // ✅ Only close on success + Navigator.pop(context); + + // Refresh list + docController.fetchDocuments( + entityTypeId: entityTypeId, + entityId: resolvedEntityId, + reset: true, + ); + } else { + // ❌ Don’t close, show error + 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, + ), + floatingActionButtonLocation: FloatingActionButtonLocation.endFloat, + ); + } +} diff --git a/lib/view/layouts/user_profile_right_bar.dart b/lib/view/layouts/user_profile_right_bar.dart index f739d42..9b2712d 100644 --- a/lib/view/layouts/user_profile_right_bar.dart +++ b/lib/view/layouts/user_profile_right_bar.dart @@ -10,6 +10,8 @@ import 'package:marco/helpers/widgets/avatar.dart'; import 'package:marco/model/employees/employee_info.dart'; import 'package:marco/controller/auth/mpin_controller.dart'; import 'package:marco/view/employees/employee_detail_screen.dart'; +import 'package:marco/view/document/user_document_screen.dart'; + class UserProfileBar extends StatefulWidget { final bool isCondensed; @@ -177,6 +179,12 @@ class _UserProfileBarState extends State onTap: _onProfileTap, ), SizedBox(height: spacingHeight), + _menuItemRow( + icon: LucideIcons.file_text, + label: 'My Documents', + onTap: _onDocumentsTap, + ), + SizedBox(height: spacingHeight), _menuItemRow( icon: LucideIcons.settings, label: 'Settings', @@ -244,6 +252,13 @@ class _UserProfileBarState extends State )); } + void _onDocumentsTap() { + Get.to(() => UserDocumentsPage( + entityId: "${employeeInfo.id}", + isEmployee: true, + )); + } + void _onMpinTap() { final controller = Get.put(MPINController()); if (hasMpin) controller.setChangeMpinMode();