From 334023bf1b492eca42759fecd02edd908ecf02a5 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Thu, 4 Sep 2025 16:56:49 +0530 Subject: [PATCH 01/16] 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(); From 2133dedfae7b8e81600b3aecf38ced0330ea6db2 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Thu, 4 Sep 2025 17:41:11 +0530 Subject: [PATCH 02/16] feat: Refactor employee detail navigation and add employee profile screen with tabbed interface --- lib/view/document/user_document_screen.dart | 23 ++--- .../employees/employee_detail_screen.dart | 90 ++++--------------- .../employees/employee_profile_screen.dart | 80 +++++++++++++++++ lib/view/layouts/user_profile_right_bar.dart | 10 +-- 4 files changed, 115 insertions(+), 88 deletions(-) create mode 100644 lib/view/employees/employee_profile_screen.dart diff --git a/lib/view/document/user_document_screen.dart b/lib/view/document/user_document_screen.dart index 402704c..c2d78ee 100644 --- a/lib/view/document/user_document_screen.dart +++ b/lib/view/document/user_document_screen.dart @@ -139,7 +139,7 @@ class _UserDocumentsPageState extends State { ), IconButton( icon: const Icon(Icons.arrow_forward_ios, color: Colors.black54), - onPressed: () {/* future actions */}, + onPressed: () {}, ), ], ), @@ -257,14 +257,19 @@ class _UserDocumentsPageState extends State { @override Widget build(BuildContext context) { + // Conditionally show AppBar (example: hide if employee view) + final bool showAppBar = !widget.isEmployee; + return Scaffold( backgroundColor: const Color(0xFFF1F1F1), - appBar: CustomAppBar( - title: 'Documents', - onBackPressed: () { - Get.back(); - }, - ), + appBar: showAppBar + ? CustomAppBar( + title: 'Documents', + onBackPressed: () { + Get.back(); + }, + ) + : null, body: _buildBody(context), floatingActionButton: FloatingActionButton.extended( onPressed: () { @@ -289,17 +294,13 @@ class _UserDocumentsPageState extends State { ); 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"); } }, diff --git a/lib/view/employees/employee_detail_screen.dart b/lib/view/employees/employee_detail_screen.dart index 27fb507..ffd1a63 100644 --- a/lib/view/employees/employee_detail_screen.dart +++ b/lib/view/employees/employee_detail_screen.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:intl/intl.dart'; import 'package:marco/controller/employee/employees_screen_controller.dart'; -import 'package:marco/controller/project_controller.dart'; +import 'package:marco/helpers/widgets/custom_app_bar.dart'; import 'package:marco/helpers/widgets/avatar.dart'; import 'package:marco/helpers/widgets/my_spacing.dart'; import 'package:marco/helpers/widgets/my_text.dart'; @@ -15,6 +15,7 @@ import 'package:marco/helpers/widgets/my_refresh_indicator.dart'; class EmployeeDetailPage extends StatefulWidget { final String employeeId; final bool fromProfile; + const EmployeeDetailPage({ super.key, required this.employeeId, @@ -30,6 +31,7 @@ class _EmployeeDetailPageState extends State { Get.put(EmployeesScreenController()); final PermissionController _permissionController = Get.find(); + @override void initState() { super.initState(); @@ -60,7 +62,6 @@ class _EmployeeDetailPageState extends State { } } - /// Row builder with email/phone tap & copy support Widget _buildLabelValueRow(String label, String value, {bool isMultiLine = false}) { final lowerLabel = label.toLowerCase(); @@ -91,9 +92,8 @@ class _EmployeeDetailPageState extends State { fontWeight: FontWeight.normal, color: (isEmail || isPhone) ? Colors.indigo : Colors.black54, fontSize: 14, - decoration: (isEmail || isPhone) - ? TextDecoration.underline - : TextDecoration.none, + decoration: + (isEmail || isPhone) ? TextDecoration.underline : TextDecoration.none, ), ), ); @@ -147,7 +147,6 @@ class _EmployeeDetailPageState extends State { ); } - /// Info card Widget _buildInfoCard(employee) { return Card( elevation: 3, @@ -188,73 +187,22 @@ class _EmployeeDetailPageState extends State { @override Widget build(BuildContext context) { + final bool showAppBar = !widget.fromProfile; + return Scaffold( backgroundColor: const Color(0xFFF1F1F1), - appBar: PreferredSize( - preferredSize: const Size.fromHeight(72), - child: AppBar( - backgroundColor: const Color(0xFFF5F5F5), - elevation: 0.5, - automaticallyImplyLeading: false, - titleSpacing: 0, - title: Padding( - padding: MySpacing.xy(16, 0), - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - IconButton( - icon: const Icon(Icons.arrow_back_ios_new, - color: Colors.black, size: 20), - onPressed: () { - if (widget.fromProfile) { - Get.back(); - } else { - Get.offNamed('/dashboard/employees'); - } - }, - ), - MySpacing.width(8), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - MyText.titleLarge( - 'Employee Details', - fontWeight: 700, - color: Colors.black, - ), - MySpacing.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), - MySpacing.width(4), - Expanded( - child: MyText.bodySmall( - projectName, - fontWeight: 600, - overflow: TextOverflow.ellipsis, - color: Colors.grey[700], - ), - ), - ], - ); - }, - ), - ], - ), - ), - ], - ), - ), - ), - ), + appBar: showAppBar + ? CustomAppBar( + title: 'Employee Details', + onBackPressed: () { + if (widget.fromProfile) { + Get.back(); + } else { + Get.offNamed('/dashboard/employees'); + } + }, + ) + : null, body: Obx(() { if (controller.isLoadingEmployeeDetails.value) { return const Center(child: CircularProgressIndicator()); diff --git a/lib/view/employees/employee_profile_screen.dart b/lib/view/employees/employee_profile_screen.dart new file mode 100644 index 0000000..726c452 --- /dev/null +++ b/lib/view/employees/employee_profile_screen.dart @@ -0,0 +1,80 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:marco/view/employees/employee_detail_screen.dart'; +import 'package:marco/view/document/user_document_screen.dart'; +import 'package:marco/helpers/widgets/custom_app_bar.dart'; + +class EmployeeProfilePage extends StatefulWidget { + final String employeeId; + + const EmployeeProfilePage({super.key, required this.employeeId}); + + @override + State createState() => _EmployeeProfilePageState(); +} + +class _EmployeeProfilePageState extends State + with SingleTickerProviderStateMixin { + late TabController _tabController; + + @override + void initState() { + super.initState(); + _tabController = TabController(length: 2, vsync: this); + } + + @override + void dispose() { + _tabController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: const Color(0xFFF1F1F1), + appBar: CustomAppBar( + title: "Employee Profile", + onBackPressed: () => Get.back(), + ), + body: Column( + children: [ + // ---------------- TabBar outside AppBar ---------------- + Container( + color: Colors.white, + child: TabBar( + controller: _tabController, + labelColor: Colors.black, + unselectedLabelColor: Colors.grey, + indicatorColor: Colors.red, + tabs: const [ + Tab(text: "Details"), + Tab(text: "Documents"), + ], + ), + ), + + // ---------------- TabBarView ---------------- + Expanded( + child: TabBarView( + controller: _tabController, + children: [ + // Details Tab + EmployeeDetailPage( + employeeId: widget.employeeId, + fromProfile: true, + ), + + // Documents Tab + UserDocumentsPage( + entityId: widget.employeeId, + isEmployee: true, + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/view/layouts/user_profile_right_bar.dart b/lib/view/layouts/user_profile_right_bar.dart index 9b2712d..45dc62f 100644 --- a/lib/view/layouts/user_profile_right_bar.dart +++ b/lib/view/layouts/user_profile_right_bar.dart @@ -9,10 +9,9 @@ import 'package:marco/helpers/widgets/my_text.dart'; 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/employees/employee_profile_screen.dart'; import 'package:marco/view/document/user_document_screen.dart'; - class UserProfileBar extends StatefulWidget { final bool isCondensed; const UserProfileBar({Key? key, this.isCondensed = false}) : super(key: key); @@ -182,7 +181,7 @@ class _UserProfileBarState extends State _menuItemRow( icon: LucideIcons.file_text, label: 'My Documents', - onTap: _onDocumentsTap, + onTap: _onDocumentsTap, ), SizedBox(height: spacingHeight), _menuItemRow( @@ -246,15 +245,14 @@ class _UserProfileBarState extends State } void _onProfileTap() { - Get.to(() => EmployeeDetailPage( + Get.to(() => EmployeeProfilePage( employeeId: employeeInfo.id, - fromProfile: true, )); } void _onDocumentsTap() { Get.to(() => UserDocumentsPage( - entityId: "${employeeInfo.id}", + entityId: "${employeeInfo.id}", isEmployee: true, )); } From e12e5ab13b7748ce35939f0b11594701599d422d Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Fri, 5 Sep 2025 15:14:49 +0530 Subject: [PATCH 03/16] feat: Enhance document management with delete/activate functionality, search, and inactive toggle --- .../document/user_document_controller.dart | 83 ++++- lib/helpers/services/api_endpoints.dart | 1 + lib/helpers/services/api_service.dart | 129 +++++++- .../widgets/my_confirmation_dialog.dart | 1 + lib/model/document/documents_list_model.dart | 21 ++ lib/view/document/document_details_page.dart | 68 ++-- lib/view/document/user_document_screen.dart | 302 ++++++++++++++++-- 7 files changed, 548 insertions(+), 57 deletions(-) diff --git a/lib/controller/document/user_document_controller.dart b/lib/controller/document/user_document_controller.dart index 8b4045b..869bd28 100644 --- a/lib/controller/document/user_document_controller.dart +++ b/lib/controller/document/user_document_controller.dart @@ -1,3 +1,4 @@ +import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:marco/helpers/services/api_service.dart'; import 'package:marco/model/document/document_filter_model.dart'; @@ -8,11 +9,14 @@ class DocumentController extends GetxController { var isLoading = false.obs; var documents = [].obs; var filters = Rxn(); + + // Selected filters 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; @@ -21,6 +25,13 @@ class DocumentController extends GetxController { // Error message var errorMessage = "".obs; + // NEW: show inactive toggle + var showInactive = false.obs; + + // NEW: search + var searchQuery = ''.obs; + var searchController = TextEditingController(); + // ------------------ API Calls ----------------------- /// Fetch Document Filters for an Entity @@ -41,12 +52,67 @@ class DocumentController extends GetxController { } } + /// Toggle document active/inactive state + Future toggleDocumentActive( + String id, { + required bool isActive, + required String entityTypeId, + required String entityId, + }) async { + try { + isLoading.value = true; + final success = + await ApiService.deleteDocumentApi(id: id, isActive: isActive); + + if (success) { + // 🔥 Always fetch fresh list after toggle + await fetchDocuments( + entityTypeId: entityTypeId, + entityId: entityId, + reset: true, + ); + return true; + } else { + errorMessage.value = "Failed to update document state"; + return false; + } + } catch (e) { + errorMessage.value = "Error updating document: $e"; + return false; + } finally { + isLoading.value = false; + } + } + + /// Permanently delete a document (or deactivate depending on API) + Future deleteDocument(String id, {bool isActive = false}) async { + try { + isLoading.value = true; + final success = + await ApiService.deleteDocumentApi(id: id, isActive: isActive); + + if (success) { + // remove from local list immediately for better UX + documents.removeWhere((doc) => doc.id == id); + return true; + } else { + errorMessage.value = "Failed to delete document"; + return false; + } + } catch (e) { + errorMessage.value = "Error deleting document: $e"; + return false; + } finally { + isLoading.value = false; + } + } + /// Fetch Documents for an entity Future fetchDocuments({ required String entityTypeId, required String entityId, - String filter = "", - String searchString = "", + String? filter, + String? searchString, bool reset = false, }) async { try { @@ -63,10 +129,11 @@ class DocumentController extends GetxController { final response = await ApiService.getDocumentListApi( entityTypeId: entityTypeId, entityId: entityId, - filter: filter, - searchString: searchString, + filter: filter ?? "", + searchString: searchString ?? searchQuery.value, pageNumber: pageNumber.value, pageSize: pageSize, + isActive: !showInactive.value, // 👈 active or inactive ); if (response != null && response.success) { @@ -95,4 +162,12 @@ class DocumentController extends GetxController { selectedType.value = ""; selectedTag.value = ""; } + + /// Check if any filters are active (for red dot indicator) + bool hasActiveFilters() { + return selectedUploadedBy.value.isNotEmpty || + selectedCategory.value.isNotEmpty || + selectedType.value.isNotEmpty || + selectedTag.value.isNotEmpty; + } } diff --git a/lib/helpers/services/api_endpoints.dart b/lib/helpers/services/api_endpoints.dart index 0bf1092..2f2a32f 100644 --- a/lib/helpers/services/api_endpoints.dart +++ b/lib/helpers/services/api_endpoints.dart @@ -84,4 +84,5 @@ class ApiEndpoints { static const String getDocumentTypesByCategory = "/master/document-type/list"; static const String getDocumentVersion = "/document/get/version"; static const String getDocumentVersions = "/document/list/versions"; + static const String editDocument = "/document/edit"; } diff --git a/lib/helpers/services/api_service.dart b/lib/helpers/services/api_service.dart index 078be53..d120a6c 100644 --- a/lib/helpers/services/api_service.dart +++ b/lib/helpers/services/api_service.dart @@ -19,7 +19,6 @@ 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); static const bool enableLogs = true; @@ -247,6 +246,7 @@ class ApiService { return null; } } + /// Get Pre-Signed URL for Old Version static Future getPresignedUrlApi(String versionId) async { final endpoint = "${ApiEndpoints.getDocumentVersion}/$versionId"; @@ -268,22 +268,138 @@ class ApiService { return jsonResponse['data'] as String?; } } catch (e, stack) { - logSafe("Exception during getPresignedUrlApi: $e", - level: LogLevel.error); + logSafe("Exception during getPresignedUrlApi: $e", level: LogLevel.error); logSafe("StackTrace: $stack", level: LogLevel.debug); } return null; } + /// Delete (Soft Delete / Deactivate) Document API + static Future deleteDocumentApi({ + required String id, + bool isActive = false, // default false = delete + }) async { + final endpoint = "${ApiEndpoints.deleteDocument}/$id"; + final queryParams = {"isActive": isActive.toString()}; + logSafe("Deleting document with id: $id | isActive: $isActive"); + + try { + final uri = Uri.parse("${ApiEndpoints.baseUrl}$endpoint") + .replace(queryParameters: queryParams); + + String? token = await _getToken(); + if (token == null) return false; + + final headers = _headers(token); + logSafe("DELETE (PUT/POST style) $uri\nHeaders: $headers"); + + // some backends use PUT instead of DELETE for soft deletes + final response = + await http.delete(uri, headers: headers).timeout(extendedTimeout); + + if (response.statusCode == 401) { + logSafe("Unauthorized DELETE. Attempting token refresh..."); + if (await AuthService.refreshToken()) { + return await deleteDocumentApi(id: id, isActive: isActive); + } + } + + logSafe("Delete document response status: ${response.statusCode}"); + logSafe("Delete document response body: ${response.body}"); + + final json = jsonDecode(response.body); + if (json['success'] == true) { + logSafe("Document delete/update success: ${json['data']}"); + return true; + } else { + logSafe( + "Failed to delete document: ${json['message'] ?? 'Unknown error'}", + level: LogLevel.warning, + ); + } + } catch (e, stack) { + logSafe("Exception during deleteDocumentApi: $e", level: LogLevel.error); + logSafe("StackTrace: $stack", level: LogLevel.debug); + } + + return false; + } + + /// Edit Document API + static Future editDocumentApi({ + required String id, + required String name, + required String documentId, + String? description, + required String fileName, + required String base64Data, + required String contentType, + required int fileSize, + String? fileDescription, + bool isActive = true, + List> tags = const [], + }) async { + final endpoint = "${ApiEndpoints.editDocument}/$id"; + logSafe("Editing document with id: $id"); + + final Map payload = { + "id": id, + "name": name, + "documentId": documentId, + "description": description ?? "", + "attachment": { + "fileName": fileName, + "base64Data": base64Data, + "contentType": contentType, + "fileSize": fileSize, + "description": fileDescription ?? "", + "isActive": isActive, + }, + "tags": tags.isNotEmpty + ? tags + : [ + {"name": "default", "isActive": true} + ], + }; + + try { + final response = + await _putRequest(endpoint, payload, customTimeout: extendedTimeout); + + if (response == null) { + logSafe("Edit document failed: null response", level: LogLevel.error); + return false; + } + + logSafe("Edit document response status: ${response.statusCode}"); + logSafe("Edit document response body: ${response.body}"); + + final json = jsonDecode(response.body); + if (json['success'] == true) { + logSafe("Document edited successfully: ${json['data']}"); + return true; + } else { + logSafe( + "Failed to edit document: ${json['message'] ?? 'Unknown error'}", + level: LogLevel.warning, + ); + } + } catch (e, stack) { + logSafe("Exception during editDocumentApi: $e", level: LogLevel.error); + logSafe("StackTrace: $stack", level: LogLevel.debug); + } + + return false; + } + /// 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 endpoint = "${ApiEndpoints.getDocumentVersions}/$parentAttachmentId"; final queryParams = { "pageNumber": pageNumber.toString(), "pageSize": pageSize.toString(), @@ -293,8 +409,7 @@ class ApiService { "Fetching document versions for parentAttachmentId: $parentAttachmentId"); try { - final response = - await _getRequest(endpoint, queryParams: queryParams); + final response = await _getRequest(endpoint, queryParams: queryParams); if (response == null) { logSafe("Document versions request failed: null response", diff --git a/lib/helpers/widgets/my_confirmation_dialog.dart b/lib/helpers/widgets/my_confirmation_dialog.dart index c1c15d4..9c9c862 100644 --- a/lib/helpers/widgets/my_confirmation_dialog.dart +++ b/lib/helpers/widgets/my_confirmation_dialog.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:marco/helpers/widgets/my_text.dart'; + class ConfirmDialog extends StatelessWidget { final String title; final String message; diff --git a/lib/model/document/documents_list_model.dart b/lib/model/document/documents_list_model.dart index f589362..5bbf820 100644 --- a/lib/model/document/documents_list_model.dart +++ b/lib/model/document/documents_list_model.dart @@ -142,6 +142,27 @@ class DocumentItem { } } +extension DocumentItemCopy on DocumentItem { + DocumentItem copyWith({ + bool? isActive, + }) { + return DocumentItem( + id: id, + name: name, + documentId: documentId, + description: description, + uploadedAt: uploadedAt, + parentAttachmentId: parentAttachmentId, + isCurrentVersion: isCurrentVersion, + version: version, + isActive: isActive ?? this.isActive, + isVerified: isVerified, + uploadedBy: uploadedBy, + documentType: documentType, + ); + } +} + class UploadedBy { final String id; final String firstName; diff --git a/lib/view/document/document_details_page.dart b/lib/view/document/document_details_page.dart index 8b714a6..8775c34 100644 --- a/lib/view/document/document_details_page.dart +++ b/lib/view/document/document_details_page.dart @@ -73,7 +73,7 @@ class _DocumentDetailsPageState extends State { onRefresh: _onRefresh, child: SingleChildScrollView( physics: - const AlwaysScrollableScrollPhysics(), // ensures pull works + const AlwaysScrollableScrollPhysics(), padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -117,30 +117,43 @@ class _DocumentDetailsPageState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Header + // Header with Edit button Row( + crossAxisAlignment: CrossAxisAlignment.start, 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, + child: Row( children: [ - MyText.titleLarge(doc.name, - fontWeight: 700, color: Colors.black), - MyText.bodySmall( - doc.documentType.name, - color: Colors.blueGrey, - fontWeight: 600, + 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, + ), + ], + ), ), ], ), ), + IconButton( + icon: const Icon(Icons.edit, color: Colors.red), + onPressed: () { + + }, + ), ], ), MySpacing.height(12), @@ -272,16 +285,15 @@ class _DocumentDetailsPageState extends State { } 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, - ); + 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/user_document_screen.dart b/lib/view/document/user_document_screen.dart index c2d78ee..2b87ecf 100644 --- a/lib/view/document/user_document_screen.dart +++ b/lib/view/document/user_document_screen.dart @@ -14,6 +14,8 @@ 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'; +import 'package:marco/helpers/widgets/my_confirmation_dialog.dart'; +import 'package:marco/helpers/widgets/my_snackbar.dart'; class UserDocumentsPage extends StatefulWidget { final String? entityId; @@ -137,9 +139,85 @@ class _UserDocumentsPageState extends State { ], ), ), - IconButton( - icon: const Icon(Icons.arrow_forward_ios, color: Colors.black54), - onPressed: () {}, + PopupMenuButton( + icon: const Icon(Icons.more_vert, color: Colors.black54), + onSelected: (value) async { + if (value == "delete") { + final result = await showDialog( + context: context, + builder: (_) => ConfirmDialog( + title: "Delete Document", + message: + "Are you sure you want to delete \"${doc.name}\"?\nThis action cannot be undone.", + confirmText: "Delete", + cancelText: "Cancel", + icon: Icons.delete_forever, + confirmColor: Colors.redAccent, + onConfirm: () async { + final success = + await docController.toggleDocumentActive( + doc.id, + isActive: false, + entityTypeId: entityTypeId, + entityId: resolvedEntityId, + ); + + if (success) { + showAppSnackbar( + title: "Deleted", + message: "Document deleted successfully", + type: SnackbarType.success, + ); + } else { + showAppSnackbar( + title: "Error", + message: "Failed to delete document", + type: SnackbarType.error, + ); + throw Exception( + "Failed to delete"); // keep dialog open + } + }, + ), + ); + if (result == true) { + debugPrint("✅ Document deleted and removed from list"); + } + } else if (value == "activate") { + final success = await docController.toggleDocumentActive( + doc.id, + isActive: true, + entityTypeId: entityTypeId, + entityId: resolvedEntityId, + ); + + if (success) { + showAppSnackbar( + title: "Reactivated", + message: "Document reactivated successfully", + type: SnackbarType.success, + ); + } else { + showAppSnackbar( + title: "Error", + message: "Failed to reactivate document", + type: SnackbarType.error, + ); + } + } + }, + itemBuilder: (context) => [ + if (doc.isActive) + const PopupMenuItem( + value: "delete", + child: Text("Delete"), + ) + else + const PopupMenuItem( + value: "activate", + child: Text("Activate"), + ), + ], ), ], ), @@ -172,26 +250,210 @@ class _UserDocumentsPageState extends State { } 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, + return Padding( + padding: MySpacing.xy(8, 8), + child: Row( + children: [ + // 🔍 Search Bar + Expanded( + child: SizedBox( + height: 35, + child: TextField( + controller: docController.searchController, + onChanged: (value) { + docController.searchQuery.value = value; + docController.fetchDocuments( + entityTypeId: entityTypeId, + entityId: resolvedEntityId, + reset: true, + ); + }, + decoration: InputDecoration( + contentPadding: const EdgeInsets.symmetric(horizontal: 12), + prefixIcon: + const Icon(Icons.search, size: 20, color: Colors.grey), + suffixIcon: ValueListenableBuilder( + valueListenable: docController.searchController, + builder: (context, value, _) { + if (value.text.isEmpty) return const SizedBox.shrink(); + return IconButton( + icon: const Icon(Icons.clear, + size: 20, color: Colors.grey), + onPressed: () { + docController.searchController.clear(); + docController.searchQuery.value = ''; + docController.fetchDocuments( + entityTypeId: entityTypeId, + entityId: resolvedEntityId, + reset: true, + ); + }, + ); + }, + ), + hintText: 'Search documents...', + filled: true, + fillColor: Colors.white, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + ), + ), ), - ); - }, + ), + MySpacing.width(8), + + // 🛠️ Filter Icon with indicator + Obx(() { + final isFilterActive = docController.hasActiveFilters(); + return Stack( + children: [ + Container( + height: 35, + width: 35, + decoration: BoxDecoration( + color: Colors.white, + border: Border.all(color: Colors.grey.shade300), + borderRadius: BorderRadius.circular(10), + ), + child: IconButton( + padding: EdgeInsets.zero, + constraints: BoxConstraints(), + icon: Icon( + Icons.tune, + size: 20, + color: Colors.black87, + ), + onPressed: () { + showModalBottomSheet( + context: context, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: + BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (_) => UserDocumentFilterBottomSheet( + entityId: resolvedEntityId, + entityTypeId: entityTypeId, + ), + ); + }, + ), + ), + if (isFilterActive) + Positioned( + top: 6, + right: 6, + child: Container( + height: 8, + width: 8, + decoration: const BoxDecoration( + color: Colors.red, + shape: BoxShape.circle, + ), + ), + ), + ], + ); + }), + MySpacing.width(10), + + // ⋮ Menu (Show Inactive toggle) + Container( + height: 35, + width: 35, + decoration: BoxDecoration( + color: Colors.white, + border: Border.all(color: Colors.grey.shade300), + borderRadius: BorderRadius.circular(10), + ), + child: PopupMenuButton( + padding: EdgeInsets.zero, + icon: + const Icon(Icons.more_vert, size: 20, color: Colors.black87), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + itemBuilder: (context) => [ + const PopupMenuItem( + enabled: false, + height: 30, + child: Text( + "Preferences", + style: TextStyle( + fontWeight: FontWeight.bold, + color: Colors.grey, + ), + ), + ), + PopupMenuItem( + value: 0, + enabled: false, + child: Obx(() => Row( + children: [ + const Icon(Icons.visibility_off_outlined, + size: 20, color: Colors.black87), + const SizedBox(width: 10), + const Expanded(child: Text('Show Inactive')), + Switch.adaptive( + value: docController.showInactive.value, + activeColor: Colors.indigo, + onChanged: (val) { + docController.showInactive.value = val; + docController.fetchDocuments( + entityTypeId: entityTypeId, + entityId: resolvedEntityId, + reset: true, + ); + Navigator.pop(context); + }, + ), + ], + )), + ), + ], + ), + ), + ], ), ); } + Widget _buildStatusHeader() { + return Obx(() { + final isInactive = docController.showInactive.value; + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), + color: isInactive ? Colors.red.shade50 : Colors.green.shade50, + child: Row( + children: [ + Icon( + isInactive ? Icons.visibility_off : Icons.check_circle, + color: isInactive ? Colors.red : Colors.green, + size: 18, + ), + const SizedBox(width: 8), + Text( + isInactive + ? "Showing Inactive Documents" + : "Showing Active Documents", + style: TextStyle( + color: isInactive ? Colors.red : Colors.green, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ); + }); + } + Widget _buildBody(BuildContext context) { return Obx(() { if (docController.isLoading.value && docController.documents.isEmpty) { @@ -206,6 +468,10 @@ class _UserDocumentsPageState extends State { child: Column( children: [ _buildFilterRow(context), + + // 👇 Add this + _buildStatusHeader(), + Expanded( child: MyRefreshIndicator( onRefresh: () async { From 4d11a2ccf0833ee27a4ff8e8c7f269a2fcf62322 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Fri, 5 Sep 2025 16:22:05 +0530 Subject: [PATCH 04/16] feat: Simplify document upload by removing initial data handling and existing file name references --- .../document_upload_bottom_sheet.dart | 79 +------------------ 1 file changed, 3 insertions(+), 76 deletions(-) diff --git a/lib/model/document/document_upload_bottom_sheet.dart b/lib/model/document/document_upload_bottom_sheet.dart index 94b9d24..7876abb 100644 --- a/lib/model/document/document_upload_bottom_sheet.dart +++ b/lib/model/document/document_upload_bottom_sheet.dart @@ -13,12 +13,10 @@ 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 @@ -35,55 +33,6 @@ class _DocumentUploadBottomSheetState extends State { 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() { @@ -96,7 +45,7 @@ class _DocumentUploadBottomSheetState extends State { void _handleSubmit() { if (!(_formKey.currentState?.validate() ?? false)) return; - if (selectedFile == null && existingFileName == null) { + if (selectedFile == null) { showAppSnackbar( title: "Error", message: "Please attach a document", @@ -173,7 +122,6 @@ class _DocumentUploadBottomSheetState extends State { setState(() { selectedFile = file; - existingFileName = null; controller.selectedFileName = fileName; controller.selectedFileBase64 = base64Data; controller.selectedFileContentType = @@ -188,7 +136,7 @@ class _DocumentUploadBottomSheetState extends State { @override Widget build(BuildContext context) { return BaseBottomSheet( - title: widget.initialData == null ? "Upload Document" : "Edit Document", + title: "Upload Document", onCancel: () => Navigator.pop(context), onSubmit: _handleSubmit, child: Form( @@ -267,11 +215,9 @@ class _DocumentUploadBottomSheetState extends State { /// 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; @@ -392,38 +338,18 @@ class _DocumentUploadBottomSheetState extends State { /// ---------------- 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) { @@ -553,6 +479,7 @@ class AttachmentSectionSingle extends StatelessWidget { } } + // ---- Reusable Widgets ---- class LabeledInput extends StatelessWidget { From bf84ef4786303850120e6e585925c289edc12514 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Fri, 5 Sep 2025 17:37:58 +0530 Subject: [PATCH 05/16] feat: Swap colors for completed and remaining tasks in tasks overview --- lib/helpers/widgets/dashbaord/dashboard_overview_widgets.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/helpers/widgets/dashbaord/dashboard_overview_widgets.dart b/lib/helpers/widgets/dashbaord/dashboard_overview_widgets.dart index ac21276..2c44271 100644 --- a/lib/helpers/widgets/dashbaord/dashboard_overview_widgets.dart +++ b/lib/helpers/widgets/dashbaord/dashboard_overview_widgets.dart @@ -110,8 +110,8 @@ class DashboardOverviewWidgets { final double percent = total > 0 ? completed / total : 0.0; // Task colors - const completedColor = Color(0xFFE57373); // red - const remainingColor = Color(0xFF64B5F6); // blue + const completedColor = Color(0xFF64B5F6); + const remainingColor =Color(0xFFE57373); final List<_ChartData> pieData = [ _ChartData('Completed', completed.toDouble(), completedColor), From 99bd26942c28798ea6f7a84fbb1a2dff1a6bb047 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Mon, 8 Sep 2025 18:00:07 +0530 Subject: [PATCH 06/16] feat: Implement document editing functionality with permissions and attachment handling --- .../document/document_upload_controller.dart | 48 ++ lib/helpers/services/api_service.dart | 16 +- lib/helpers/utils/permission_constants.dart | 19 + .../document/document_details_model.dart | 46 +- .../document/document_edit_bottom_sheet.dart | 753 ++++++++++++++++++ lib/view/document/document_details_page.dart | 101 ++- lib/view/document/user_document_screen.dart | 122 +-- 7 files changed, 1020 insertions(+), 85 deletions(-) create mode 100644 lib/model/document/document_edit_bottom_sheet.dart diff --git a/lib/controller/document/document_upload_controller.dart b/lib/controller/document/document_upload_controller.dart index f698d41..c7e33c7 100644 --- a/lib/controller/document/document_upload_controller.dart +++ b/lib/controller/document/document_upload_controller.dart @@ -68,6 +68,10 @@ class DocumentUploadController extends GetxController { } } + Future fetchPresignedUrl(String versionId) async { + return await ApiService.getPresignedUrlApi(versionId); + } + /// Fetch available document tags Future fetchTags() async { try { @@ -188,4 +192,48 @@ class DocumentUploadController extends GetxController { isUploading.value = false; } } + + Future editDocument(Map payload) async { + try { + isUploading.value = true; + + final attachment = payload["attachment"]; + + final success = await ApiService.editDocumentApi( + id: payload["id"], + name: payload["name"], + documentId: payload["documentId"], + description: payload["description"], + tags: (payload["tags"] as List).cast>(), + attachment: attachment, + ); + + if (success) { + showAppSnackbar( + title: "Success", + message: "Document updated successfully", + type: SnackbarType.success, + ); + } else { + showAppSnackbar( + title: "Error", + message: "Failed to update document", + type: SnackbarType.error, + ); + } + + return success; + } catch (e, stack) { + logSafe("Edit error: $e", level: LogLevel.error); + logSafe("Stacktrace: $stack", level: LogLevel.debug); + showAppSnackbar( + title: "Error", + message: "An unexpected error occurred", + type: SnackbarType.error, + ); + return false; + } finally { + isUploading.value = false; + } + } } diff --git a/lib/helpers/services/api_service.dart b/lib/helpers/services/api_service.dart index d120a6c..5d1a1e8 100644 --- a/lib/helpers/services/api_service.dart +++ b/lib/helpers/services/api_service.dart @@ -332,13 +332,8 @@ class ApiService { required String name, required String documentId, String? description, - required String fileName, - required String base64Data, - required String contentType, - required int fileSize, - String? fileDescription, - bool isActive = true, List> tags = const [], + Map? attachment, // 👈 can be null }) async { final endpoint = "${ApiEndpoints.editDocument}/$id"; logSafe("Editing document with id: $id"); @@ -348,19 +343,12 @@ class ApiService { "name": name, "documentId": documentId, "description": description ?? "", - "attachment": { - "fileName": fileName, - "base64Data": base64Data, - "contentType": contentType, - "fileSize": fileSize, - "description": fileDescription ?? "", - "isActive": isActive, - }, "tags": tags.isNotEmpty ? tags : [ {"name": "default", "isActive": true} ], + "attachment": attachment, // 👈 null or object }; try { diff --git a/lib/helpers/utils/permission_constants.dart b/lib/helpers/utils/permission_constants.dart index 05d80ff..f257cfc 100644 --- a/lib/helpers/utils/permission_constants.dart +++ b/lib/helpers/utils/permission_constants.dart @@ -98,4 +98,23 @@ class Permissions { /// Entity ID for employee documents static const String employeeEntity = "dbb9555a-7a0c-40f2-a9ed-f0463f1ceed7"; + + // ------------------- Document Permissions ---------------------------- + /// Permission to view documents + static const String viewDocument = "71189504-f1c8-4ca5-8db6-810497be2854"; + + /// Permission to upload documents + static const String uploadDocument = "3f6d1f67-6fa5-4b7c-b17b-018d4fe4aab8"; + + /// Permission to modify documents + static const String modifyDocument = "c423fd81-6273-4b9d-bb5e-76a0fb343833"; + + /// Permission to delete documents + static const String deleteDocument = "40863a13-5a66-469d-9b48-135bc5dbf486"; + + /// Permission to download documents + static const String downloadDocument = "404373d0-860f-490e-a575-1c086ffbce1d"; + + /// Permission to verify documents + static const String verifyDocument = "13a1f30f-38d1-41bf-8e7a-b75189aab8e0"; } diff --git a/lib/model/document/document_details_model.dart b/lib/model/document/document_details_model.dart index 055e3db..06e52eb 100644 --- a/lib/model/document/document_details_model.dart +++ b/lib/model/document/document_details_model.dart @@ -130,19 +130,30 @@ class UploadedBy { jobRoleName: json['jobRoleName'] ?? '', ); } + + Map toJson() { + return { + 'id': id, + 'firstName': firstName, + 'lastName': lastName, + 'photo': photo, + 'jobRoleId': jobRoleId, + 'jobRoleName': jobRoleName, + }; + } } class DocumentType { final String id; final String name; - final String? regexExpression; // nullable + final String? regexExpression; final String allowedContentType; final int maxSizeAllowedInMB; final bool isValidationRequired; final bool isMandatory; final bool isSystem; final bool isActive; - final DocumentCategory? documentCategory; // nullable + final DocumentCategory? documentCategory; DocumentType({ required this.id, @@ -173,6 +184,21 @@ class DocumentType { : null, ); } + + Map toJson() { + return { + 'id': id, + 'name': name, + 'regexExpression': regexExpression, + 'allowedContentType': allowedContentType, + 'maxSizeAllowedInMB': maxSizeAllowedInMB, + 'isValidationRequired': isValidationRequired, + 'isMandatory': isMandatory, + 'isSystem': isSystem, + 'isActive': isActive, + 'documentCategory': documentCategory?.toJson(), + }; + } } class DocumentCategory { @@ -196,6 +222,15 @@ class DocumentCategory { entityTypeId: json['entityTypeId'] ?? '', ); } + + Map toJson() { + return { + 'id': id, + 'name': name, + 'description': description, + 'entityTypeId': entityTypeId, + }; + } } class DocumentTag { @@ -210,4 +245,11 @@ class DocumentTag { isActive: json['isActive'] ?? false, ); } + + Map toJson() { + return { + 'name': name, + 'isActive': isActive, + }; + } } diff --git a/lib/model/document/document_edit_bottom_sheet.dart b/lib/model/document/document_edit_bottom_sheet.dart new file mode 100644 index 0000000..fa375dd --- /dev/null +++ b/lib/model/document/document_edit_bottom_sheet.dart @@ -0,0 +1,753 @@ +import 'dart:convert'; +import 'dart:io'; +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:marco/controller/document/document_upload_controller.dart'; +import 'package:marco/helpers/utils/base_bottom_sheet.dart'; +import 'package:marco/helpers/widgets/my_spacing.dart'; +import 'package:marco/helpers/widgets/my_snackbar.dart'; +import 'package:marco/helpers/widgets/my_text.dart'; +import 'package:marco/helpers/widgets/image_viewer_dialog.dart'; +import 'package:marco/model/document/master_document_type_model.dart'; +import 'package:marco/helpers/widgets/my_text_style.dart'; +import 'package:url_launcher/url_launcher.dart'; + +class DocumentEditBottomSheet extends StatefulWidget { + final Map documentData; + final Function(Map) onSubmit; + + const DocumentEditBottomSheet({ + Key? key, + required this.documentData, + required this.onSubmit, + }) : super(key: key); + + @override + State createState() => + _DocumentEditBottomSheetState(); +} + +class _DocumentEditBottomSheetState extends State { + final _formKey = GlobalKey(); + final controller = Get.put(DocumentUploadController()); + + final TextEditingController _docIdController = TextEditingController(); + final TextEditingController _docNameController = TextEditingController(); + final TextEditingController _descriptionController = TextEditingController(); + String? latestVersionUrl; + + File? selectedFile; + bool fileChanged = false; + @override + void initState() { + super.initState(); + + _docIdController.text = widget.documentData["documentId"] ?? ""; + _docNameController.text = widget.documentData["name"] ?? ""; + _descriptionController.text = widget.documentData["description"] ?? ""; + + // Tags + if (widget.documentData["tags"] != null) { + controller.enteredTags.assignAll( + List.from( + (widget.documentData["tags"] as List) + .map((t) => t is String ? t : t["name"]), + ), + ); + } + + // --- Convert category map to DocumentType --- + if (widget.documentData["category"] != null) { + controller.selectedCategory = + DocumentType.fromJson(widget.documentData["category"]); + } + + // Type (if separate) + if (widget.documentData["type"] != null) { + controller.selectedType = + DocumentType.fromJson(widget.documentData["type"]); + } + // Fetch latest version URL if attachment exists + final latestVersion = widget.documentData["attachment"]; + if (latestVersion != null) { + WidgetsBinding.instance.addPostFrameCallback((_) async { + final url = await controller.fetchPresignedUrl(latestVersion["id"]); + if (url != null) { + setState(() { + latestVersionUrl = url; + }); + } + }); + } + } + + @override + void dispose() { + _docIdController.dispose(); + _docNameController.dispose(); + _descriptionController.dispose(); + super.dispose(); + } + + void _handleSubmit() async { + if (!(_formKey.currentState?.validate() ?? false)) return; + + // ✅ Validate only if user picked a new file + if (fileChanged && selectedFile != null) { + final maxSizeMB = controller.selectedType?.maxSizeAllowedInMB; + if (maxSizeMB != null && controller.selectedFileSize != null) { + final fileSizeMB = controller.selectedFileSize! / (1024 * 1024); + if (fileSizeMB > maxSizeMB) { + showAppSnackbar( + title: "Error", + message: "File size exceeds $maxSizeMB MB limit", + type: SnackbarType.error, + ); + return; + } + } + } + + final payload = { + "id": widget.documentData["id"], + "documentId": _docIdController.text.trim(), + "name": _docNameController.text.trim(), + "description": _descriptionController.text.trim(), + "documentTypeId": controller.selectedType?.id, + "tags": controller.enteredTags + .map((t) => {"name": t, "isActive": true}) + .toList(), + }; + +// ✅ Always include attachment logic + if (fileChanged) { + if (selectedFile != null) { + // User picked new file + payload["attachment"] = { + "fileName": controller.selectedFileName, + "base64Data": controller.selectedFileBase64, + "contentType": controller.selectedFileContentType, + "fileSize": controller.selectedFileSize, + "isActive": true, + }; + } else { + // User explicitly removed file + payload["attachment"] = null; + } + } else { + // ✅ User did NOT touch the attachment → send null explicitly + payload["attachment"] = null; + } + + // else: do nothing → existing attachment remains as is + + final success = await controller.editDocument(payload); + if (success) { + widget.onSubmit(payload); + Navigator.pop(context); + } + } + + Future _pickFile() async { + final result = await FilePicker.platform.pickFiles( + type: FileType.custom, + allowedExtensions: ['pdf', 'jpg', 'png', 'jpeg'], + ); + + if (result != null && result.files.single.path != null) { + final file = File(result.files.single.path!); + final fileName = result.files.single.name; + final fileBytes = await file.readAsBytes(); + final base64Data = base64Encode(fileBytes); + + setState(() { + selectedFile = file; + fileChanged = true; + controller.selectedFileName = fileName; + controller.selectedFileBase64 = base64Data; + controller.selectedFileContentType = + result.files.single.extension?.toLowerCase() == "pdf" + ? "application/pdf" + : "image/${result.files.single.extension?.toLowerCase()}"; + controller.selectedFileSize = (fileBytes.length / 1024).round(); + }); + } + } + + @override + Widget build(BuildContext context) { + return BaseBottomSheet( + title: "Edit Document", + onCancel: () => Navigator.pop(context), + onSubmit: _handleSubmit, + child: Form( + key: _formKey, + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MySpacing.height(16), + + /// Document ID + LabeledInput( + label: "Document ID", + hint: "Enter Document ID", + controller: _docIdController, + validator: (v) => + v == null || v.trim().isEmpty ? "Required" : null, + isRequired: true, + ), + MySpacing.height(16), + + /// Document Name + LabeledInput( + label: "Document Name", + hint: "e.g., PAN Card", + controller: _docNameController, + validator: (v) => + v == null || v.trim().isEmpty ? "Required" : null, + isRequired: true, + ), + MySpacing.height(16), + + /// Document Category (Read-only, non-editable) + LabeledInput( + label: "Document Category", + hint: "", + controller: TextEditingController( + text: controller.selectedCategory?.name ?? ""), + validator: (_) => null, + isRequired: false, + // Disable interaction + readOnly: true, + ), + + MySpacing.height(16), + + /// Document Type (Read-only, non-editable) + LabeledInput( + label: "Document Type", + hint: "", + controller: TextEditingController( + text: controller.selectedType?.name ?? ""), + validator: (_) => null, + isRequired: false, + readOnly: true, + ), + + MySpacing.height(24), + + /// Attachment Section + AttachmentSectionSingle( + attachmentFile: selectedFile, + attachmentUrl: latestVersionUrl, + onPick: _pickFile, + onRemove: () => setState(() { + selectedFile = null; + fileChanged = true; + controller.selectedFileName = null; + controller.selectedFileBase64 = null; + controller.selectedFileContentType = null; + controller.selectedFileSize = null; + latestVersionUrl = null; + }), + ), + + if (controller.selectedType?.maxSizeAllowedInMB != null) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Text( + "Max file size: ${controller.selectedType!.maxSizeAllowedInMB} MB", + style: const TextStyle(color: Colors.grey, fontSize: 12), + ), + ), + MySpacing.height(16), + + /// Tags Section + MyText.labelMedium("Tags"), + MySpacing.height(8), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + height: 56, + child: TextFormField( + controller: controller.tagCtrl, + onChanged: controller.filterSuggestions, + onFieldSubmitted: (v) { + controller.addEnteredTag(v); + controller.tagCtrl.clear(); + controller.clearSuggestions(); + }, + decoration: InputDecoration( + hintText: "Start typing to add tags", + hintStyle: MyTextStyle.bodySmall(xMuted: true), + filled: true, + fillColor: Colors.grey.shade100, + border: _inputBorder(), + enabledBorder: _inputBorder(), + focusedBorder: _inputFocusedBorder(), + contentPadding: MySpacing.all(16), + ), + ), + ), + Obx(() => controller.filteredSuggestions.isEmpty + ? const SizedBox.shrink() + : Container( + margin: const EdgeInsets.only(top: 4), + decoration: BoxDecoration( + color: Colors.white, + border: Border.all(color: Colors.grey.shade300), + borderRadius: BorderRadius.circular(8), + boxShadow: const [ + BoxShadow(color: Colors.black12, blurRadius: 4), + ], + ), + child: ListView.builder( + shrinkWrap: true, + itemCount: controller.filteredSuggestions.length, + itemBuilder: (_, i) { + final suggestion = + controller.filteredSuggestions[i]; + return ListTile( + dense: true, + title: Text(suggestion), + onTap: () { + controller.addEnteredTag(suggestion); + controller.tagCtrl.clear(); + controller.clearSuggestions(); + }, + ); + }, + ), + )), + MySpacing.height(8), + Obx(() => Wrap( + spacing: 8, + children: controller.enteredTags + .map((tag) => Chip( + label: Text(tag), + onDeleted: () => + controller.removeEnteredTag(tag), + )) + .toList(), + )), + ], + ), + MySpacing.height(16), + + /// Description + LabeledInput( + label: "Description", + hint: "Enter short description", + controller: _descriptionController, + validator: (v) => + v == null || v.trim().isEmpty ? "Required" : null, + isRequired: true, + ), + ], + ), + ), + ), + ); + } +} + +OutlineInputBorder _inputBorder() => OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: Colors.grey.shade300), + ); + +OutlineInputBorder _inputFocusedBorder() => const OutlineInputBorder( + borderRadius: BorderRadius.all(Radius.circular(12)), + borderSide: BorderSide(color: Colors.blueAccent, width: 1.5), + ); + +/// ---------------- Single Attachment Widget (Rewritten) ---------------- +class AttachmentSectionSingle extends StatelessWidget { + final File? attachmentFile; // Local file + final String? attachmentUrl; // Online latest version URL + final VoidCallback onPick; + final VoidCallback? onRemove; + + const AttachmentSectionSingle({ + Key? key, + this.attachmentFile, + this.attachmentUrl, + required this.onPick, + this.onRemove, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + final allowedImageExtensions = ['jpg', 'jpeg', 'png']; + + Widget buildTile({File? file, String? url}) { + final isImage = file != null + ? allowedImageExtensions + .contains(file.path.split('.').last.toLowerCase()) + : url != null + ? allowedImageExtensions + .contains(url.split('.').last.toLowerCase()) + : false; + + final fileName = file != null + ? file.path.split('/').last + : url != null + ? url.split('/').last + : ''; + + IconData fileIcon = Icons.insert_drive_file; + Color iconColor = Colors.blueGrey; + + if (!isImage) { + final ext = fileName.split('.').last.toLowerCase(); + switch (ext) { + case 'pdf': + fileIcon = Icons.picture_as_pdf; + iconColor = Colors.redAccent; + break; + case 'doc': + case 'docx': + fileIcon = Icons.description; + iconColor = Colors.blueAccent; + break; + case 'xls': + case 'xlsx': + fileIcon = Icons.table_chart; + iconColor = Colors.green; + break; + case 'txt': + fileIcon = Icons.article; + iconColor = Colors.grey; + break; + } + } + + return Stack( + clipBehavior: Clip.none, + children: [ + GestureDetector( + onTap: () async { + if (isImage && file != null) { + showDialog( + context: context, + builder: (_) => ImageViewerDialog( + imageSources: [file], + initialIndex: 0, + ), + ); + } else if (url != null) { + final uri = Uri.parse(url); + if (await canLaunchUrl(uri)) { + await launchUrl(uri, mode: LaunchMode.externalApplication); + } else { + showAppSnackbar( + title: "Error", + message: "Could not open document", + type: SnackbarType.error, + ); + } + } + }, + child: Container( + width: 100, + height: 100, + margin: const EdgeInsets.only(right: 8), + decoration: BoxDecoration( + border: Border.all(color: Colors.grey.shade300), + borderRadius: BorderRadius.circular(8), + color: Colors.grey.shade100, + ), + child: isImage && file != null + ? ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.file(file, fit: BoxFit.cover), + ) + : Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(fileIcon, color: iconColor, size: 30), + const SizedBox(height: 4), + ], + ), + ), + ), + if (onRemove != null) + Positioned( + top: -6, + right: -6, + child: IconButton( + icon: const Icon(Icons.close, color: Colors.red, size: 18), + onPressed: onRemove, + ), + ), + ], + ); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, // prevent overflow + children: [ + Row( + children: const [ + Text("Attachment", style: TextStyle(fontWeight: FontWeight.w600)), + Text(" *", + style: + TextStyle(color: Colors.red, fontWeight: FontWeight.bold)) + ], + ), + const SizedBox(height: 8), + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + if (attachmentFile != null) + buildTile(file: attachmentFile) + else if (attachmentUrl != null) + buildTile(url: attachmentUrl) + else + GestureDetector( + onTap: onPick, + child: Container( + width: 100, + height: 100, + decoration: BoxDecoration( + border: Border.all(color: Colors.grey.shade300), + borderRadius: BorderRadius.circular(8), + color: Colors.grey.shade100, + ), + child: const Icon(Icons.add, size: 40, color: Colors.grey), + ), + ), + ], + ), + ), + ], + ); + } +} + +// ---- Reusable Widgets ---- + +class LabeledInput extends StatelessWidget { + final String label; + final String hint; + final TextEditingController controller; + final String? Function(String?) validator; + final bool isRequired; + final bool readOnly; // <-- Add this + + const LabeledInput({ + Key? key, + required this.label, + required this.hint, + required this.controller, + required this.validator, + this.isRequired = false, + this.readOnly = false, // default false + }) : super(key: key); + + @override + Widget build(BuildContext context) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + MyText.labelMedium(label), + if (isRequired) + const Text( + " *", + style: + TextStyle(color: Colors.red, fontWeight: FontWeight.bold), + ), + ], + ), + MySpacing.height(8), + TextFormField( + controller: controller, + validator: validator, + readOnly: readOnly, // <-- Use the new property here + decoration: _inputDecoration(context, hint), + ), + ], + ); + + InputDecoration _inputDecoration(BuildContext context, String hint) => + InputDecoration( + hintText: hint, + hintStyle: MyTextStyle.bodySmall(xMuted: true), + filled: true, + fillColor: Colors.grey.shade100, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + focusedBorder: const OutlineInputBorder( + borderRadius: BorderRadius.all(Radius.circular(12)), + borderSide: BorderSide(color: Colors.blueAccent, width: 1.5), + ), + contentPadding: MySpacing.all(16), + ); +} + +class LabeledDropdown extends StatefulWidget { + final String label; + final String hint; + final String? value; + final List items; + final ValueChanged onChanged; + final bool isRequired; + + const LabeledDropdown({ + Key? key, + required this.label, + required this.hint, + required this.value, + required this.items, + required this.onChanged, + this.isRequired = false, + }) : super(key: key); + + @override + State createState() => _LabeledDropdownState(); +} + +class _LabeledDropdownState extends State { + final GlobalKey _dropdownKey = GlobalKey(); + + @override + Widget build(BuildContext context) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + MyText.labelMedium(widget.label), + if (widget.isRequired) + const Text( + " *", + style: + TextStyle(color: Colors.red, fontWeight: FontWeight.bold), + ), + ], + ), + MySpacing.height(8), + GestureDetector( + key: _dropdownKey, + onTap: () async { + final RenderBox renderBox = + _dropdownKey.currentContext!.findRenderObject() as RenderBox; + final Offset offset = renderBox.localToGlobal(Offset.zero); + final Size size = renderBox.size; + final RelativeRect position = RelativeRect.fromLTRB( + offset.dx, + offset.dy + size.height, + offset.dx + size.width, + offset.dy, + ); + final selected = await showMenu( + context: context, + position: position, + items: widget.items + .map((item) => PopupMenuItem( + value: item, + child: Text(item), + )) + .toList(), + ); + if (selected != null) widget.onChanged(selected); + }, + child: AbsorbPointer( + child: TextFormField( + readOnly: true, + controller: TextEditingController(text: widget.value ?? ""), + validator: (value) => + widget.isRequired && (value == null || value.isEmpty) + ? "Required" + : null, + decoration: _inputDecoration(context, widget.hint).copyWith( + suffixIcon: const Icon(Icons.expand_more), + ), + ), + ), + ), + ], + ); + + InputDecoration _inputDecoration(BuildContext context, String hint) => + InputDecoration( + hintText: hint, + hintStyle: MyTextStyle.bodySmall(xMuted: true), + filled: true, + fillColor: Colors.grey.shade100, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + focusedBorder: const OutlineInputBorder( + borderRadius: BorderRadius.all(Radius.circular(12)), + borderSide: BorderSide(color: Colors.blueAccent, width: 1.5), + ), + contentPadding: MySpacing.all(16), + ); +} + +class FilePickerTile extends StatelessWidget { + final String? pickedFile; + final VoidCallback onTap; + final bool isRequired; + + const FilePickerTile({ + Key? key, + required this.pickedFile, + required this.onTap, + this.isRequired = false, + }) : super(key: key); + + @override + Widget build(BuildContext context) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + MyText.labelMedium("Attachments"), + if (isRequired) + const Text( + " *", + style: + TextStyle(color: Colors.red, fontWeight: FontWeight.bold), + ), + ], + ), + MySpacing.height(8), + GestureDetector( + onTap: onTap, + child: Container( + padding: MySpacing.all(16), + decoration: BoxDecoration( + color: Colors.grey.shade100, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.grey.shade300), + ), + child: Row( + children: [ + const Icon(Icons.upload_file, color: Colors.blueAccent), + const SizedBox(width: 12), + Text(pickedFile ?? "Choose File"), + ], + ), + ), + ), + ], + ); +} diff --git a/lib/view/document/document_details_page.dart b/lib/view/document/document_details_page.dart index 8775c34..d7cea7b 100644 --- a/lib/view/document/document_details_page.dart +++ b/lib/view/document/document_details_page.dart @@ -10,6 +10,9 @@ import 'package:marco/model/document/document_details_model.dart'; import 'package:marco/controller/document/document_details_controller.dart'; import 'package:marco/helpers/widgets/custom_app_bar.dart'; import 'package:marco/helpers/widgets/my_snackbar.dart'; +import 'package:marco/model/document/document_edit_bottom_sheet.dart'; +import 'package:marco/controller/permission_controller.dart'; +import 'package:marco/helpers/utils/permission_constants.dart'; class DocumentDetailsPage extends StatefulWidget { final String documentId; @@ -23,7 +26,8 @@ class DocumentDetailsPage extends StatefulWidget { class _DocumentDetailsPageState extends State { final DocumentDetailsController controller = Get.put(DocumentDetailsController()); - + final PermissionController permissionController = + Get.find(); @override void initState() { super.initState(); @@ -72,8 +76,7 @@ class _DocumentDetailsPageState extends State { return MyRefreshIndicator( onRefresh: _onRefresh, child: SingleChildScrollView( - physics: - const AlwaysScrollableScrollPhysics(), + physics: const AlwaysScrollableScrollPhysics(), padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -148,12 +151,58 @@ class _DocumentDetailsPageState extends State { ], ), ), - IconButton( - icon: const Icon(Icons.edit, color: Colors.red), - onPressed: () { - - }, - ), + if (permissionController + .hasPermission(Permissions.modifyDocument)) + IconButton( + icon: const Icon(Icons.edit, color: Colors.red), + onPressed: () async { + // existing bottom sheet flow + await controller + .fetchDocumentVersions(doc.parentAttachmentId); + + final latestVersion = controller.versions.isNotEmpty + ? controller.versions.reduce((a, b) => + a.uploadedAt.isAfter(b.uploadedAt) ? a : b) + : null; + + final documentData = { + "id": doc.id, + "documentId": doc.documentId, + "name": doc.name, + "description": doc.description, + "tags": doc.tags + .map((t) => {"name": t.name, "isActive": t.isActive}) + .toList(), + "category": doc.documentType.documentCategory?.toJson(), + "type": doc.documentType.toJson(), + "attachment": latestVersion != null + ? { + "id": latestVersion.id, + "fileName": latestVersion.name, + "contentType": latestVersion.contentType, + "fileSize": latestVersion.fileSize, + } + : null, + }; + + showModalBottomSheet( + context: context, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: + BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (_) { + return DocumentEditBottomSheet( + documentData: documentData, + onSubmit: (updatedData) async { + await _fetchDetails(); + }, + ); + }, + ); + }, + ) ], ), MySpacing.height(12), @@ -220,21 +269,25 @@ class _DocumentDetailsPageState extends State { "Uploaded by ${version.uploadedBy.firstName} ${version.uploadedBy.lastName} • $uploadDate", color: Colors.grey.shade600, ), - trailing: IconButton( - icon: const Icon(Icons.open_in_new, color: Colors.blue), - onPressed: () async { - final url = await controller.fetchPresignedUrl(version.id); - if (url != null) { - _openDocument(url); - } else { - showAppSnackbar( - title: "Error", - message: "Failed to fetch document link", - type: SnackbarType.error, - ); - } - }, - ), + trailing: + permissionController.hasPermission(Permissions.viewDocument) + ? IconButton( + icon: const Icon(Icons.open_in_new, color: Colors.blue), + onPressed: () async { + final url = + await controller.fetchPresignedUrl(version.id); + if (url != null) { + _openDocument(url); + } else { + showAppSnackbar( + title: "Error", + message: "Failed to fetch document link", + type: SnackbarType.error, + ); + } + }, + ) + : null, ); }, ); diff --git a/lib/view/document/user_document_screen.dart b/lib/view/document/user_document_screen.dart index 2b87ecf..5a56e6e 100644 --- a/lib/view/document/user_document_screen.dart +++ b/lib/view/document/user_document_screen.dart @@ -16,6 +16,7 @@ import 'package:marco/view/document/document_details_page.dart'; import 'package:marco/helpers/widgets/custom_app_bar.dart'; import 'package:marco/helpers/widgets/my_confirmation_dialog.dart'; import 'package:marco/helpers/widgets/my_snackbar.dart'; +import 'package:marco/controller/permission_controller.dart'; class UserDocumentsPage extends StatefulWidget { final String? entityId; @@ -33,6 +34,8 @@ class UserDocumentsPage extends StatefulWidget { class _UserDocumentsPageState extends State { final DocumentController docController = Get.put(DocumentController()); + final PermissionController permissionController = + Get.find(); String get entityTypeId => widget.isEmployee ? Permissions.employeeEntity @@ -143,6 +146,7 @@ class _UserDocumentsPageState extends State { icon: const Icon(Icons.more_vert, color: Colors.black54), onSelected: (value) async { if (value == "delete") { + // existing delete flow (unchanged) final result = await showDialog( context: context, builder: (_) => ConfirmDialog( @@ -184,6 +188,7 @@ class _UserDocumentsPageState extends State { debugPrint("✅ Document deleted and removed from list"); } } else if (value == "activate") { + // existing activate flow (unchanged) final success = await docController.toggleDocumentActive( doc.id, isActive: true, @@ -207,12 +212,16 @@ class _UserDocumentsPageState extends State { } }, itemBuilder: (context) => [ - if (doc.isActive) + if (doc.isActive && + permissionController + .hasPermission(Permissions.deleteDocument)) const PopupMenuItem( value: "delete", child: Text("Delete"), ) - else + else if (!doc.isActive && + permissionController + .hasPermission(Permissions.modifyDocument)) const PopupMenuItem( value: "activate", child: Text("Activate"), @@ -322,7 +331,7 @@ class _UserDocumentsPageState extends State { borderRadius: BorderRadius.circular(10), ), child: IconButton( - padding: EdgeInsets.zero, + padding: EdgeInsets.zero, constraints: BoxConstraints(), icon: Icon( Icons.tune, @@ -455,6 +464,29 @@ class _UserDocumentsPageState extends State { } Widget _buildBody(BuildContext context) { + // 🔒 Check for viewDocument permission + if (!permissionController.hasPermission(Permissions.viewDocument)) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.lock_outline, size: 60, color: Colors.grey), + MySpacing.height(18), + MyText.titleMedium( + 'Access Denied', + fontWeight: 600, + color: Colors.grey, + ), + MySpacing.height(10), + MyText.bodySmall( + 'You do not have permission to view documents.', + color: Colors.grey, + ), + ], + ), + ); + } + return Obx(() { if (docController.isLoading.value && docController.documents.isEmpty) { return SingleChildScrollView( @@ -468,10 +500,7 @@ class _UserDocumentsPageState extends State { child: Column( children: [ _buildFilterRow(context), - - // 👇 Add this _buildStatusHeader(), - Expanded( child: MyRefreshIndicator( onRefresh: () async { @@ -523,7 +552,6 @@ class _UserDocumentsPageState extends State { @override Widget build(BuildContext context) { - // Conditionally show AppBar (example: hide if employee view) final bool showAppBar = !widget.isEmployee; return Scaffold( @@ -537,47 +565,51 @@ class _UserDocumentsPageState extends State { ) : null, body: _buildBody(context), - floatingActionButton: FloatingActionButton.extended( - onPressed: () { - final uploadController = Get.put(DocumentUploadController()); + floatingActionButton: permissionController + .hasPermission(Permissions.uploadDocument) + ? FloatingActionButton.extended( + onPressed: () { + final uploadController = Get.put(DocumentUploadController()); - showModalBottomSheet( - context: context, - isScrollControlled: true, - backgroundColor: Colors.transparent, - builder: (_) => DocumentUploadBottomSheet( - onSubmit: (data) async { - final success = await uploadController.uploadDocument( - name: data["name"], - description: data["description"], - documentId: data["documentId"], - entityId: resolvedEntityId, - documentTypeId: data["documentTypeId"], - fileName: data["attachment"]["fileName"], - base64Data: data["attachment"]["base64Data"], - contentType: data["attachment"]["contentType"], - fileSize: data["attachment"]["fileSize"], + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (_) => DocumentUploadBottomSheet( + onSubmit: (data) async { + final success = await uploadController.uploadDocument( + name: data["name"], + description: data["description"], + documentId: data["documentId"], + entityId: resolvedEntityId, + documentTypeId: data["documentTypeId"], + fileName: data["attachment"]["fileName"], + base64Data: data["attachment"]["base64Data"], + contentType: data["attachment"]["contentType"], + fileSize: data["attachment"]["fileSize"], + ); + + if (success) { + Navigator.pop(context); + docController.fetchDocuments( + entityTypeId: entityTypeId, + entityId: resolvedEntityId, + reset: true, + ); + } else { + Get.snackbar( + "Error", "Upload failed, please try again"); + } + }, + ), ); - - if (success) { - Navigator.pop(context); - docController.fetchDocuments( - entityTypeId: entityTypeId, - entityId: resolvedEntityId, - reset: true, - ); - } else { - Get.snackbar("Error", "Upload failed, please try again"); - } }, - ), - ); - }, - icon: const Icon(Icons.add, color: Colors.white), - label: MyText.bodyMedium("Add Document", - color: Colors.white, fontWeight: 600), - backgroundColor: Colors.red, - ), + icon: const Icon(Icons.add, color: Colors.white), + label: MyText.bodyMedium("Add Document", + color: Colors.white, fontWeight: 600), + backgroundColor: Colors.red, + ) + : null, floatingActionButtonLocation: FloatingActionButtonLocation.endFloat, ); } From a02887845bfe1cd78dcb2877161b9071b1969a20 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Tue, 9 Sep 2025 10:47:22 +0530 Subject: [PATCH 07/16] feat: Improve dynamic menu fetching with enhanced error handling and cache fallback --- .../dynamicMenu/dynamic_menu_controller.dart | 62 ++++++++++--------- 1 file changed, 33 insertions(+), 29 deletions(-) diff --git a/lib/controller/dynamicMenu/dynamic_menu_controller.dart b/lib/controller/dynamicMenu/dynamic_menu_controller.dart index e7fb382..4af984c 100644 --- a/lib/controller/dynamicMenu/dynamic_menu_controller.dart +++ b/lib/controller/dynamicMenu/dynamic_menu_controller.dart @@ -13,19 +13,29 @@ class DynamicMenuController extends GetxController { final RxList menuItems = [].obs; Timer? _autoRefreshTimer; + @override void onInit() { super.onInit(); + + // ✅ Load cached menus immediately (so user doesn’t see empty state) + final cachedMenus = LocalStorage.getMenus(); + if (cachedMenus.isNotEmpty) { + menuItems.assignAll(cachedMenus); + logSafe("Loaded ${cachedMenus.length} menus from cache at startup"); + } + + // ✅ Fetch from API in background fetchMenu(); - /// Auto refresh every 5 minutes (adjust as needed) + // Auto refresh every 15 minutes _autoRefreshTimer = Timer.periodic( const Duration(minutes: 15), (_) => fetchMenu(), ); } - /// Fetch dynamic menu from API with error and local storage support + /// Fetch dynamic menu from API with cache fallback Future fetchMenu() async { isLoading.value = true; hasError.value = false; @@ -34,45 +44,39 @@ class DynamicMenuController extends GetxController { try { final responseData = await ApiService.getMenuApi(); if (responseData != null) { - // Directly parse full JSON into MenuResponse + // Parse JSON into MenuResponse final menuResponse = MenuResponse.fromJson(responseData); - menuItems.assignAll(menuResponse.data); - // Save menus for offline use + // Save for offline use await LocalStorage.setMenus(menuItems); - logSafe("Menu loaded from API with ${menuItems.length} items"); + logSafe("✅ Menu loaded from API with ${menuItems.length} items"); } else { - // If API fails, load from cache - final cachedMenus = LocalStorage.getMenus(); - if (cachedMenus.isNotEmpty) { - menuItems.assignAll(cachedMenus); - logSafe("Loaded menus from cache: ${menuItems.length} items"); - } else { - hasError.value = true; - errorMessage.value = "Failed to fetch menu"; - menuItems.clear(); - } + _handleApiFailure("Menu API returned null response"); } } catch (e) { - logSafe("Menu fetch exception: $e", level: LogLevel.error); - - // On error, load cached menus - final cachedMenus = LocalStorage.getMenus(); - if (cachedMenus.isNotEmpty) { - menuItems.assignAll(cachedMenus); - logSafe("Loaded menus from cache after error: ${menuItems.length}"); - } else { - hasError.value = true; - errorMessage.value = e.toString(); - menuItems.clear(); - } + _handleApiFailure("Menu fetch exception: $e"); } finally { isLoading.value = false; } } + void _handleApiFailure(String logMessage) { + logSafe(logMessage, level: LogLevel.error); + + final cachedMenus = LocalStorage.getMenus(); + if (cachedMenus.isNotEmpty) { + menuItems.assignAll(cachedMenus); + errorMessage.value = "⚠️ Using offline menus (latest sync failed)"; + logSafe("Loaded ${menuItems.length} menus from cache after failure"); + } else { + hasError.value = true; + errorMessage.value = "❌ Unable to load menus. Please try again later."; + menuItems.clear(); + } + } + bool isMenuAllowed(String menuName) { final menu = menuItems.firstWhereOrNull((m) => m.name == menuName); return menu?.available ?? false; // default false if not found @@ -80,7 +84,7 @@ class DynamicMenuController extends GetxController { @override void onClose() { - _autoRefreshTimer?.cancel(); // clean up timer + _autoRefreshTimer?.cancel(); super.onClose(); } } From 6d70afc779256499335f01de36c3e76dd67caa46 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Thu, 11 Sep 2025 15:45:32 +0530 Subject: [PATCH 08/16] feat: Add document verification and rejection functionality with remote logging support --- lib/controller/auth/login_controller.dart | 3 + .../document/document_details_controller.dart | 20 +++ lib/helpers/services/api_endpoints.dart | 7 +- lib/helpers/services/api_service.dart | 83 ++++++++++++ lib/helpers/services/app_logger.dart | 128 ++++++++++++------ lib/main.dart | 8 +- lib/view/document/document_details_page.dart | 75 ++++++++++ 7 files changed, 279 insertions(+), 45 deletions(-) diff --git a/lib/controller/auth/login_controller.dart b/lib/controller/auth/login_controller.dart index 30418bc..0ae276f 100644 --- a/lib/controller/auth/login_controller.dart +++ b/lib/controller/auth/login_controller.dart @@ -75,6 +75,9 @@ class LoginController extends MyController { basicValidator.clearErrors(); } else { await _handleRememberMe(); + // ✅ Enable remote logging after successful login + enableRemoteLogging(); + logSafe("✅ Remote logging enabled after login."); // ✅ Commented out FCM token registration after login /* diff --git a/lib/controller/document/document_details_controller.dart b/lib/controller/document/document_details_controller.dart index 6abd95f..83f0564 100644 --- a/lib/controller/document/document_details_controller.dart +++ b/lib/controller/document/document_details_controller.dart @@ -45,6 +45,26 @@ class DocumentDetailsController extends GetxController { } } + /// Verify document + Future verifyDocument(String documentId) async { + final result = + await ApiService.verifyDocumentApi(id: documentId, isVerify: true); + if (result) { + await fetchDocumentDetails(documentId); // refresh details + } + return result; + } + + /// Reject document + Future rejectDocument(String documentId) async { + final result = + await ApiService.verifyDocumentApi(id: documentId, isVerify: false); + if (result) { + await fetchDocumentDetails(documentId); // refresh details + } + return result; + } + /// Fetch Pre-Signed URL for a given version Future fetchPresignedUrl(String versionId) async { return await ApiService.getPresignedUrlApi(versionId); diff --git a/lib/helpers/services/api_endpoints.dart b/lib/helpers/services/api_endpoints.dart index 2f2a32f..eee0216 100644 --- a/lib/helpers/services/api_endpoints.dart +++ b/lib/helpers/services/api_endpoints.dart @@ -1,7 +1,8 @@ class ApiEndpoints { static const String baseUrl = "https://stageapi.marcoaiot.com/api"; // static const String baseUrl = "https://api.marcoaiot.com/api"; -// + // static const String baseUrl = "https://devapi.marcoaiot.com/api"; + // Dashboard Module API Endpoints static const String getDashboardAttendanceOverview = "/dashboard/attendance-overview"; @@ -85,4 +86,8 @@ class ApiEndpoints { static const String getDocumentVersion = "/document/get/version"; static const String getDocumentVersions = "/document/list/versions"; static const String editDocument = "/document/edit"; + static const String verifyDocument = "/document/verify"; + + /// Logs Module API Endpoints + static const String uploadLogs = "/log"; } diff --git a/lib/helpers/services/api_service.dart b/lib/helpers/services/api_service.dart index 5d1a1e8..4b97336 100644 --- a/lib/helpers/services/api_service.dart +++ b/lib/helpers/services/api_service.dart @@ -247,6 +247,89 @@ class ApiService { } } + static Future postLogsApi(List> logs) async { + const endpoint = "${ApiEndpoints.uploadLogs}"; + logSafe("Posting logs... count=${logs.length}"); + + try { + final response = + await _postRequest(endpoint, logs, customTimeout: extendedTimeout); + + if (response == null) { + logSafe("Post logs failed: null response", level: LogLevel.error); + return false; + } + + logSafe("Post logs response status: ${response.statusCode}"); + logSafe("Post logs response body: ${response.body}"); + + if (response.statusCode == 200 && response.body.isNotEmpty) { + final json = jsonDecode(response.body); + if (json['success'] == true) { + logSafe("Logs posted successfully."); + return true; + } + } + + logSafe("Failed to post logs: ${response.body}", level: LogLevel.warning); + } catch (e, stack) { + logSafe("Exception during postLogsApi: $e", level: LogLevel.error); + logSafe("StackTrace: $stack", level: LogLevel.debug); + } + + return false; + } + + /// Verify Document API + static Future verifyDocumentApi({ + required String id, + bool isVerify = true, + }) async { + final endpoint = "${ApiEndpoints.verifyDocument}/$id"; + final queryParams = {"isVerify": isVerify.toString()}; + logSafe("Verifying document with id: $id | isVerify: $isVerify"); + + try { + final uri = Uri.parse("${ApiEndpoints.baseUrl}$endpoint") + .replace(queryParameters: queryParams); + + String? token = await _getToken(); + if (token == null) return false; + + final headers = _headers(token); + logSafe("POST (verify) $uri\nHeaders: $headers"); + + final response = + await http.post(uri, headers: headers).timeout(extendedTimeout); + + if (response.statusCode == 401) { + logSafe("Unauthorized VERIFY. Attempting token refresh..."); + if (await AuthService.refreshToken()) { + return await verifyDocumentApi(id: id, isVerify: isVerify); + } + } + + logSafe("Verify document response status: ${response.statusCode}"); + logSafe("Verify document response body: ${response.body}"); + + final json = jsonDecode(response.body); + if (json['success'] == true) { + logSafe("Document verify success: ${json['data']}"); + return true; + } else { + logSafe( + "Failed to verify document: ${json['message'] ?? 'Unknown error'}", + level: LogLevel.warning, + ); + } + } catch (e, stack) { + logSafe("Exception during verifyDocumentApi: $e", level: LogLevel.error); + logSafe("StackTrace: $stack", level: LogLevel.debug); + } + + return false; + } + /// Get Pre-Signed URL for Old Version static Future getPresignedUrlApi(String versionId) async { final endpoint = "${ApiEndpoints.getDocumentVersion}/$versionId"; diff --git a/lib/helpers/services/app_logger.dart b/lib/helpers/services/app_logger.dart index 9047066..9e855e9 100644 --- a/lib/helpers/services/app_logger.dart +++ b/lib/helpers/services/app_logger.dart @@ -2,16 +2,41 @@ import 'dart:io'; import 'package:logger/logger.dart'; import 'package:intl/intl.dart'; import 'package:path_provider/path_provider.dart'; +import 'package:marco/helpers/services/api_service.dart'; /// Global logger instance -late final Logger appLogger; -late final FileLogOutput fileLogOutput; +Logger? _appLogger; +late final FileLogOutput _fileLogOutput; + +/// Store logs temporarily for API posting +final List> _logBuffer = []; + +/// Lock flag to prevent concurrent posting +bool _isPosting = false; + +/// Flag to allow API posting only after login +bool _canPostLogs = false; + +/// Maximum number of logs before triggering API post +const int _maxLogsBeforePost = 50; + +/// Maximum logs in memory buffer +const int _maxBufferSize = 50; + +/// Enum → logger level mapping +const _levelMap = { + LogLevel.debug: Level.debug, + LogLevel.info: Level.info, + LogLevel.warning: Level.warning, + LogLevel.error: Level.error, + LogLevel.verbose: Level.verbose, +}; /// Initialize logging Future initLogging() async { - fileLogOutput = FileLogOutput(); + _fileLogOutput = FileLogOutput(); - appLogger = Logger( + _appLogger = Logger( printer: PrettyPrinter( methodCount: 0, printTime: true, @@ -20,12 +45,18 @@ Future initLogging() async { ), output: MultiOutput([ ConsoleOutput(), - fileLogOutput, + _fileLogOutput, ]), level: Level.debug, ); } +/// Enable API posting after login +void enableRemoteLogging() { + _canPostLogs = true; + _postBufferedLogs(); // flush logs if any +} + /// Safe logger wrapper void logSafe( String message, { @@ -34,27 +65,60 @@ void logSafe( StackTrace? stackTrace, bool sensitive = false, }) { - if (sensitive) return; + if (sensitive || _appLogger == null) return; - switch (level) { - case LogLevel.debug: - appLogger.d(message, error: error, stackTrace: stackTrace); - break; - case LogLevel.warning: - appLogger.w(message, error: error, stackTrace: stackTrace); - break; - case LogLevel.error: - appLogger.e(message, error: error, stackTrace: stackTrace); - break; - case LogLevel.verbose: - appLogger.v(message, error: error, stackTrace: stackTrace); - break; - default: - appLogger.i(message, error: error, stackTrace: stackTrace); + final loggerLevel = _levelMap[level] ?? Level.info; + _appLogger!.log(loggerLevel, message, error: error, stackTrace: stackTrace); + + // Buffer logs for API posting + _logBuffer.add({ + "logLevel": level.name, + "message": message, + "timeStamp": DateTime.now().toUtc().toIso8601String(), + "ipAddress": "this is test IP", // TODO: real IP + "userAgent": "FlutterApp/1.0", // TODO: device_info_plus + "details": error?.toString() ?? stackTrace?.toString(), + }); + + if (_logBuffer.length >= _maxLogsBeforePost) { + _postBufferedLogs(); } } -/// Log output to file (safe path, no permission required) +/// Post buffered logs to API +Future _postBufferedLogs() async { + if (!_canPostLogs) return; // 🚫 skip if not logged in + if (_isPosting || _logBuffer.isEmpty) return; + + _isPosting = true; + final logsToSend = List>.from(_logBuffer); + _logBuffer.clear(); + + try { + final success = await ApiService.postLogsApi(logsToSend); + if (!success) { + _reinsertLogs(logsToSend, reason: "API call returned false"); + } + } catch (e) { + _reinsertLogs(logsToSend, reason: "API exception: $e"); + } finally { + _isPosting = false; + } +} + +/// Reinsert logs into buffer if posting fails +void _reinsertLogs(List> logs, {required String reason}) { + _appLogger?.w("Failed to post logs, re-queuing. Reason: $reason"); + + if (_logBuffer.length + logs.length > _maxBufferSize) { + _appLogger?.e("Buffer full. Dropping ${logs.length} logs to prevent crash."); + return; + } + + _logBuffer.insertAll(0, logs); +} + +/// File-based log output (safe storage) class FileLogOutput extends LogOutput { File? _logFile; @@ -81,7 +145,6 @@ class FileLogOutput extends LogOutput { @override void output(OutputEvent event) async { await _init(); - if (event.lines.isEmpty) return; final logMessage = event.lines.join('\n') + '\n'; @@ -122,22 +185,5 @@ class FileLogOutput extends LogOutput { } } -/// Simple log printer for file output -class SimpleFileLogPrinter extends LogPrinter { - @override - List log(LogEvent event) { - final message = event.message.toString(); - - if (message.contains('[SENSITIVE]')) return []; - - final timestamp = DateFormat('yyyy-MM-dd HH:mm:ss').format(DateTime.now()); - final level = event.level.name.toUpperCase(); - final error = event.error != null ? ' | ERROR: ${event.error}' : ''; - final stack = - event.stackTrace != null ? '\nSTACKTRACE:\n${event.stackTrace}' : ''; - return ['[$timestamp] [$level] $message$error$stack']; - } -} - -/// Optional enum for log levels +/// Custom log levels enum LogLevel { debug, info, warning, error, verbose } diff --git a/lib/main.dart b/lib/main.dart index 8b8bb2e..fb56445 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -13,7 +13,7 @@ Future main() async { await initLogging(); logSafe("App starting..."); - + enableRemoteLogging(); try { await initializeApp(); logSafe("App initialized successfully."); @@ -73,9 +73,11 @@ class _MainWrapperState extends State { @override Widget build(BuildContext context) { - final bool isOffline = _connectivityStatus.contains(ConnectivityResult.none); + final bool isOffline = + _connectivityStatus.contains(ConnectivityResult.none); return isOffline - ? const MaterialApp(debugShowCheckedModeBanner: false, home: OfflineScreen()) + ? const MaterialApp( + debugShowCheckedModeBanner: false, home: OfflineScreen()) : const MyApp(); } } diff --git a/lib/view/document/document_details_page.dart b/lib/view/document/document_details_page.dart index d7cea7b..695f590 100644 --- a/lib/view/document/document_details_page.dart +++ b/lib/view/document/document_details_page.dart @@ -229,6 +229,81 @@ class _DocumentDetailsPageState extends State { _buildDetailRow("Uploaded On", uploadDate), if (doc.updatedAt != null) _buildDetailRow("Last Updated On", updateDate), + MySpacing.height(12), + if (permissionController + .hasPermission(Permissions.verifyDocument)) ...[ + Row( + children: [ + Expanded( + child: ElevatedButton.icon( + icon: const Icon(Icons.check, color: Colors.white), + label: MyText.bodyMedium( + "Verify", + color: Colors.white, + fontWeight: 600, + ), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.green, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + padding: const EdgeInsets.symmetric(vertical: 8), + ), + onPressed: () async { + final success = await controller.verifyDocument(doc.id); + if (success) { + showAppSnackbar( + title: "Success", + message: "Document verified successfully", + type: SnackbarType.success, + ); + } else { + showAppSnackbar( + title: "Error", + message: "Failed to verify document", + type: SnackbarType.error, + ); + } + }, + ), + ), + const SizedBox(width: 12), + Expanded( + child: ElevatedButton.icon( + icon: const Icon(Icons.close, color: Colors.white), + label: MyText.bodyMedium( + "Reject", + color: Colors.white, + fontWeight: 600, + ), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + padding: const EdgeInsets.symmetric(vertical: 8), + ), + onPressed: () async { + final success = await controller.rejectDocument(doc.id); + if (success) { + showAppSnackbar( + title: "Rejected", + message: "Document rejected successfully", + type: SnackbarType.warning, + ); + } else { + showAppSnackbar( + title: "Error", + message: "Failed to reject document", + type: SnackbarType.error, + ); + } + }, + ), + ), + ], + ), + ], ], ), ); From 0cccdc6b05f7bbb245694e1c62f6e718b107a3f0 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Thu, 11 Sep 2025 17:06:26 +0530 Subject: [PATCH 09/16] feat: Add joining date functionality and enhance document upload validation --- .../employee/add_employee_controller.dart | 8 + lib/helpers/services/api_service.dart | 2 + .../document_upload_bottom_sheet.dart | 90 +++++++++- .../employees/add_employee_bottom_sheet.dart | 161 ++++++++++++++++-- lib/view/document/user_document_screen.dart | 9 +- 5 files changed, 244 insertions(+), 26 deletions(-) diff --git a/lib/controller/employee/add_employee_controller.dart b/lib/controller/employee/add_employee_controller.dart index 68e3b58..b23e842 100644 --- a/lib/controller/employee/add_employee_controller.dart +++ b/lib/controller/employee/add_employee_controller.dart @@ -25,6 +25,7 @@ class AddEmployeeController extends MyController { String selectedCountryCode = "+91"; bool showOnline = true; final List categories = []; + DateTime? joiningDate; @override void onInit() { @@ -34,6 +35,12 @@ class AddEmployeeController extends MyController { fetchRoles(); } + void setJoiningDate(DateTime date) { + joiningDate = date; + logSafe("Joining date selected: $date"); + update(); + } + void _initializeFields() { basicValidator.addField( 'first_name', @@ -109,6 +116,7 @@ class AddEmployeeController extends MyController { phoneNumber: phoneNumber!, gender: selectedGender!.name, jobRoleId: selectedRoleId!, + joiningDate: joiningDate?.toIso8601String() ?? "", ); logSafe("Response: $response"); diff --git a/lib/helpers/services/api_service.dart b/lib/helpers/services/api_service.dart index 4b97336..d0f12e2 100644 --- a/lib/helpers/services/api_service.dart +++ b/lib/helpers/services/api_service.dart @@ -1885,6 +1885,7 @@ class ApiService { required String phoneNumber, required String gender, required String jobRoleId, + required String joiningDate, }) async { final body = { "firstName": firstName, @@ -1892,6 +1893,7 @@ class ApiService { "phoneNumber": phoneNumber, "gender": gender, "jobRoleId": jobRoleId, + "joiningDate": joiningDate }; final response = await _postRequest( diff --git a/lib/model/document/document_upload_bottom_sheet.dart b/lib/model/document/document_upload_bottom_sheet.dart index 7876abb..a4a4f87 100644 --- a/lib/model/document/document_upload_bottom_sheet.dart +++ b/lib/model/document/document_upload_bottom_sheet.dart @@ -13,10 +13,11 @@ import 'package:marco/helpers/widgets/my_snackbar.dart'; class DocumentUploadBottomSheet extends StatefulWidget { final Function(Map) onSubmit; - + final bool isEmployee; const DocumentUploadBottomSheet({ Key? key, required this.onSubmit, + this.isEmployee = false, }) : super(key: key); @override @@ -43,8 +44,31 @@ class _DocumentUploadBottomSheetState extends State { } void _handleSubmit() { - if (!(_formKey.currentState?.validate() ?? false)) return; + final formState = _formKey.currentState; + // 1️⃣ Validate form fields + if (!(formState?.validate() ?? false)) { + // Collect first validation error + final errorFields = [ + {"label": "Document ID", "value": _docIdController.text.trim()}, + {"label": "Document Name", "value": _docNameController.text.trim()}, + {"label": "Description", "value": _descriptionController.text.trim()}, + ]; + + for (var field in errorFields) { + if (field["value"] == null || (field["value"] as String).isEmpty) { + showAppSnackbar( + title: "Error", + message: "${field["label"]} is required", + type: SnackbarType.error, + ); + return; + } + } + return; + } + + // 2️⃣ Validate file attachment if (selectedFile == null) { showAppSnackbar( title: "Error", @@ -54,7 +78,38 @@ class _DocumentUploadBottomSheetState extends State { return; } - // ✅ Validate file size + // 3️⃣ Validate document category based on employee/project + if (controller.selectedCategory != null) { + final selectedCategoryName = controller.selectedCategory!.name; + + if (widget.isEmployee && selectedCategoryName != 'Employee Documents') { + showAppSnackbar( + title: "Error", + message: + "Only 'Employee Documents' can be uploaded from the Employee screen. Please select the correct document type.", + type: SnackbarType.error, + ); + return; + } else if (!widget.isEmployee && + selectedCategoryName != 'Project Documents') { + showAppSnackbar( + title: "Error", + message: + "Only 'Project Documents' can be uploaded from the Project screen. Please select the correct document type.", + type: SnackbarType.error, + ); + return; + } + } else { + showAppSnackbar( + title: "Error", + message: "Please select a Document Category before uploading.", + type: SnackbarType.error, + ); + return; + } + + // 4️⃣ Validate file size final maxSizeMB = controller.selectedType?.maxSizeAllowedInMB; if (maxSizeMB != null && controller.selectedFileSize != null) { final fileSizeMB = controller.selectedFileSize! / (1024 * 1024); @@ -68,7 +123,7 @@ class _DocumentUploadBottomSheetState extends State { } } - // ✅ Validate file type + // 5️⃣ Validate file type final allowedType = controller.selectedType?.allowedContentType; if (allowedType != null && controller.selectedFileContentType != null) { if (!allowedType @@ -83,6 +138,7 @@ class _DocumentUploadBottomSheetState extends State { } } + // 6️⃣ Prepare payload final payload = { "documentId": _docIdController.text.trim(), "name": _docNameController.text.trim(), @@ -100,7 +156,10 @@ class _DocumentUploadBottomSheetState extends State { .toList(), }; + // 7️⃣ Submit widget.onSubmit(payload); + + // 8️⃣ Show success message showAppSnackbar( title: "Success", message: "Document submitted successfully", @@ -152,10 +211,28 @@ class _DocumentUploadBottomSheetState extends State { label: "Document ID", hint: "Enter Document ID", controller: _docIdController, - validator: (value) => - value == null || value.trim().isEmpty ? "Required" : null, + validator: (value) { + if (value == null || value.trim().isEmpty) { + return "Required"; + } + + // ✅ Regex validation if enabled + final selectedType = controller.selectedType; + if (selectedType != null && + selectedType.isValidationRequired && + selectedType.regexExpression != null && + selectedType.regexExpression!.isNotEmpty) { + final regExp = RegExp(selectedType.regexExpression!); + if (!regExp.hasMatch(value.trim())) { + return "Invalid ${selectedType.name} format"; + } + } + + return null; + }, isRequired: true, ), + MySpacing.height(16), /// Document Name @@ -479,7 +556,6 @@ class AttachmentSectionSingle extends StatelessWidget { } } - // ---- Reusable Widgets ---- class LabeledInput extends StatelessWidget { diff --git a/lib/model/employees/add_employee_bottom_sheet.dart b/lib/model/employees/add_employee_bottom_sheet.dart index 993bb3d..2a30f14 100644 --- a/lib/model/employees/add_employee_bottom_sheet.dart +++ b/lib/model/employees/add_employee_bottom_sheet.dart @@ -8,6 +8,9 @@ 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/utils/base_bottom_sheet.dart'; +import 'package:intl/intl.dart'; +import 'package:marco/helpers/widgets/my_snackbar.dart'; + class AddEmployeeBottomSheet extends StatefulWidget { @override @@ -54,6 +57,18 @@ class _AddEmployeeBottomSheetState extends State _controller.basicValidator.getValidation('last_name'), ), MySpacing.height(16), + _sectionLabel("Joining Details"), + MySpacing.height(16), + _buildDatePickerField( + label: "Joining Date", + value: _controller.joiningDate != null + ? DateFormat("dd MMM yyyy") + .format(_controller.joiningDate!) + : "", + hint: "Select Joining Date", + onTap: () => _pickJoiningDate(context), + ), + MySpacing.height(16), _sectionLabel("Contact Details"), MySpacing.height(16), _buildPhoneInput(context), @@ -83,12 +98,113 @@ class _AddEmployeeBottomSheetState extends State ); } - // Submit logic + // --- Common label with red star --- + Widget _requiredLabel(String text) { + return Row( + children: [ + MyText.labelMedium(text), + const SizedBox(width: 4), + const Text("*", style: TextStyle(color: Colors.red)), + ], + ); + } + + // --- Date Picker field --- + Widget _buildDatePickerField({ + required String label, + required String value, + required String hint, + required VoidCallback onTap, + }) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _requiredLabel(label), + MySpacing.height(8), + GestureDetector( + onTap: onTap, + child: AbsorbPointer( + child: TextFormField( + readOnly: true, + controller: TextEditingController(text: value), + validator: (val) { + if (val == null || val.trim().isEmpty) { + return "$label is required"; + } + return null; + }, + decoration: _inputDecoration(hint).copyWith( + suffixIcon: const Icon(Icons.calendar_today), + ), + ), + ), + ), + ], + ); + } + + Future _pickJoiningDate(BuildContext context) async { + final picked = await showDatePicker( + context: context, + initialDate: DateTime.now(), + firstDate: DateTime(2000), + lastDate: DateTime(2100), + ); + + if (picked != null) { + _controller.setJoiningDate(picked); + _controller.update(); + } + } + + // --- Submit logic --- Future _handleSubmit() async { + // Run form validation first + final isValid = + _controller.basicValidator.formKey.currentState?.validate() ?? false; + + if (!isValid) { + showAppSnackbar( + title: "Missing Fields", + message: "Please fill all required fields before submitting.", + type: SnackbarType.warning, + ); + return; + } + + // Additional check for dropdowns & joining date + if (_controller.joiningDate == null) { + showAppSnackbar( + title: "Missing Fields", + message: "Please select Joining Date.", + type: SnackbarType.warning, + ); + return; + } + + if (_controller.selectedGender == null) { + showAppSnackbar( + title: "Missing Fields", + message: "Please select Gender.", + type: SnackbarType.warning, + ); + return; + } + + if (_controller.selectedRoleId == null) { + showAppSnackbar( + title: "Missing Fields", + message: "Please select Role.", + type: SnackbarType.warning, + ); + return; + } + + // All validations passed → Call API final result = await _controller.createEmployees(); if (result != null && result['success'] == true) { - final employeeData = result['data']; // ✅ Safe now + final employeeData = result['data']; final employeeController = Get.find(); final projectId = employeeController.selectedProjectId; @@ -100,18 +216,20 @@ class _AddEmployeeBottomSheetState extends State employeeController.update(['employee_screen_controller']); + // Reset form _controller.basicValidator.getController("first_name")?.clear(); _controller.basicValidator.getController("last_name")?.clear(); _controller.basicValidator.getController("phone_number")?.clear(); _controller.selectedGender = null; _controller.selectedRoleId = null; + _controller.joiningDate = null; _controller.update(); Navigator.pop(context, employeeData); } } - // Section label widget + // --- Section label widget --- Widget _sectionLabel(String title) => Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -121,7 +239,7 @@ class _AddEmployeeBottomSheetState extends State ], ); - // Input field with icon + // --- Input field with icon --- Widget _inputWithIcon({ required String label, required String hint, @@ -132,11 +250,16 @@ class _AddEmployeeBottomSheetState extends State return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - MyText.labelMedium(label), + _requiredLabel(label), MySpacing.height(8), TextFormField( controller: controller, - validator: validator, + validator: (val) { + if (val == null || val.trim().isEmpty) { + return "$label is required"; + } + return validator?.call(val); + }, decoration: _inputDecoration(hint).copyWith( prefixIcon: Icon(icon, size: 20), ), @@ -145,12 +268,12 @@ class _AddEmployeeBottomSheetState extends State ); } - // Phone input with country code selector + // --- Phone input --- Widget _buildPhoneInput(BuildContext context) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - MyText.labelMedium("Phone Number"), + _requiredLabel("Phone Number"), MySpacing.height(8), Row( children: [ @@ -161,7 +284,7 @@ class _AddEmployeeBottomSheetState extends State borderRadius: BorderRadius.circular(12), color: Colors.grey.shade100, ), - child: Text("+91"), + child: const Text("+91"), ), MySpacing.width(12), Expanded( @@ -170,13 +293,11 @@ class _AddEmployeeBottomSheetState extends State _controller.basicValidator.getController('phone_number'), validator: (value) { if (value == null || value.trim().isEmpty) { - return "Phone number is required"; + return "Phone Number is required"; } - if (!RegExp(r'^\d{10}$').hasMatch(value.trim())) { return "Enter a valid 10-digit number"; } - return null; }, keyboardType: TextInputType.phone, @@ -198,7 +319,7 @@ class _AddEmployeeBottomSheetState extends State ); } - // Gender/Role field (read-only dropdown) + // --- Dropdown (Gender/Role) --- Widget _buildDropdownField({ required String label, required String value, @@ -208,7 +329,7 @@ class _AddEmployeeBottomSheetState extends State return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - MyText.labelMedium(label), + _requiredLabel(label), MySpacing.height(8), GestureDetector( onTap: onTap, @@ -216,6 +337,12 @@ class _AddEmployeeBottomSheetState extends State child: TextFormField( readOnly: true, controller: TextEditingController(text: value), + validator: (val) { + if (val == null || val.trim().isEmpty) { + return "$label is required"; + } + return null; + }, decoration: _inputDecoration(hint).copyWith( suffixIcon: const Icon(Icons.expand_more), ), @@ -226,7 +353,7 @@ class _AddEmployeeBottomSheetState extends State ); } - // Common input decoration + // --- Common input decoration --- InputDecoration _inputDecoration(String hint) { return InputDecoration( hintText: hint, @@ -249,7 +376,7 @@ class _AddEmployeeBottomSheetState extends State ); } - // Gender popup menu + // --- Gender popup --- void _showGenderPopup(BuildContext context) async { final selected = await showMenu( context: context, @@ -268,7 +395,7 @@ class _AddEmployeeBottomSheetState extends State } } - // Role popup menu + // --- Role popup --- void _showRolePopup(BuildContext context) async { final selected = await showMenu( context: context, diff --git a/lib/view/document/user_document_screen.dart b/lib/view/document/user_document_screen.dart index 5a56e6e..a33651e 100644 --- a/lib/view/document/user_document_screen.dart +++ b/lib/view/document/user_document_screen.dart @@ -576,6 +576,8 @@ class _UserDocumentsPageState extends State { isScrollControlled: true, backgroundColor: Colors.transparent, builder: (_) => DocumentUploadBottomSheet( + isEmployee: + widget.isEmployee, // 👈 Pass the employee flag here onSubmit: (data) async { final success = await uploadController.uploadDocument( name: data["name"], @@ -605,8 +607,11 @@ class _UserDocumentsPageState extends State { ); }, icon: const Icon(Icons.add, color: Colors.white), - label: MyText.bodyMedium("Add Document", - color: Colors.white, fontWeight: 600), + label: MyText.bodyMedium( + "Add Document", + color: Colors.white, + fontWeight: 600, + ), backgroundColor: Colors.red, ) : null, From 20365697a7f1e87179e01ea7ea37a9c4d59c7230 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Thu, 11 Sep 2025 17:19:30 +0530 Subject: [PATCH 10/16] feat: Update navigation to employee profile screen in employees list --- lib/view/employees/employees_screen.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/view/employees/employees_screen.dart b/lib/view/employees/employees_screen.dart index 3feb40d..2e30d03 100644 --- a/lib/view/employees/employees_screen.dart +++ b/lib/view/employees/employees_screen.dart @@ -9,13 +9,13 @@ import 'package:marco/controller/employee/employees_screen_controller.dart'; import 'package:marco/helpers/widgets/avatar.dart'; import 'package:marco/controller/project_controller.dart'; import 'package:marco/helpers/widgets/my_custom_skeleton.dart'; -import 'package:marco/view/employees/employee_detail_screen.dart'; import 'package:marco/model/employees/employee_model.dart'; import 'package:marco/helpers/utils/launcher_utils.dart'; import 'package:marco/view/employees/assign_employee_bottom_sheet.dart'; import 'package:marco/controller/permission_controller.dart'; import 'package:marco/helpers/utils/permission_constants.dart'; import 'package:marco/helpers/widgets/my_refresh_indicator.dart'; +import 'package:marco/view/employees/employee_profile_screen.dart'; class EmployeesScreen extends StatefulWidget { const EmployeesScreen({super.key}); @@ -413,7 +413,7 @@ class _EmployeesScreenState extends State with UIMixin { final lastName = names.length > 1 ? names.last : ''; return InkWell( - onTap: () => Get.to(() => EmployeeDetailPage(employeeId: e.id)), + onTap: () => Get.to(() => EmployeeProfilePage(employeeId: e.id)), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ From 229531c5bfbf2360647328906435413e4f94d354 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Thu, 11 Sep 2025 17:32:13 +0530 Subject: [PATCH 11/16] feat: Add comprehensive validation for expense submission fields in bottom sheet --- .../expense/add_expense_bottom_sheet.dart | 62 ++++++++++++++++--- 1 file changed, 55 insertions(+), 7 deletions(-) diff --git a/lib/model/expense/add_expense_bottom_sheet.dart b/lib/model/expense/add_expense_bottom_sheet.dart index 2c3e43b..03f7c4f 100644 --- a/lib/model/expense/add_expense_bottom_sheet.dart +++ b/lib/model/expense/add_expense_bottom_sheet.dart @@ -118,7 +118,61 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> { onCancel: Get.back, onSubmit: () { if (_formKey.currentState!.validate()) { + // Additional dropdown validation + if (controller.selectedProject.value.isEmpty) { + showAppSnackbar( + title: "Error", + message: "Please select a project", + type: SnackbarType.error, + ); + return; + } + + if (controller.selectedExpenseType.value == null) { + showAppSnackbar( + title: "Error", + message: "Please select an expense type", + type: SnackbarType.error, + ); + return; + } + + if (controller.selectedPaymentMode.value == null) { + showAppSnackbar( + title: "Error", + message: "Please select a payment mode", + type: SnackbarType.error, + ); + return; + } + + if (controller.selectedPaidBy.value == null) { + showAppSnackbar( + title: "Error", + message: "Please select a person who paid", + type: SnackbarType.error, + ); + return; + } + + if (controller.attachments.isEmpty && + controller.existingAttachments.isEmpty) { + showAppSnackbar( + title: "Error", + message: "Please attach at least one document", + type: SnackbarType.error, + ); + return; + } + + // Validation passed, submit controller.submitOrUpdateExpense(); + } else { + showAppSnackbar( + title: "Error", + message: "Please fill all required fields correctly", + type: SnackbarType.error, + ); } }, child: SingleChildScrollView( @@ -186,12 +240,6 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> { _CustomTextField( controller: controller.gstController, hint: "Enter GST No.", - validator: (value) { - if (value != null && value.isNotEmpty) { - return Validators.gstValidator(value); - } - return null; - }, ), MySpacing.height(16), @@ -284,7 +332,7 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> { if (value != null && value.isNotEmpty) { return Validators.transactionIdValidator(value); } - return null; + return null; }, ), From bd6f175ca7d2c2f7ecb72348ad799b3f952f9c08 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Thu, 11 Sep 2025 17:45:44 +0530 Subject: [PATCH 12/16] feat: Conditionally display Create Bucket option based on user permissions --- lib/view/directory/directory_view.dart | 68 +++++++++++++++----------- 1 file changed, 39 insertions(+), 29 deletions(-) diff --git a/lib/view/directory/directory_view.dart b/lib/view/directory/directory_view.dart index 54c4fee..9f1a290 100644 --- a/lib/view/directory/directory_view.dart +++ b/lib/view/directory/directory_view.dart @@ -16,6 +16,7 @@ import 'package:marco/model/directory/create_bucket_bottom_sheet.dart'; import 'package:marco/view/directory/contact_detail_screen.dart'; import 'package:marco/view/directory/manage_bucket_screen.dart'; import 'package:marco/controller/permission_controller.dart'; +import 'package:marco/helpers/utils/permission_constants.dart'; class DirectoryView extends StatefulWidget { @override @@ -288,35 +289,44 @@ class _DirectoryViewState extends State { ), ); - // Create Bucket option - menuItems.add( - PopupMenuItem( - value: 2, - child: Row( - children: const [ - Icon(Icons.add_box_outlined, - size: 20, color: Colors.black87), - SizedBox(width: 10), - Expanded(child: Text("Create Bucket")), - Icon(Icons.chevron_right, - size: 20, color: Colors.red), - ], + // ✅ Conditionally show Create Bucket option + if (permissionController + .hasPermission(Permissions.directoryAdmin) || + permissionController + .hasPermission(Permissions.directoryManager) || + permissionController + .hasPermission(Permissions.directoryUser)) { + menuItems.add( + PopupMenuItem( + value: 2, + child: Row( + children: const [ + Icon(Icons.add_box_outlined, + size: 20, color: Colors.black87), + SizedBox(width: 10), + Expanded(child: Text("Create Bucket")), + Icon(Icons.chevron_right, + size: 20, color: Colors.red), + ], + ), + onTap: () { + Future.delayed(Duration.zero, () async { + final created = + await showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (_) => + const CreateBucketBottomSheet(), + ); + if (created == true) { + await controller.fetchBuckets(); + } + }); + }, ), - onTap: () { - Future.delayed(Duration.zero, () async { - final created = await showModalBottomSheet( - context: context, - isScrollControlled: true, - backgroundColor: Colors.transparent, - builder: (_) => const CreateBucketBottomSheet(), - ); - if (created == true) { - await controller.fetchBuckets(); - } - }); - }, - ), - ); + ); + } // Manage Buckets option menuItems.add( @@ -355,7 +365,7 @@ class _DirectoryViewState extends State { ), ); - // Show Inactive switch + // Show Inactive toggle menuItems.add( PopupMenuItem( value: 0, From 5c923bb48bb8e336e8ceb11c271fab7e3e205f9a Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Thu, 11 Sep 2025 18:03:14 +0530 Subject: [PATCH 13/16] feat: Enhance expense payload construction with attachment change detection --- .../expense/add_expense_controller.dart | 106 ++++++++++-------- 1 file changed, 58 insertions(+), 48 deletions(-) diff --git a/lib/controller/expense/add_expense_controller.dart b/lib/controller/expense/add_expense_controller.dart index 34ae284..e2b09e0 100644 --- a/lib/controller/expense/add_expense_controller.dart +++ b/lib/controller/expense/add_expense_controller.dart @@ -358,56 +358,66 @@ class AddExpenseController extends GetxController { } } - Future> _buildExpensePayload() async { - final now = DateTime.now(); - final existingAttachmentPayloads = existingAttachments - .map((e) => { - "documentId": e['documentId'], - "fileName": e['fileName'], - "contentType": e['contentType'], - "fileSize": 0, - "description": "", - "url": e['url'], - "isActive": e['isActive'] ?? true, - "base64Data": e['isActive'] == false ? null : e['base64Data'], - }) - .toList(); +Future> _buildExpensePayload() async { + final now = DateTime.now(); - final newAttachmentPayloads = - await Future.wait(attachments.map((file) async { - final bytes = await file.readAsBytes(); - return { - "fileName": file.path.split('/').last, - "base64Data": base64Encode(bytes), - "contentType": lookupMimeType(file.path) ?? 'application/octet-stream', - "fileSize": await file.length(), - "description": "", - }; - })); + // Determine if attachments were changed + bool attachmentsChanged = + attachments.isNotEmpty || existingAttachments.any((e) => e['isActive'] == false); - final type = selectedExpenseType.value!; - return { - if (isEditMode.value && editingExpenseId != null) "id": editingExpenseId, - "projectId": projectsMap[selectedProject.value]!, - "expensesTypeId": type.id, - "paymentModeId": selectedPaymentMode.value!.id, - "paidById": selectedPaidBy.value!.id, - "transactionDate": (selectedTransactionDate.value?.toUtc() ?? now.toUtc()) - .toIso8601String(), - "transactionId": transactionIdController.text, - "description": descriptionController.text, - "location": locationController.text, - "supplerName": supplierController.text, - "amount": double.parse(amountController.text.trim()), - "noOfPersons": type.noOfPersonsRequired == true - ? int.tryParse(noOfPersonsController.text.trim()) ?? 0 - : 0, - "billAttachments": [ - ...existingAttachmentPayloads, - ...newAttachmentPayloads - ], - }; - } + // Existing attachments payload + final existingAttachmentPayloads = attachmentsChanged + ? existingAttachments.map((e) => { + "documentId": e['documentId'], + "fileName": e['fileName'], + "contentType": e['contentType'], + "fileSize": 0, + "description": "", + "url": e['url'], + "isActive": e['isActive'] ?? true, + // If attachment removed, base64Data should be empty array + "base64Data": e['isActive'] == false ? "" : e['base64Data'], + }).toList() + : []; + + // New attachments payload + final newAttachmentPayloads = attachmentsChanged + ? await Future.wait(attachments.map((file) async { + final bytes = await file.readAsBytes(); + return { + "fileName": file.path.split('/').last, + "base64Data": base64Encode(bytes), + "contentType": lookupMimeType(file.path) ?? 'application/octet-stream', + "fileSize": await file.length(), + "description": "", + }; + })) + : []; + + final type = selectedExpenseType.value!; + + return { + if (isEditMode.value && editingExpenseId != null) "id": editingExpenseId, + "projectId": projectsMap[selectedProject.value]!, + "expensesTypeId": type.id, + "paymentModeId": selectedPaymentMode.value!.id, + "paidById": selectedPaidBy.value!.id, + "transactionDate": (selectedTransactionDate.value?.toUtc() ?? now.toUtc()) + .toIso8601String(), + "transactionId": transactionIdController.text, + "description": descriptionController.text, + "location": locationController.text, + "supplerName": supplierController.text, + "amount": double.parse(amountController.text.trim()), + "noOfPersons": type.noOfPersonsRequired == true + ? int.tryParse(noOfPersonsController.text.trim()) ?? 0 + : 0, + // Attachments logic + "billAttachments": isEditMode.value && !attachmentsChanged + ? null + : [...existingAttachmentPayloads, ...newAttachmentPayloads], + }; +} String validateForm() { final missing = []; From be908a52513128f85e582845e2bcf687e94453e7 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Thu, 11 Sep 2025 19:07:56 +0530 Subject: [PATCH 14/16] feat: Improve document verification and rejection loading states; update permission checks for button visibility --- .../document/document_details_controller.dart | 38 ++++++++++--------- lib/view/document/document_details_page.dart | 5 ++- 2 files changed, 24 insertions(+), 19 deletions(-) diff --git a/lib/controller/document/document_details_controller.dart b/lib/controller/document/document_details_controller.dart index 83f0564..c160a0d 100644 --- a/lib/controller/document/document_details_controller.dart +++ b/lib/controller/document/document_details_controller.dart @@ -11,17 +11,16 @@ class DocumentDetailsController extends GetxController { var versions = [].obs; var isVersionsLoading = false.obs; + // Loading states for buttons + var isVerifyLoading = false.obs; + var isRejectLoading = 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; - } + documentDetails.value = response; } finally { isLoading.value = false; } @@ -34,7 +33,6 @@ class DocumentDetailsController extends GetxController { final response = await ApiService.getDocumentVersionsApi( parentAttachmentId: parentAttachmentId, ); - if (response != null) { versions.assignAll(response.data.data); } else { @@ -47,22 +45,28 @@ class DocumentDetailsController extends GetxController { /// Verify document Future verifyDocument(String documentId) async { - final result = - await ApiService.verifyDocumentApi(id: documentId, isVerify: true); - if (result) { - await fetchDocumentDetails(documentId); // refresh details + try { + isVerifyLoading.value = true; + final result = + await ApiService.verifyDocumentApi(id: documentId, isVerify: true); + if (result) await fetchDocumentDetails(documentId); + return result; + } finally { + isVerifyLoading.value = false; } - return result; } /// Reject document Future rejectDocument(String documentId) async { - final result = - await ApiService.verifyDocumentApi(id: documentId, isVerify: false); - if (result) { - await fetchDocumentDetails(documentId); // refresh details + try { + isRejectLoading.value = true; + final result = + await ApiService.verifyDocumentApi(id: documentId, isVerify: false); + if (result) await fetchDocumentDetails(documentId); + return result; + } finally { + isRejectLoading.value = false; } - return result; } /// Fetch Pre-Signed URL for a given version diff --git a/lib/view/document/document_details_page.dart b/lib/view/document/document_details_page.dart index 695f590..b931844 100644 --- a/lib/view/document/document_details_page.dart +++ b/lib/view/document/document_details_page.dart @@ -230,8 +230,9 @@ class _DocumentDetailsPageState extends State { if (doc.updatedAt != null) _buildDetailRow("Last Updated On", updateDate), MySpacing.height(12), - if (permissionController - .hasPermission(Permissions.verifyDocument)) ...[ + // Show buttons only if user has permission AND document is not verified yet + if (permissionController.hasPermission(Permissions.verifyDocument) && + doc.isVerified == null) ...[ Row( children: [ Expanded( From 61acbb019bba2daaf26f186e93033638a3d4d595 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Fri, 12 Sep 2025 12:22:12 +0530 Subject: [PATCH 15/16] feat: Refactor DynamicMenuController to remove caching and auto-refresh; update error handling in DashboardScreen and UserProfileBar --- .../dynamicMenu/dynamic_menu_controller.dart | 42 ++----- lib/view/dashboard/dashboard_screen.dart | 113 +++++++++--------- lib/view/layouts/user_profile_right_bar.dart | 14 --- 3 files changed, 63 insertions(+), 106 deletions(-) diff --git a/lib/controller/dynamicMenu/dynamic_menu_controller.dart b/lib/controller/dynamicMenu/dynamic_menu_controller.dart index 4af984c..f55cb1e 100644 --- a/lib/controller/dynamicMenu/dynamic_menu_controller.dart +++ b/lib/controller/dynamicMenu/dynamic_menu_controller.dart @@ -3,7 +3,6 @@ import 'package:get/get.dart'; import 'package:marco/model/dynamicMenu/dynamic_menu_model.dart'; import 'package:marco/helpers/services/api_service.dart'; import 'package:marco/helpers/services/app_logger.dart'; -import 'package:marco/helpers/services/storage/local_storage.dart'; class DynamicMenuController extends GetxController { // UI reactive states @@ -12,30 +11,14 @@ class DynamicMenuController extends GetxController { final RxString errorMessage = ''.obs; final RxList menuItems = [].obs; - Timer? _autoRefreshTimer; - @override void onInit() { super.onInit(); - - // ✅ Load cached menus immediately (so user doesn’t see empty state) - final cachedMenus = LocalStorage.getMenus(); - if (cachedMenus.isNotEmpty) { - menuItems.assignAll(cachedMenus); - logSafe("Loaded ${cachedMenus.length} menus from cache at startup"); - } - - // ✅ Fetch from API in background + // Fetch menus directly from API at startup fetchMenu(); - - // Auto refresh every 15 minutes - _autoRefreshTimer = Timer.periodic( - const Duration(minutes: 15), - (_) => fetchMenu(), - ); } - /// Fetch dynamic menu from API with cache fallback + /// Fetch dynamic menu from API (no local cache) Future fetchMenu() async { isLoading.value = true; hasError.value = false; @@ -44,13 +27,9 @@ class DynamicMenuController extends GetxController { try { final responseData = await ApiService.getMenuApi(); if (responseData != null) { - // Parse JSON into MenuResponse final menuResponse = MenuResponse.fromJson(responseData); menuItems.assignAll(menuResponse.data); - // Save for offline use - await LocalStorage.setMenus(menuItems); - logSafe("✅ Menu loaded from API with ${menuItems.length} items"); } else { _handleApiFailure("Menu API returned null response"); @@ -65,26 +44,19 @@ class DynamicMenuController extends GetxController { void _handleApiFailure(String logMessage) { logSafe(logMessage, level: LogLevel.error); - final cachedMenus = LocalStorage.getMenus(); - if (cachedMenus.isNotEmpty) { - menuItems.assignAll(cachedMenus); - errorMessage.value = "⚠️ Using offline menus (latest sync failed)"; - logSafe("Loaded ${menuItems.length} menus from cache after failure"); - } else { - hasError.value = true; - errorMessage.value = "❌ Unable to load menus. Please try again later."; - menuItems.clear(); - } + // No cache available, show error state + hasError.value = true; + errorMessage.value = "❌ Unable to load menus. Please try again later."; + menuItems.clear(); } bool isMenuAllowed(String menuName) { final menu = menuItems.firstWhereOrNull((m) => m.name == menuName); - return menu?.available ?? false; // default false if not found + return menu?.available ?? false; } @override void onClose() { - _autoRefreshTimer?.cancel(); super.onClose(); } } diff --git a/lib/view/dashboard/dashboard_screen.dart b/lib/view/dashboard/dashboard_screen.dart index a169aad..a2269e3 100644 --- a/lib/view/dashboard/dashboard_screen.dart +++ b/lib/view/dashboard/dashboard_screen.dart @@ -40,8 +40,7 @@ class DashboardScreen extends StatefulWidget { class _DashboardScreenState extends State with UIMixin { final DashboardController dashboardController = Get.put(DashboardController(), permanent: true); - final DynamicMenuController menuController = - Get.put(DynamicMenuController(), permanent: true); + final DynamicMenuController menuController = Get.put(DynamicMenuController()); bool hasMpin = true; @@ -243,7 +242,8 @@ class _DashboardScreenState extends State with UIMixin { if (menuController.isLoading.value) { return _buildLoadingSkeleton(context); } - if (menuController.hasError.value) { + if (menuController.hasError.value && menuController.menuItems.isEmpty) { + // ❌ Only show error if there are no menus at all return Padding( padding: const EdgeInsets.all(16), child: Center( @@ -299,70 +299,70 @@ class _DashboardScreenState extends State with UIMixin { } /// Stat Card (Compact with wrapping text) -/// Stat Card (Compact with wrapping text) -Widget _buildStatCard(_StatItem statItem, bool isProjectSelected) { - 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; - // ✅ Attendance should always be enabled - final bool isEnabled = statItem.title == "Attendance" || isProjectSelected; + // ✅ 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, + 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, ), - 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); } -} + /// 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) { @@ -378,7 +378,6 @@ void _handleStatCardTap(_StatItem statItem, bool isEnabled) { } /// Handle Tap - } class _StatItem { diff --git a/lib/view/layouts/user_profile_right_bar.dart b/lib/view/layouts/user_profile_right_bar.dart index 45dc62f..d3d86d8 100644 --- a/lib/view/layouts/user_profile_right_bar.dart +++ b/lib/view/layouts/user_profile_right_bar.dart @@ -10,7 +10,6 @@ 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_profile_screen.dart'; -import 'package:marco/view/document/user_document_screen.dart'; class UserProfileBar extends StatefulWidget { final bool isCondensed; @@ -178,12 +177,6 @@ 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', @@ -250,13 +243,6 @@ 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(); From fd7f108a2069937d993782e8a9f65429521971b0 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Fri, 12 Sep 2025 15:38:42 +0530 Subject: [PATCH 16/16] feat: Add camera functionality to expense attachment; update logger configuration for improved performance --- .../expense/add_expense_controller.dart | 135 ++++++++++-------- lib/helpers/services/app_logger.dart | 4 +- .../expense/add_expense_bottom_sheet.dart | 21 ++- 3 files changed, 100 insertions(+), 60 deletions(-) diff --git a/lib/controller/expense/add_expense_controller.dart b/lib/controller/expense/add_expense_controller.dart index e2b09e0..9f3727d 100644 --- a/lib/controller/expense/add_expense_controller.dart +++ b/lib/controller/expense/add_expense_controller.dart @@ -16,6 +16,7 @@ import 'package:marco/model/employees/employee_model.dart'; import 'package:marco/model/expense/expense_type_model.dart'; import 'package:marco/model/expense/payment_types_model.dart'; import 'package:mime/mime.dart'; +import 'package:image_picker/image_picker.dart'; class AddExpenseController extends GetxController { // --- Text Controllers --- @@ -57,7 +58,7 @@ class AddExpenseController extends GetxController { String? editingExpenseId; final expenseController = Get.find(); - + final ImagePicker _picker = ImagePicker(); @override void onInit() { super.onInit(); @@ -189,7 +190,7 @@ class AddExpenseController extends GetxController { ); if (pickedDate != null) { - final now = DateTime.now(); + final now = DateTime.now(); final finalDateTime = DateTime( pickedDate.year, pickedDate.month, @@ -308,6 +309,17 @@ class AddExpenseController extends GetxController { } } + Future pickFromCamera() async { + try { + final pickedFile = await _picker.pickImage(source: ImageSource.camera); + if (pickedFile != null) { + attachments.add(File(pickedFile.path)); + } + } catch (e) { + _errorSnackbar("Camera error: $e"); + } + } + // --- Submission --- Future submitOrUpdateExpense() async { if (isSubmitting.value) return; @@ -358,66 +370,75 @@ class AddExpenseController extends GetxController { } } -Future> _buildExpensePayload() async { - final now = DateTime.now(); + Future> _buildExpensePayload() async { + final now = DateTime.now(); - // Determine if attachments were changed - bool attachmentsChanged = - attachments.isNotEmpty || existingAttachments.any((e) => e['isActive'] == false); + // --- Existing Attachments Payload (for edit mode only) --- + final List> existingAttachmentPayloads = + isEditMode.value + ? existingAttachments + .map>((e) => { + "documentId": e['documentId'], + "fileName": e['fileName'], + "contentType": e['contentType'], + "fileSize": 0, + "description": "", + "url": e['url'], + "isActive": e['isActive'] ?? true, + "base64Data": "", // <-- always empty now + }) + .toList() + : >[]; - // Existing attachments payload - final existingAttachmentPayloads = attachmentsChanged - ? existingAttachments.map((e) => { - "documentId": e['documentId'], - "fileName": e['fileName'], - "contentType": e['contentType'], - "fileSize": 0, - "description": "", - "url": e['url'], - "isActive": e['isActive'] ?? true, - // If attachment removed, base64Data should be empty array - "base64Data": e['isActive'] == false ? "" : e['base64Data'], - }).toList() - : []; + // --- New Attachments Payload (always include if attachments exist) --- + final List> newAttachmentPayloads = + attachments.isNotEmpty + ? await Future.wait(attachments.map((file) async { + final bytes = await file.readAsBytes(); + final length = await file.length(); + return { + "fileName": file.path.split('/').last, + "base64Data": base64Encode(bytes), + "contentType": + lookupMimeType(file.path) ?? 'application/octet-stream', + "fileSize": length, + "description": "", + }; + })) + : >[]; - // New attachments payload - final newAttachmentPayloads = attachmentsChanged - ? await Future.wait(attachments.map((file) async { - final bytes = await file.readAsBytes(); - return { - "fileName": file.path.split('/').last, - "base64Data": base64Encode(bytes), - "contentType": lookupMimeType(file.path) ?? 'application/octet-stream', - "fileSize": await file.length(), - "description": "", - }; - })) - : []; + // --- Selected Expense Type --- + final type = selectedExpenseType.value!; - final type = selectedExpenseType.value!; + // --- Combine all attachments --- + final List> combinedAttachments = [ + ...existingAttachmentPayloads, + ...newAttachmentPayloads + ]; - return { - if (isEditMode.value && editingExpenseId != null) "id": editingExpenseId, - "projectId": projectsMap[selectedProject.value]!, - "expensesTypeId": type.id, - "paymentModeId": selectedPaymentMode.value!.id, - "paidById": selectedPaidBy.value!.id, - "transactionDate": (selectedTransactionDate.value?.toUtc() ?? now.toUtc()) - .toIso8601String(), - "transactionId": transactionIdController.text, - "description": descriptionController.text, - "location": locationController.text, - "supplerName": supplierController.text, - "amount": double.parse(amountController.text.trim()), - "noOfPersons": type.noOfPersonsRequired == true - ? int.tryParse(noOfPersonsController.text.trim()) ?? 0 - : 0, - // Attachments logic - "billAttachments": isEditMode.value && !attachmentsChanged - ? null - : [...existingAttachmentPayloads, ...newAttachmentPayloads], - }; -} + // --- Build Payload --- + final payload = { + if (isEditMode.value && editingExpenseId != null) "id": editingExpenseId, + "projectId": projectsMap[selectedProject.value]!, + "expensesTypeId": type.id, + "paymentModeId": selectedPaymentMode.value!.id, + "paidById": selectedPaidBy.value!.id, + "transactionDate": (selectedTransactionDate.value?.toUtc() ?? now.toUtc()) + .toIso8601String(), + "transactionId": transactionIdController.text, + "description": descriptionController.text, + "location": locationController.text, + "supplerName": supplierController.text, + "amount": double.parse(amountController.text.trim()), + "noOfPersons": type.noOfPersonsRequired == true + ? int.tryParse(noOfPersonsController.text.trim()) ?? 0 + : 0, + "billAttachments": + combinedAttachments.isEmpty ? null : combinedAttachments, + }; + + return payload; + } String validateForm() { final missing = []; diff --git a/lib/helpers/services/app_logger.dart b/lib/helpers/services/app_logger.dart index 9e855e9..1e631ea 100644 --- a/lib/helpers/services/app_logger.dart +++ b/lib/helpers/services/app_logger.dart @@ -18,10 +18,10 @@ bool _isPosting = false; bool _canPostLogs = false; /// Maximum number of logs before triggering API post -const int _maxLogsBeforePost = 50; +const int _maxLogsBeforePost = 100; /// Maximum logs in memory buffer -const int _maxBufferSize = 50; +const int _maxBufferSize = 500; /// Enum → logger level mapping const _levelMap = { diff --git a/lib/model/expense/add_expense_bottom_sheet.dart b/lib/model/expense/add_expense_bottom_sheet.dart index 03f7c4f..49713c3 100644 --- a/lib/model/expense/add_expense_bottom_sheet.dart +++ b/lib/model/expense/add_expense_bottom_sheet.dart @@ -791,6 +791,8 @@ class _AttachmentsSection extends StatelessWidget { ), ); }), + + // 📎 File Picker Button GestureDetector( onTap: onAdd, child: Container( @@ -801,7 +803,24 @@ class _AttachmentsSection extends StatelessWidget { borderRadius: BorderRadius.circular(8), color: Colors.grey.shade100, ), - child: const Icon(Icons.add, size: 30, color: Colors.grey), + child: const Icon(Icons.attach_file, + size: 30, color: Colors.grey), + ), + ), + + // 📷 Camera Button + GestureDetector( + onTap: () => Get.find().pickFromCamera(), + child: Container( + width: 80, + height: 80, + decoration: BoxDecoration( + border: Border.all(color: Colors.grey.shade400), + borderRadius: BorderRadius.circular(8), + color: Colors.grey.shade100, + ), + child: const Icon(Icons.camera_alt, + size: 30, color: Colors.grey), ), ), ],