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 new file mode 100644 index 0000000..c160a0d --- /dev/null +++ b/lib/controller/document/document_details_controller.dart @@ -0,0 +1,82 @@ +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; + + // 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); + documentDetails.value = response; + } 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; + } + } + + /// Verify document + Future verifyDocument(String documentId) async { + try { + isVerifyLoading.value = true; + final result = + await ApiService.verifyDocumentApi(id: documentId, isVerify: true); + if (result) await fetchDocumentDetails(documentId); + return result; + } finally { + isVerifyLoading.value = false; + } + } + + /// Reject document + Future rejectDocument(String documentId) async { + try { + isRejectLoading.value = true; + final result = + await ApiService.verifyDocumentApi(id: documentId, isVerify: false); + if (result) await fetchDocumentDetails(documentId); + return result; + } finally { + isRejectLoading.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..c7e33c7 --- /dev/null +++ b/lib/controller/document/document_upload_controller.dart @@ -0,0 +1,239 @@ +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; + } + } + + Future fetchPresignedUrl(String versionId) async { + return await ApiService.getPresignedUrlApi(versionId); + } + + /// 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; + } + } + + 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/controller/document/user_document_controller.dart b/lib/controller/document/user_document_controller.dart new file mode 100644 index 0000000..869bd28 --- /dev/null +++ b/lib/controller/document/user_document_controller.dart @@ -0,0 +1,173 @@ +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'; +import 'package:marco/model/document/documents_list_model.dart'; + +class DocumentController extends GetxController { + // ------------------ Observables --------------------- + 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; + var hasMore = true.obs; + + // 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 + 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; + } + } + + /// 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, + 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 ?? searchQuery.value, + pageNumber: pageNumber.value, + pageSize: pageSize, + isActive: !showInactive.value, // 👈 active or inactive + ); + + 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 = ""; + } + + /// 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/controller/dynamicMenu/dynamic_menu_controller.dart b/lib/controller/dynamicMenu/dynamic_menu_controller.dart index e7fb382..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,20 +11,14 @@ class DynamicMenuController extends GetxController { final RxString errorMessage = ''.obs; final RxList menuItems = [].obs; - Timer? _autoRefreshTimer; @override void onInit() { super.onInit(); + // Fetch menus directly from API at startup fetchMenu(); - - /// Auto refresh every 5 minutes (adjust as needed) - _autoRefreshTimer = Timer.periodic( - const Duration(minutes: 15), - (_) => fetchMenu(), - ); } - /// Fetch dynamic menu from API with error and local storage support + /// Fetch dynamic menu from API (no local cache) Future fetchMenu() async { isLoading.value = true; hasError.value = false; @@ -34,53 +27,36 @@ class DynamicMenuController extends GetxController { try { final responseData = await ApiService.getMenuApi(); if (responseData != null) { - // Directly parse full JSON into MenuResponse final menuResponse = MenuResponse.fromJson(responseData); - menuItems.assignAll(menuResponse.data); - // Save menus 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); + + // 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(); // clean up timer super.onClose(); } } 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/controller/expense/add_expense_controller.dart b/lib/controller/expense/add_expense_controller.dart index 34ae284..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; @@ -360,33 +372,52 @@ 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(); - 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": "", - }; - })); + // --- 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() + : >[]; + // --- 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": "", + }; + })) + : >[]; + + // --- Selected Expense Type --- final type = selectedExpenseType.value!; - return { + + // --- Combine all attachments --- + final List> combinedAttachments = [ + ...existingAttachmentPayloads, + ...newAttachmentPayloads + ]; + + // --- Build Payload --- + final payload = { if (isEditMode.value && editingExpenseId != null) "id": editingExpenseId, "projectId": projectsMap[selectedProject.value]!, "expensesTypeId": type.id, @@ -402,11 +433,11 @@ class AddExpenseController extends GetxController { "noOfPersons": type.noOfPersonsRequired == true ? int.tryParse(noOfPersonsController.text.trim()) ?? 0 : 0, - "billAttachments": [ - ...existingAttachmentPayloads, - ...newAttachmentPayloads - ], + "billAttachments": + combinedAttachments.isEmpty ? null : combinedAttachments, }; + + return payload; } String validateForm() { diff --git a/lib/helpers/services/api_endpoints.dart b/lib/helpers/services/api_endpoints.dart index 9bf8e59..eee0216 100644 --- a/lib/helpers/services/api_endpoints.dart +++ b/lib/helpers/services/api_endpoints.dart @@ -1,15 +1,16 @@ 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://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"; + // 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 +46,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 +72,22 @@ 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"; + 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 4c64820..d0f12e2 100644 --- a/lib/helpers/services/api_service.dart +++ b/lib/helpers/services/api_service.dart @@ -12,6 +12,12 @@ 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); @@ -241,6 +247,527 @@ 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"; + 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; + } + + /// 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, + List> tags = const [], + Map? attachment, // 👈 can be null + }) async { + final endpoint = "${ApiEndpoints.editDocument}/$id"; + logSafe("Editing document with id: $id"); + + final Map payload = { + "id": id, + "name": name, + "documentId": documentId, + "description": description ?? "", + "tags": tags.isNotEmpty + ? tags + : [ + {"name": "default", "isActive": true} + ], + "attachment": attachment, // 👈 null or object + }; + + 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 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 === // /// Get Sidebar Menu API @@ -1358,6 +1885,7 @@ class ApiService { required String phoneNumber, required String gender, required String jobRoleId, + required String joiningDate, }) async { final body = { "firstName": firstName, @@ -1365,6 +1893,7 @@ class ApiService { "phoneNumber": phoneNumber, "gender": gender, "jobRoleId": jobRoleId, + "joiningDate": joiningDate }; final response = await _postRequest( diff --git a/lib/helpers/services/app_logger.dart b/lib/helpers/services/app_logger.dart index 9047066..1e631ea 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 = 100; + +/// Maximum logs in memory buffer +const int _maxBufferSize = 500; + +/// 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/helpers/utils/permission_constants.dart b/lib/helpers/utils/permission_constants.dart index c576f37..f257cfc 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,30 @@ 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"; + + // ------------------- 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/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/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), 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/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/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/model/document/document_details_model.dart b/lib/model/document/document_details_model.dart new file mode 100644 index 0000000..06e52eb --- /dev/null +++ b/lib/model/document/document_details_model.dart @@ -0,0 +1,255 @@ +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'] ?? '', + ); + } + + 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, + 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, + ); + } + + 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, + required 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, + }; + } +} + +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, + ); + } + + 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/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..a4a4f87 --- /dev/null +++ b/lib/model/document/document_upload_bottom_sheet.dart @@ -0,0 +1,776 @@ +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 bool isEmployee; + const DocumentUploadBottomSheet({ + Key? key, + required this.onSubmit, + this.isEmployee = false, + }) : 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; + + @override + void dispose() { + _docIdController.dispose(); + _docNameController.dispose(); + _descriptionController.dispose(); + super.dispose(); + } + + void _handleSubmit() { + 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", + message: "Please attach a document", + type: SnackbarType.error, + ); + return; + } + + // 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); + if (fileSizeMB > maxSizeMB) { + showAppSnackbar( + title: "Error", + message: "File size exceeds $maxSizeMB MB limit", + type: SnackbarType.error, + ); + return; + } + } + + // 5️⃣ 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; + } + } + + // 6️⃣ Prepare payload + 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(), + }; + + // 7️⃣ Submit + widget.onSubmit(payload); + + // 8️⃣ Show success message + 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; + 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: "Upload 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) { + 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 + 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, + onPick: _pickFile, + onRemove: () => setState(() { + selectedFile = 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 VoidCallback onPick; + final VoidCallback? onRemove; + + const AttachmentSectionSingle({ + Key? key, + this.attachment, + required this.onPick, + this.onRemove, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + 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..5bbf820 --- /dev/null +++ b/lib/model/document/documents_list_model.dart @@ -0,0 +1,294 @@ +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(), + }; + } +} + +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; + 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/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/model/expense/add_expense_bottom_sheet.dart b/lib/model/expense/add_expense_bottom_sheet.dart index 2c3e43b..49713c3 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; }, ), @@ -743,6 +791,8 @@ class _AttachmentsSection extends StatelessWidget { ), ); }), + + // 📎 File Picker Button GestureDetector( onTap: onAdd, child: Container( @@ -753,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), ), ), ], 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..a2269e3 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(); @@ -39,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; @@ -79,7 +79,7 @@ class _DashboardScreenState extends State with UIMixin { */ _buildDashboardStats(context), MySpacing.height(24), - SizedBox( + SizedBox( width: double.infinity, child: DashboardOverviewWidgets.teamsOverview(), ), @@ -242,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( @@ -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,10 +299,14 @@ class _DashboardScreenState extends State with UIMixin { } /// Stat Card (Compact with wrapping text) - Widget _buildStatCard(_StatItem statItem, bool isEnabled) { + /// 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; + return Opacity( opacity: isEnabled ? 1.0 : 0.4, child: IgnorePointer( @@ -334,19 +347,6 @@ class _DashboardScreenState extends State with UIMixin { ); } - /// Compact Icon - Widget _buildStatCardIconCompact(_StatItem statItem) { - return MyContainer.rounded( - paddingAll: 6, - color: statItem.color.withOpacity(0.1), - child: Icon( - statItem.icon, - size: 14, - color: statItem.color, - ), - ); - } - /// Handle Tap void _handleStatCardTap(_StatItem statItem, bool isEnabled) { if (!isEnabled) { @@ -363,6 +363,21 @@ class _DashboardScreenState extends State with UIMixin { Get.toNamed(statItem.route); } } + + /// Compact Icon + Widget _buildStatCardIconCompact(_StatItem statItem) { + return MyContainer.rounded( + paddingAll: 6, + color: statItem.color.withOpacity(0.1), + child: Icon( + statItem.icon, + size: 14, + color: statItem.color, + ), + ); + } + + /// Handle Tap } class _StatItem { 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, diff --git a/lib/view/document/document_details_page.dart b/lib/view/document/document_details_page.dart new file mode 100644 index 0000000..b931844 --- /dev/null +++ b/lib/view/document/document_details_page.dart @@ -0,0 +1,428 @@ +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'; +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; + + const DocumentDetailsPage({super.key, required this.documentId}); + + @override + State createState() => _DocumentDetailsPageState(); +} + +class _DocumentDetailsPageState extends State { + final DocumentDetailsController controller = + Get.put(DocumentDetailsController()); + final PermissionController permissionController = + Get.find(); + @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(), + 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 with Edit button + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: 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, + ), + ], + ), + ), + ], + ), + ), + 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), + + // 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), + MySpacing.height(12), + // Show buttons only if user has permission AND document is not verified yet + if (permissionController.hasPermission(Permissions.verifyDocument) && + doc.isVerified == null) ...[ + 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, + ); + } + }, + ), + ), + ], + ), + ], + ], + ), + ); + } + + /// ---------------- 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: + 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, + ); + }, + ); + }); + } + + /// ---------------- 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..a33651e --- /dev/null +++ b/lib/view/document/user_document_screen.dart @@ -0,0 +1,621 @@ +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'; +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; + 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()); + final PermissionController permissionController = + Get.find(); + + 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, + ), + ], + ), + ), + PopupMenuButton( + 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( + 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") { + // existing activate flow (unchanged) + 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 && + permissionController + .hasPermission(Permissions.deleteDocument)) + const PopupMenuItem( + value: "delete", + child: Text("Delete"), + ) + else if (!doc.isActive && + permissionController + .hasPermission(Permissions.modifyDocument)) + const PopupMenuItem( + value: "activate", + child: Text("Activate"), + ), + ], + ), + ], + ), + ), + ), + ], + ); + } + + 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 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) { + // 🔒 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( + physics: const NeverScrollableScrollPhysics(), + child: SkeletonLoaders.documentSkeletonLoader(), + ); + } + + final docs = docController.documents; + return SafeArea( + child: Column( + children: [ + _buildFilterRow(context), + _buildStatusHeader(), + 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) { + final bool showAppBar = !widget.isEmployee; + + return Scaffold( + backgroundColor: const Color(0xFFF1F1F1), + appBar: showAppBar + ? CustomAppBar( + title: 'Documents', + onBackPressed: () { + Get.back(); + }, + ) + : null, + body: _buildBody(context), + floatingActionButton: permissionController + .hasPermission(Permissions.uploadDocument) + ? FloatingActionButton.extended( + onPressed: () { + final uploadController = Get.put(DocumentUploadController()); + + showModalBottomSheet( + context: context, + 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"], + 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"); + } + }, + ), + ); + }, + 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, + ); + } +} 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/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: [ diff --git a/lib/view/layouts/user_profile_right_bar.dart b/lib/view/layouts/user_profile_right_bar.dart index f739d42..d3d86d8 100644 --- a/lib/view/layouts/user_profile_right_bar.dart +++ b/lib/view/layouts/user_profile_right_bar.dart @@ -9,7 +9,7 @@ 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'; class UserProfileBar extends StatefulWidget { final bool isCondensed; @@ -238,9 +238,8 @@ class _UserProfileBarState extends State } void _onProfileTap() { - Get.to(() => EmployeeDetailPage( + Get.to(() => EmployeeProfilePage( employeeId: employeeInfo.id, - fromProfile: true, )); }