diff --git a/lib/controller/document/user_document_controller.dart b/lib/controller/document/user_document_controller.dart index 8b4045b..869bd28 100644 --- a/lib/controller/document/user_document_controller.dart +++ b/lib/controller/document/user_document_controller.dart @@ -1,3 +1,4 @@ +import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:marco/helpers/services/api_service.dart'; import 'package:marco/model/document/document_filter_model.dart'; @@ -8,11 +9,14 @@ class DocumentController extends GetxController { var isLoading = false.obs; var documents = [].obs; var filters = Rxn(); + + // Selected filters var selectedFilter = "".obs; var selectedUploadedBy = "".obs; var selectedCategory = "".obs; var selectedType = "".obs; var selectedTag = "".obs; + // Pagination state var pageNumber = 1.obs; final int pageSize = 20; @@ -21,6 +25,13 @@ class DocumentController extends GetxController { // Error message var errorMessage = "".obs; + // NEW: show inactive toggle + var showInactive = false.obs; + + // NEW: search + var searchQuery = ''.obs; + var searchController = TextEditingController(); + // ------------------ API Calls ----------------------- /// Fetch Document Filters for an Entity @@ -41,12 +52,67 @@ class DocumentController extends GetxController { } } + /// Toggle document active/inactive state + Future toggleDocumentActive( + String id, { + required bool isActive, + required String entityTypeId, + required String entityId, + }) async { + try { + isLoading.value = true; + final success = + await ApiService.deleteDocumentApi(id: id, isActive: isActive); + + if (success) { + // 🔥 Always fetch fresh list after toggle + await fetchDocuments( + entityTypeId: entityTypeId, + entityId: entityId, + reset: true, + ); + return true; + } else { + errorMessage.value = "Failed to update document state"; + return false; + } + } catch (e) { + errorMessage.value = "Error updating document: $e"; + return false; + } finally { + isLoading.value = false; + } + } + + /// Permanently delete a document (or deactivate depending on API) + Future deleteDocument(String id, {bool isActive = false}) async { + try { + isLoading.value = true; + final success = + await ApiService.deleteDocumentApi(id: id, isActive: isActive); + + if (success) { + // remove from local list immediately for better UX + documents.removeWhere((doc) => doc.id == id); + return true; + } else { + errorMessage.value = "Failed to delete document"; + return false; + } + } catch (e) { + errorMessage.value = "Error deleting document: $e"; + return false; + } finally { + isLoading.value = false; + } + } + /// Fetch Documents for an entity Future fetchDocuments({ required String entityTypeId, required String entityId, - String filter = "", - String searchString = "", + String? filter, + String? searchString, bool reset = false, }) async { try { @@ -63,10 +129,11 @@ class DocumentController extends GetxController { final response = await ApiService.getDocumentListApi( entityTypeId: entityTypeId, entityId: entityId, - filter: filter, - searchString: searchString, + filter: filter ?? "", + searchString: searchString ?? searchQuery.value, pageNumber: pageNumber.value, pageSize: pageSize, + isActive: !showInactive.value, // 👈 active or inactive ); if (response != null && response.success) { @@ -95,4 +162,12 @@ class DocumentController extends GetxController { selectedType.value = ""; selectedTag.value = ""; } + + /// Check if any filters are active (for red dot indicator) + bool hasActiveFilters() { + return selectedUploadedBy.value.isNotEmpty || + selectedCategory.value.isNotEmpty || + selectedType.value.isNotEmpty || + selectedTag.value.isNotEmpty; + } } diff --git a/lib/helpers/services/api_endpoints.dart b/lib/helpers/services/api_endpoints.dart index 0bf1092..2f2a32f 100644 --- a/lib/helpers/services/api_endpoints.dart +++ b/lib/helpers/services/api_endpoints.dart @@ -84,4 +84,5 @@ class ApiEndpoints { static const String getDocumentTypesByCategory = "/master/document-type/list"; static const String getDocumentVersion = "/document/get/version"; static const String getDocumentVersions = "/document/list/versions"; + static const String editDocument = "/document/edit"; } diff --git a/lib/helpers/services/api_service.dart b/lib/helpers/services/api_service.dart index 078be53..d120a6c 100644 --- a/lib/helpers/services/api_service.dart +++ b/lib/helpers/services/api_service.dart @@ -19,7 +19,6 @@ import 'package:marco/model/document/master_document_type_model.dart'; import 'package:marco/model/document/document_details_model.dart'; import 'package:marco/model/document/document_version_model.dart'; - class ApiService { static const Duration timeout = Duration(seconds: 30); static const bool enableLogs = true; @@ -247,6 +246,7 @@ class ApiService { return null; } } + /// Get Pre-Signed URL for Old Version static Future getPresignedUrlApi(String versionId) async { final endpoint = "${ApiEndpoints.getDocumentVersion}/$versionId"; @@ -268,22 +268,138 @@ class ApiService { return jsonResponse['data'] as String?; } } catch (e, stack) { - logSafe("Exception during getPresignedUrlApi: $e", - level: LogLevel.error); + logSafe("Exception during getPresignedUrlApi: $e", level: LogLevel.error); logSafe("StackTrace: $stack", level: LogLevel.debug); } return null; } + /// Delete (Soft Delete / Deactivate) Document API + static Future deleteDocumentApi({ + required String id, + bool isActive = false, // default false = delete + }) async { + final endpoint = "${ApiEndpoints.deleteDocument}/$id"; + final queryParams = {"isActive": isActive.toString()}; + logSafe("Deleting document with id: $id | isActive: $isActive"); + + try { + final uri = Uri.parse("${ApiEndpoints.baseUrl}$endpoint") + .replace(queryParameters: queryParams); + + String? token = await _getToken(); + if (token == null) return false; + + final headers = _headers(token); + logSafe("DELETE (PUT/POST style) $uri\nHeaders: $headers"); + + // some backends use PUT instead of DELETE for soft deletes + final response = + await http.delete(uri, headers: headers).timeout(extendedTimeout); + + if (response.statusCode == 401) { + logSafe("Unauthorized DELETE. Attempting token refresh..."); + if (await AuthService.refreshToken()) { + return await deleteDocumentApi(id: id, isActive: isActive); + } + } + + logSafe("Delete document response status: ${response.statusCode}"); + logSafe("Delete document response body: ${response.body}"); + + final json = jsonDecode(response.body); + if (json['success'] == true) { + logSafe("Document delete/update success: ${json['data']}"); + return true; + } else { + logSafe( + "Failed to delete document: ${json['message'] ?? 'Unknown error'}", + level: LogLevel.warning, + ); + } + } catch (e, stack) { + logSafe("Exception during deleteDocumentApi: $e", level: LogLevel.error); + logSafe("StackTrace: $stack", level: LogLevel.debug); + } + + return false; + } + + /// Edit Document API + static Future editDocumentApi({ + required String id, + required String name, + required String documentId, + String? description, + required String fileName, + required String base64Data, + required String contentType, + required int fileSize, + String? fileDescription, + bool isActive = true, + List> tags = const [], + }) async { + final endpoint = "${ApiEndpoints.editDocument}/$id"; + logSafe("Editing document with id: $id"); + + final Map payload = { + "id": id, + "name": name, + "documentId": documentId, + "description": description ?? "", + "attachment": { + "fileName": fileName, + "base64Data": base64Data, + "contentType": contentType, + "fileSize": fileSize, + "description": fileDescription ?? "", + "isActive": isActive, + }, + "tags": tags.isNotEmpty + ? tags + : [ + {"name": "default", "isActive": true} + ], + }; + + try { + final response = + await _putRequest(endpoint, payload, customTimeout: extendedTimeout); + + if (response == null) { + logSafe("Edit document failed: null response", level: LogLevel.error); + return false; + } + + logSafe("Edit document response status: ${response.statusCode}"); + logSafe("Edit document response body: ${response.body}"); + + final json = jsonDecode(response.body); + if (json['success'] == true) { + logSafe("Document edited successfully: ${json['data']}"); + return true; + } else { + logSafe( + "Failed to edit document: ${json['message'] ?? 'Unknown error'}", + level: LogLevel.warning, + ); + } + } catch (e, stack) { + logSafe("Exception during editDocumentApi: $e", level: LogLevel.error); + logSafe("StackTrace: $stack", level: LogLevel.debug); + } + + return false; + } + /// Get List of Versions by ParentAttachmentId static Future getDocumentVersionsApi({ required String parentAttachmentId, int pageNumber = 1, int pageSize = 20, }) async { - final endpoint = - "${ApiEndpoints.getDocumentVersions}/$parentAttachmentId"; + final endpoint = "${ApiEndpoints.getDocumentVersions}/$parentAttachmentId"; final queryParams = { "pageNumber": pageNumber.toString(), "pageSize": pageSize.toString(), @@ -293,8 +409,7 @@ class ApiService { "Fetching document versions for parentAttachmentId: $parentAttachmentId"); try { - final response = - await _getRequest(endpoint, queryParams: queryParams); + final response = await _getRequest(endpoint, queryParams: queryParams); if (response == null) { logSafe("Document versions request failed: null response", diff --git a/lib/helpers/widgets/my_confirmation_dialog.dart b/lib/helpers/widgets/my_confirmation_dialog.dart index c1c15d4..9c9c862 100644 --- a/lib/helpers/widgets/my_confirmation_dialog.dart +++ b/lib/helpers/widgets/my_confirmation_dialog.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:marco/helpers/widgets/my_text.dart'; + class ConfirmDialog extends StatelessWidget { final String title; final String message; diff --git a/lib/model/document/documents_list_model.dart b/lib/model/document/documents_list_model.dart index f589362..5bbf820 100644 --- a/lib/model/document/documents_list_model.dart +++ b/lib/model/document/documents_list_model.dart @@ -142,6 +142,27 @@ class DocumentItem { } } +extension DocumentItemCopy on DocumentItem { + DocumentItem copyWith({ + bool? isActive, + }) { + return DocumentItem( + id: id, + name: name, + documentId: documentId, + description: description, + uploadedAt: uploadedAt, + parentAttachmentId: parentAttachmentId, + isCurrentVersion: isCurrentVersion, + version: version, + isActive: isActive ?? this.isActive, + isVerified: isVerified, + uploadedBy: uploadedBy, + documentType: documentType, + ); + } +} + class UploadedBy { final String id; final String firstName; diff --git a/lib/view/document/document_details_page.dart b/lib/view/document/document_details_page.dart index 8b714a6..8775c34 100644 --- a/lib/view/document/document_details_page.dart +++ b/lib/view/document/document_details_page.dart @@ -73,7 +73,7 @@ class _DocumentDetailsPageState extends State { onRefresh: _onRefresh, child: SingleChildScrollView( physics: - const AlwaysScrollableScrollPhysics(), // ensures pull works + const AlwaysScrollableScrollPhysics(), padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -117,30 +117,43 @@ class _DocumentDetailsPageState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Header + // Header with Edit button Row( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - CircleAvatar( - backgroundColor: Colors.blue.shade50, - radius: 28, - child: - const Icon(Icons.description, color: Colors.blue, size: 28), - ), - MySpacing.width(16), Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + child: Row( children: [ - MyText.titleLarge(doc.name, - fontWeight: 700, color: Colors.black), - MyText.bodySmall( - doc.documentType.name, - color: Colors.blueGrey, - fontWeight: 600, + CircleAvatar( + backgroundColor: Colors.blue.shade50, + radius: 28, + child: const Icon(Icons.description, + color: Colors.blue, size: 28), + ), + MySpacing.width(16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.titleLarge(doc.name, + fontWeight: 700, color: Colors.black), + MyText.bodySmall( + doc.documentType.name, + color: Colors.blueGrey, + fontWeight: 600, + ), + ], + ), ), ], ), ), + IconButton( + icon: const Icon(Icons.edit, color: Colors.red), + onPressed: () { + + }, + ), ], ), MySpacing.height(12), @@ -272,16 +285,15 @@ class _DocumentDetailsPageState extends State { } Future _openDocument(String url) async { - final uri = Uri.parse(url); - if (await canLaunchUrl(uri)) { - await launchUrl(uri, mode: LaunchMode.externalApplication); - } else { - showAppSnackbar( - title: "Error", - message: "Could not open document", - type: SnackbarType.error, - ); + final uri = Uri.parse(url); + if (await canLaunchUrl(uri)) { + await launchUrl(uri, mode: LaunchMode.externalApplication); + } else { + showAppSnackbar( + title: "Error", + message: "Could not open document", + type: SnackbarType.error, + ); + } } } - -} diff --git a/lib/view/document/user_document_screen.dart b/lib/view/document/user_document_screen.dart index c2d78ee..2b87ecf 100644 --- a/lib/view/document/user_document_screen.dart +++ b/lib/view/document/user_document_screen.dart @@ -14,6 +14,8 @@ import 'package:marco/model/document/document_upload_bottom_sheet.dart'; import 'package:marco/controller/document/document_upload_controller.dart'; import 'package:marco/view/document/document_details_page.dart'; import 'package:marco/helpers/widgets/custom_app_bar.dart'; +import 'package:marco/helpers/widgets/my_confirmation_dialog.dart'; +import 'package:marco/helpers/widgets/my_snackbar.dart'; class UserDocumentsPage extends StatefulWidget { final String? entityId; @@ -137,9 +139,85 @@ class _UserDocumentsPageState extends State { ], ), ), - IconButton( - icon: const Icon(Icons.arrow_forward_ios, color: Colors.black54), - onPressed: () {}, + PopupMenuButton( + icon: const Icon(Icons.more_vert, color: Colors.black54), + onSelected: (value) async { + if (value == "delete") { + final result = await showDialog( + context: context, + builder: (_) => ConfirmDialog( + title: "Delete Document", + message: + "Are you sure you want to delete \"${doc.name}\"?\nThis action cannot be undone.", + confirmText: "Delete", + cancelText: "Cancel", + icon: Icons.delete_forever, + confirmColor: Colors.redAccent, + onConfirm: () async { + final success = + await docController.toggleDocumentActive( + doc.id, + isActive: false, + entityTypeId: entityTypeId, + entityId: resolvedEntityId, + ); + + if (success) { + showAppSnackbar( + title: "Deleted", + message: "Document deleted successfully", + type: SnackbarType.success, + ); + } else { + showAppSnackbar( + title: "Error", + message: "Failed to delete document", + type: SnackbarType.error, + ); + throw Exception( + "Failed to delete"); // keep dialog open + } + }, + ), + ); + if (result == true) { + debugPrint("✅ Document deleted and removed from list"); + } + } else if (value == "activate") { + final success = await docController.toggleDocumentActive( + doc.id, + isActive: true, + entityTypeId: entityTypeId, + entityId: resolvedEntityId, + ); + + if (success) { + showAppSnackbar( + title: "Reactivated", + message: "Document reactivated successfully", + type: SnackbarType.success, + ); + } else { + showAppSnackbar( + title: "Error", + message: "Failed to reactivate document", + type: SnackbarType.error, + ); + } + } + }, + itemBuilder: (context) => [ + if (doc.isActive) + const PopupMenuItem( + value: "delete", + child: Text("Delete"), + ) + else + const PopupMenuItem( + value: "activate", + child: Text("Activate"), + ), + ], ), ], ), @@ -172,26 +250,210 @@ class _UserDocumentsPageState extends State { } Widget _buildFilterRow(BuildContext context) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - alignment: Alignment.centerRight, - child: IconButton( - icon: const Icon(Icons.tune, color: Colors.black), - onPressed: () { - showModalBottomSheet( - context: context, - isScrollControlled: true, - backgroundColor: Colors.transparent, - builder: (_) => UserDocumentFilterBottomSheet( - entityId: resolvedEntityId, - entityTypeId: entityTypeId, + return Padding( + padding: MySpacing.xy(8, 8), + child: Row( + children: [ + // 🔍 Search Bar + Expanded( + child: SizedBox( + height: 35, + child: TextField( + controller: docController.searchController, + onChanged: (value) { + docController.searchQuery.value = value; + docController.fetchDocuments( + entityTypeId: entityTypeId, + entityId: resolvedEntityId, + reset: true, + ); + }, + decoration: InputDecoration( + contentPadding: const EdgeInsets.symmetric(horizontal: 12), + prefixIcon: + const Icon(Icons.search, size: 20, color: Colors.grey), + suffixIcon: ValueListenableBuilder( + valueListenable: docController.searchController, + builder: (context, value, _) { + if (value.text.isEmpty) return const SizedBox.shrink(); + return IconButton( + icon: const Icon(Icons.clear, + size: 20, color: Colors.grey), + onPressed: () { + docController.searchController.clear(); + docController.searchQuery.value = ''; + docController.fetchDocuments( + entityTypeId: entityTypeId, + entityId: resolvedEntityId, + reset: true, + ); + }, + ); + }, + ), + hintText: 'Search documents...', + filled: true, + fillColor: Colors.white, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + ), + ), ), - ); - }, + ), + MySpacing.width(8), + + // 🛠️ Filter Icon with indicator + Obx(() { + final isFilterActive = docController.hasActiveFilters(); + return Stack( + children: [ + Container( + height: 35, + width: 35, + decoration: BoxDecoration( + color: Colors.white, + border: Border.all(color: Colors.grey.shade300), + borderRadius: BorderRadius.circular(10), + ), + child: IconButton( + padding: EdgeInsets.zero, + constraints: BoxConstraints(), + icon: Icon( + Icons.tune, + size: 20, + color: Colors.black87, + ), + onPressed: () { + showModalBottomSheet( + context: context, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: + BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (_) => UserDocumentFilterBottomSheet( + entityId: resolvedEntityId, + entityTypeId: entityTypeId, + ), + ); + }, + ), + ), + if (isFilterActive) + Positioned( + top: 6, + right: 6, + child: Container( + height: 8, + width: 8, + decoration: const BoxDecoration( + color: Colors.red, + shape: BoxShape.circle, + ), + ), + ), + ], + ); + }), + MySpacing.width(10), + + // ⋮ Menu (Show Inactive toggle) + Container( + height: 35, + width: 35, + decoration: BoxDecoration( + color: Colors.white, + border: Border.all(color: Colors.grey.shade300), + borderRadius: BorderRadius.circular(10), + ), + child: PopupMenuButton( + padding: EdgeInsets.zero, + icon: + const Icon(Icons.more_vert, size: 20, color: Colors.black87), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + itemBuilder: (context) => [ + const PopupMenuItem( + enabled: false, + height: 30, + child: Text( + "Preferences", + style: TextStyle( + fontWeight: FontWeight.bold, + color: Colors.grey, + ), + ), + ), + PopupMenuItem( + value: 0, + enabled: false, + child: Obx(() => Row( + children: [ + const Icon(Icons.visibility_off_outlined, + size: 20, color: Colors.black87), + const SizedBox(width: 10), + const Expanded(child: Text('Show Inactive')), + Switch.adaptive( + value: docController.showInactive.value, + activeColor: Colors.indigo, + onChanged: (val) { + docController.showInactive.value = val; + docController.fetchDocuments( + entityTypeId: entityTypeId, + entityId: resolvedEntityId, + reset: true, + ); + Navigator.pop(context); + }, + ), + ], + )), + ), + ], + ), + ), + ], ), ); } + Widget _buildStatusHeader() { + return Obx(() { + final isInactive = docController.showInactive.value; + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), + color: isInactive ? Colors.red.shade50 : Colors.green.shade50, + child: Row( + children: [ + Icon( + isInactive ? Icons.visibility_off : Icons.check_circle, + color: isInactive ? Colors.red : Colors.green, + size: 18, + ), + const SizedBox(width: 8), + Text( + isInactive + ? "Showing Inactive Documents" + : "Showing Active Documents", + style: TextStyle( + color: isInactive ? Colors.red : Colors.green, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ); + }); + } + Widget _buildBody(BuildContext context) { return Obx(() { if (docController.isLoading.value && docController.documents.isEmpty) { @@ -206,6 +468,10 @@ class _UserDocumentsPageState extends State { child: Column( children: [ _buildFilterRow(context), + + // 👇 Add this + _buildStatusHeader(), + Expanded( child: MyRefreshIndicator( onRefresh: () async {