feat: Enhance document management with delete/activate functionality, search, and inactive toggle

This commit is contained in:
Vaibhav Surve 2025-09-05 15:14:49 +05:30
parent 2133dedfae
commit e12e5ab13b
7 changed files with 548 additions and 57 deletions

View File

@ -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 = <DocumentItem>[].obs;
var filters = Rxn<DocumentFiltersData>();
// 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<bool> 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<bool> 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<void> 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;
}
}

View File

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

View File

@ -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<String?> 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<bool> 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<bool> 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<Map<String, dynamic>> tags = const [],
}) async {
final endpoint = "${ApiEndpoints.editDocument}/$id";
logSafe("Editing document with id: $id");
final Map<String, dynamic> 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<DocumentVersionsResponse?> 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",

View File

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

View File

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

View File

@ -73,7 +73,7 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
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<DocumentDetailsPage> {
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<DocumentDetailsPage> {
}
Future<void> _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,
);
}
}
}
}

View File

@ -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<UserDocumentsPage> {
],
),
),
IconButton(
icon: const Icon(Icons.arrow_forward_ios, color: Colors.black54),
onPressed: () {},
PopupMenuButton<String>(
icon: const Icon(Icons.more_vert, color: Colors.black54),
onSelected: (value) async {
if (value == "delete") {
final result = await showDialog<bool>(
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<UserDocumentsPage> {
}
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<TextEditingValue>(
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<int>(
padding: EdgeInsets.zero,
icon:
const Icon(Icons.more_vert, size: 20, color: Colors.black87),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
itemBuilder: (context) => [
const PopupMenuItem<int>(
enabled: false,
height: 30,
child: Text(
"Preferences",
style: TextStyle(
fontWeight: FontWeight.bold,
color: Colors.grey,
),
),
),
PopupMenuItem<int>(
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<UserDocumentsPage> {
child: Column(
children: [
_buildFilterRow(context),
// 👇 Add this
_buildStatusHeader(),
Expanded(
child: MyRefreshIndicator(
onRefresh: () async {