feat: Add document management features including document listing, details, and filtering
- Implemented DocumentsResponse and related models for handling document data. - Created UserDocumentsPage for displaying user-specific documents with filtering options. - Developed DocumentDetailsPage to show detailed information about a selected document. - Added functionality for uploading documents with DocumentUploadBottomSheet. - Integrated document filtering through UserDocumentFilterBottomSheet. - Enhanced dashboard to include navigation to the document management section. - Updated user profile right bar to provide quick access to user documents.
This commit is contained in:
parent
40a4a77af5
commit
334023bf1b
58
lib/controller/document/document_details_controller.dart
Normal file
58
lib/controller/document/document_details_controller.dart
Normal file
@ -0,0 +1,58 @@
|
||||
import 'package:get/get.dart';
|
||||
import 'package:marco/helpers/services/api_service.dart';
|
||||
import 'package:marco/model/document/document_details_model.dart';
|
||||
import 'package:marco/model/document/document_version_model.dart';
|
||||
|
||||
class DocumentDetailsController extends GetxController {
|
||||
/// Observables
|
||||
var isLoading = false.obs;
|
||||
var documentDetails = Rxn<DocumentDetailsResponse>();
|
||||
|
||||
var versions = <DocumentVersionItem>[].obs;
|
||||
var isVersionsLoading = false.obs;
|
||||
|
||||
/// Fetch document details by id
|
||||
Future<void> fetchDocumentDetails(String documentId) async {
|
||||
try {
|
||||
isLoading.value = true;
|
||||
final response = await ApiService.getDocumentDetailsApi(documentId);
|
||||
|
||||
if (response != null) {
|
||||
documentDetails.value = response;
|
||||
} else {
|
||||
documentDetails.value = null;
|
||||
}
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch document versions by parentAttachmentId
|
||||
Future<void> fetchDocumentVersions(String parentAttachmentId) async {
|
||||
try {
|
||||
isVersionsLoading.value = true;
|
||||
final response = await ApiService.getDocumentVersionsApi(
|
||||
parentAttachmentId: parentAttachmentId,
|
||||
);
|
||||
|
||||
if (response != null) {
|
||||
versions.assignAll(response.data.data);
|
||||
} else {
|
||||
versions.clear();
|
||||
}
|
||||
} finally {
|
||||
isVersionsLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch Pre-Signed URL for a given version
|
||||
Future<String?> fetchPresignedUrl(String versionId) async {
|
||||
return await ApiService.getPresignedUrlApi(versionId);
|
||||
}
|
||||
|
||||
/// Clear data when leaving the screen
|
||||
void clearDetails() {
|
||||
documentDetails.value = null;
|
||||
versions.clear();
|
||||
}
|
||||
}
|
191
lib/controller/document/document_upload_controller.dart
Normal file
191
lib/controller/document/document_upload_controller.dart
Normal file
@ -0,0 +1,191 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:marco/helpers/services/app_logger.dart';
|
||||
import 'package:marco/helpers/services/api_service.dart';
|
||||
import 'package:marco/model/document/master_document_type_model.dart';
|
||||
import 'package:marco/model/document/master_document_tags.dart';
|
||||
import 'package:marco/helpers/widgets/my_snackbar.dart';
|
||||
|
||||
class DocumentUploadController extends GetxController {
|
||||
// Observables
|
||||
var isLoading = false.obs;
|
||||
var isUploading = false.obs;
|
||||
|
||||
var categories = <DocumentType>[].obs;
|
||||
var tags = <TagItem>[].obs;
|
||||
|
||||
DocumentType? selectedCategory;
|
||||
|
||||
/// --- FILE HANDLING ---
|
||||
String? selectedFileName;
|
||||
String? selectedFileBase64;
|
||||
String? selectedFileContentType;
|
||||
int? selectedFileSize;
|
||||
|
||||
/// --- TAG HANDLING ---
|
||||
final tagCtrl = TextEditingController();
|
||||
final enteredTags = <String>[].obs;
|
||||
final filteredSuggestions = <String>[].obs;
|
||||
var documentTypes = <DocumentType>[].obs;
|
||||
DocumentType? selectedType;
|
||||
@override
|
||||
void onInit() {
|
||||
super.onInit();
|
||||
fetchCategories();
|
||||
fetchTags();
|
||||
}
|
||||
|
||||
/// Fetch available document categories
|
||||
Future<void> 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<void> fetchDocumentTypes(String categoryId) async {
|
||||
try {
|
||||
isLoading.value = true;
|
||||
final response =
|
||||
await ApiService.getDocumentTypesByCategoryApi(categoryId);
|
||||
if (response != null && response.data.isNotEmpty) {
|
||||
documentTypes.assignAll(response.data);
|
||||
selectedType = null; // reset previous type
|
||||
} else {
|
||||
documentTypes.clear();
|
||||
selectedType = null;
|
||||
}
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch available document tags
|
||||
Future<void> 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<bool> 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 ? "<base64-string-truncated>" : 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;
|
||||
}
|
||||
}
|
||||
}
|
98
lib/controller/document/user_document_controller.dart
Normal file
98
lib/controller/document/user_document_controller.dart
Normal file
@ -0,0 +1,98 @@
|
||||
import 'package:get/get.dart';
|
||||
import 'package:marco/helpers/services/api_service.dart';
|
||||
import 'package:marco/model/document/document_filter_model.dart';
|
||||
import 'package:marco/model/document/documents_list_model.dart';
|
||||
|
||||
class DocumentController extends GetxController {
|
||||
// ------------------ Observables ---------------------
|
||||
var isLoading = false.obs;
|
||||
var documents = <DocumentItem>[].obs;
|
||||
var filters = Rxn<DocumentFiltersData>();
|
||||
var selectedFilter = "".obs;
|
||||
var selectedUploadedBy = "".obs;
|
||||
var selectedCategory = "".obs;
|
||||
var selectedType = "".obs;
|
||||
var selectedTag = "".obs;
|
||||
// Pagination state
|
||||
var pageNumber = 1.obs;
|
||||
final int pageSize = 20;
|
||||
var hasMore = true.obs;
|
||||
|
||||
// Error message
|
||||
var errorMessage = "".obs;
|
||||
|
||||
// ------------------ API Calls -----------------------
|
||||
|
||||
/// Fetch Document Filters for an Entity
|
||||
Future<void> fetchFilters(String entityTypeId) async {
|
||||
try {
|
||||
isLoading.value = true;
|
||||
final response = await ApiService.getDocumentFilters(entityTypeId);
|
||||
|
||||
if (response != null && response.success) {
|
||||
filters.value = response.data;
|
||||
} else {
|
||||
errorMessage.value = response?.message ?? "Failed to fetch filters";
|
||||
}
|
||||
} catch (e) {
|
||||
errorMessage.value = "Error fetching filters: $e";
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch Documents for an entity
|
||||
Future<void> fetchDocuments({
|
||||
required String entityTypeId,
|
||||
required String entityId,
|
||||
String filter = "",
|
||||
String searchString = "",
|
||||
bool reset = false,
|
||||
}) async {
|
||||
try {
|
||||
if (reset) {
|
||||
pageNumber.value = 1;
|
||||
documents.clear();
|
||||
hasMore.value = true;
|
||||
}
|
||||
|
||||
if (!hasMore.value) return;
|
||||
|
||||
isLoading.value = true;
|
||||
|
||||
final response = await ApiService.getDocumentListApi(
|
||||
entityTypeId: entityTypeId,
|
||||
entityId: entityId,
|
||||
filter: filter,
|
||||
searchString: searchString,
|
||||
pageNumber: pageNumber.value,
|
||||
pageSize: pageSize,
|
||||
);
|
||||
|
||||
if (response != null && response.success) {
|
||||
if (response.data.data.isNotEmpty) {
|
||||
documents.addAll(response.data.data);
|
||||
pageNumber.value++;
|
||||
} else {
|
||||
hasMore.value = false;
|
||||
}
|
||||
} else {
|
||||
errorMessage.value = response?.message ?? "Failed to fetch documents";
|
||||
}
|
||||
} catch (e) {
|
||||
errorMessage.value = "Error fetching documents: $e";
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------ Helpers -----------------------
|
||||
|
||||
/// Clear selected filters
|
||||
void clearFilters() {
|
||||
selectedUploadedBy.value = "";
|
||||
selectedCategory.value = "";
|
||||
selectedType.value = "";
|
||||
selectedTag.value = "";
|
||||
}
|
||||
}
|
@ -1,15 +1,15 @@
|
||||
class ApiEndpoints {
|
||||
// static const String baseUrl = "https://stageapi.marcoaiot.com/api";
|
||||
static const String baseUrl = "https://api.marcoaiot.com/api";
|
||||
|
||||
// Dashboard Module API Endpoints
|
||||
static const String getDashboardAttendanceOverview = "/dashboard/attendance-overview";
|
||||
static const String baseUrl = "https://stageapi.marcoaiot.com/api";
|
||||
// static const String baseUrl = "https://api.marcoaiot.com/api";
|
||||
//
|
||||
// Dashboard Module API Endpoints
|
||||
static const String getDashboardAttendanceOverview =
|
||||
"/dashboard/attendance-overview";
|
||||
static const String getDashboardProjectProgress = "/dashboard/progression";
|
||||
static const String getDashboardTasks = "/dashboard/tasks";
|
||||
static const String getDashboardTeams = "/dashboard/teams";
|
||||
static const String getDashboardProjects = "/dashboard/projects";
|
||||
|
||||
|
||||
// Attendance Module API Endpoints
|
||||
static const String getProjects = "/project/list";
|
||||
static const String getGlobalProjects = "/project/list/basic";
|
||||
@ -45,7 +45,8 @@ class ApiEndpoints {
|
||||
static const String getDirectoryContacts = "/directory";
|
||||
static const String getDirectoryBucketList = "/directory/buckets";
|
||||
static const String getDirectoryContactDetail = "/directory/notes";
|
||||
static const String getDirectoryContactCategory = "/master/contact-categories";
|
||||
static const String getDirectoryContactCategory =
|
||||
"/master/contact-categories";
|
||||
static const String getDirectoryContactTags = "/master/contact-tags";
|
||||
static const String getDirectoryOrganization = "/directory/organization";
|
||||
static const String createContact = "/directory";
|
||||
@ -70,4 +71,17 @@ class ApiEndpoints {
|
||||
|
||||
////// Dynamic Menu Module API Endpoints
|
||||
static const String getDynamicMenu = "/appmenu/get/menu-mobile";
|
||||
|
||||
///// Document Module API Endpoints
|
||||
static const String getMasterDocumentCategories =
|
||||
"/master/document-category/list";
|
||||
static const String getMasterDocumentTags = "/document/get/tags";
|
||||
static const String getDocumentList = "/document/list";
|
||||
static const String getDocumentDetails = "/document/get/details";
|
||||
static const String uploadDocument = "/document/upload";
|
||||
static const String deleteDocument = "/document/delete";
|
||||
static const String getDocumentFilter = "/document/get/filter";
|
||||
static const String getDocumentTypesByCategory = "/master/document-type/list";
|
||||
static const String getDocumentVersion = "/document/get/version";
|
||||
static const String getDocumentVersions = "/document/list/versions";
|
||||
}
|
||||
|
@ -12,6 +12,13 @@ import 'package:marco/model/dashboard/project_progress_model.dart';
|
||||
import 'package:marco/model/dashboard/dashboard_tasks_model.dart';
|
||||
import 'package:marco/model/dashboard/dashboard_teams_model.dart';
|
||||
import 'package:marco/helpers/services/app_logger.dart';
|
||||
import 'package:marco/model/document/document_filter_model.dart';
|
||||
import 'package:marco/model/document/documents_list_model.dart';
|
||||
import 'package:marco/model/document/master_document_tags.dart';
|
||||
import 'package:marco/model/document/master_document_type_model.dart';
|
||||
import 'package:marco/model/document/document_details_model.dart';
|
||||
import 'package:marco/model/document/document_version_model.dart';
|
||||
|
||||
|
||||
class ApiService {
|
||||
static const Duration timeout = Duration(seconds: 30);
|
||||
@ -240,6 +247,340 @@ class ApiService {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
/// Get Pre-Signed URL for Old Version
|
||||
static Future<String?> getPresignedUrlApi(String versionId) async {
|
||||
final endpoint = "${ApiEndpoints.getDocumentVersion}/$versionId";
|
||||
logSafe("Fetching Pre-Signed URL for versionId: $versionId");
|
||||
|
||||
try {
|
||||
final response = await _getRequest(endpoint);
|
||||
|
||||
if (response == null) {
|
||||
logSafe("Pre-Signed URL request failed: null response",
|
||||
level: LogLevel.error);
|
||||
return null;
|
||||
}
|
||||
|
||||
final jsonResponse =
|
||||
_parseResponseForAllData(response, label: "Pre-Signed URL");
|
||||
|
||||
if (jsonResponse != null) {
|
||||
return jsonResponse['data'] as String?;
|
||||
}
|
||||
} catch (e, stack) {
|
||||
logSafe("Exception during getPresignedUrlApi: $e",
|
||||
level: LogLevel.error);
|
||||
logSafe("StackTrace: $stack", level: LogLevel.debug);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Get List of Versions by ParentAttachmentId
|
||||
static Future<DocumentVersionsResponse?> 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<DocumentDetailsResponse?> 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<DocumentTypesResponse?> 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<DocumentTypesResponse?> 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<bool> 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<Map<String, dynamic>> tags = const [],
|
||||
}) async {
|
||||
const endpoint = ApiEndpoints.uploadDocument;
|
||||
logSafe("Uploading document: $name for entity: $entityId");
|
||||
|
||||
final Map<String, dynamic> 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<TagResponse?> 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<DocumentsResponse?> 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<DocumentFiltersResponse?> 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 === //
|
||||
|
||||
|
@ -1,4 +1,4 @@
|
||||
/// Contains all role and permission UUIDs used for access control across the application.
|
||||
/// Contains all role, permission, and entity UUIDs used for access control across the application.
|
||||
class Permissions {
|
||||
// ------------------- Project Management ------------------------------
|
||||
/// Permission to manage master data (like dropdowns, configurations)
|
||||
@ -91,4 +91,11 @@ class Permissions {
|
||||
// ------------------- Application Roles -------------------------------
|
||||
/// Application role ID for users with full expense management rights
|
||||
static const String expenseManagement = "a4e25142-449b-4334-a6e5-22f70e4732d7";
|
||||
|
||||
// ------------------- Document Entities -------------------------------
|
||||
/// Entity ID for project documents
|
||||
static const String projectEntity = "c8fe7115-aa27-43bc-99f4-7b05fabe436e";
|
||||
|
||||
/// Entity ID for employee documents
|
||||
static const String employeeEntity = "dbb9555a-7a0c-40f2-a9ed-f0463f1ceed7";
|
||||
}
|
||||
|
89
lib/helpers/widgets/custom_app_bar.dart
Normal file
89
lib/helpers/widgets/custom_app_bar.dart
Normal file
@ -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<ProjectController>(
|
||||
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);
|
||||
}
|
@ -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(
|
||||
|
213
lib/model/document/document_details_model.dart
Normal file
213
lib/model/document/document_details_model.dart
Normal file
@ -0,0 +1,213 @@
|
||||
class DocumentDetailsResponse {
|
||||
final bool success;
|
||||
final String message;
|
||||
final DocumentDetails? data;
|
||||
final dynamic errors;
|
||||
final int statusCode;
|
||||
final DateTime timestamp;
|
||||
|
||||
DocumentDetailsResponse({
|
||||
required this.success,
|
||||
required this.message,
|
||||
required this.data,
|
||||
required this.errors,
|
||||
required this.statusCode,
|
||||
required this.timestamp,
|
||||
});
|
||||
|
||||
factory DocumentDetailsResponse.fromJson(Map<String, dynamic> 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<DocumentTag> 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<String, dynamic> 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<dynamic>?)
|
||||
?.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<String, dynamic> json) {
|
||||
return UploadedBy(
|
||||
id: json['id'] ?? '',
|
||||
firstName: json['firstName'] ?? '',
|
||||
lastName: json['lastName'],
|
||||
photo: json['photo'],
|
||||
jobRoleId: json['jobRoleId'] ?? '',
|
||||
jobRoleName: json['jobRoleName'] ?? '',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class DocumentType {
|
||||
final String id;
|
||||
final String name;
|
||||
final String? regexExpression; // nullable
|
||||
final String allowedContentType;
|
||||
final int maxSizeAllowedInMB;
|
||||
final bool isValidationRequired;
|
||||
final bool isMandatory;
|
||||
final bool isSystem;
|
||||
final bool isActive;
|
||||
final DocumentCategory? documentCategory; // nullable
|
||||
|
||||
DocumentType({
|
||||
required this.id,
|
||||
required this.name,
|
||||
this.regexExpression,
|
||||
required this.allowedContentType,
|
||||
required this.maxSizeAllowedInMB,
|
||||
required this.isValidationRequired,
|
||||
required this.isMandatory,
|
||||
required this.isSystem,
|
||||
required this.isActive,
|
||||
this.documentCategory,
|
||||
});
|
||||
|
||||
factory DocumentType.fromJson(Map<String, dynamic> json) {
|
||||
return DocumentType(
|
||||
id: json['id'] ?? '',
|
||||
name: json['name'] ?? '',
|
||||
regexExpression: json['regexExpression'],
|
||||
allowedContentType: json['allowedContentType'] ?? '',
|
||||
maxSizeAllowedInMB: json['maxSizeAllowedInMB'] ?? 0,
|
||||
isValidationRequired: json['isValidationRequired'] ?? false,
|
||||
isMandatory: json['isMandatory'] ?? false,
|
||||
isSystem: json['isSystem'] ?? false,
|
||||
isActive: json['isActive'] ?? false,
|
||||
documentCategory: json['documentCategory'] != null
|
||||
? DocumentCategory.fromJson(json['documentCategory'])
|
||||
: null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class DocumentCategory {
|
||||
final String id;
|
||||
final String name;
|
||||
final String? description;
|
||||
final String entityTypeId;
|
||||
|
||||
DocumentCategory({
|
||||
required this.id,
|
||||
required this.name,
|
||||
this.description,
|
||||
required this.entityTypeId,
|
||||
});
|
||||
|
||||
factory DocumentCategory.fromJson(Map<String, dynamic> json) {
|
||||
return DocumentCategory(
|
||||
id: json['id'] ?? '',
|
||||
name: json['name'] ?? '',
|
||||
description: json['description'],
|
||||
entityTypeId: json['entityTypeId'] ?? '',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class DocumentTag {
|
||||
final String name;
|
||||
final bool isActive;
|
||||
|
||||
DocumentTag({required this.name, required this.isActive});
|
||||
|
||||
factory DocumentTag.fromJson(Map<String, dynamic> json) {
|
||||
return DocumentTag(
|
||||
name: json['name'] ?? '',
|
||||
isActive: json['isActive'] ?? false,
|
||||
);
|
||||
}
|
||||
}
|
105
lib/model/document/document_filter_model.dart
Normal file
105
lib/model/document/document_filter_model.dart
Normal file
@ -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<String, dynamic> 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<String, dynamic> toJson() {
|
||||
return {
|
||||
"success": success,
|
||||
"message": message,
|
||||
"data": data?.toJson(),
|
||||
"errors": errors,
|
||||
"statusCode": statusCode,
|
||||
"timestamp": timestamp.toIso8601String(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class DocumentFiltersData {
|
||||
final List<FilterItem> uploadedBy;
|
||||
final List<FilterItem> documentCategory;
|
||||
final List<FilterItem> documentType;
|
||||
final List<FilterItem> documentTag;
|
||||
|
||||
DocumentFiltersData({
|
||||
required this.uploadedBy,
|
||||
required this.documentCategory,
|
||||
required this.documentType,
|
||||
required this.documentTag,
|
||||
});
|
||||
|
||||
factory DocumentFiltersData.fromJson(Map<String, dynamic> json) {
|
||||
return DocumentFiltersData(
|
||||
uploadedBy: (json['uploadedBy'] as List<dynamic>)
|
||||
.map((e) => FilterItem.fromJson(e))
|
||||
.toList(),
|
||||
documentCategory: (json['documentCategory'] as List<dynamic>)
|
||||
.map((e) => FilterItem.fromJson(e))
|
||||
.toList(),
|
||||
documentType: (json['documentType'] as List<dynamic>)
|
||||
.map((e) => FilterItem.fromJson(e))
|
||||
.toList(),
|
||||
documentTag: (json['documentTag'] as List<dynamic>)
|
||||
.map((e) => FilterItem.fromJson(e))
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> 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<String, dynamic> json) {
|
||||
return FilterItem(
|
||||
id: json['id'],
|
||||
name: json['name'],
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
"id": id,
|
||||
"name": name,
|
||||
};
|
||||
}
|
||||
}
|
773
lib/model/document/document_upload_bottom_sheet.dart
Normal file
773
lib/model/document/document_upload_bottom_sheet.dart
Normal file
@ -0,0 +1,773 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:marco/controller/document/document_upload_controller.dart';
|
||||
import 'package:marco/helpers/utils/base_bottom_sheet.dart';
|
||||
import 'package:marco/helpers/widgets/my_spacing.dart';
|
||||
import 'package:marco/helpers/widgets/my_text.dart';
|
||||
import 'package:marco/helpers/widgets/my_text_style.dart';
|
||||
import 'package:marco/helpers/widgets/image_viewer_dialog.dart';
|
||||
import 'package:marco/helpers/widgets/my_snackbar.dart';
|
||||
|
||||
class DocumentUploadBottomSheet extends StatefulWidget {
|
||||
final Function(Map<String, dynamic>) onSubmit;
|
||||
final Map<String, dynamic>? initialData;
|
||||
|
||||
const DocumentUploadBottomSheet({
|
||||
Key? key,
|
||||
required this.onSubmit,
|
||||
this.initialData,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<DocumentUploadBottomSheet> createState() =>
|
||||
_DocumentUploadBottomSheetState();
|
||||
}
|
||||
|
||||
class _DocumentUploadBottomSheetState extends State<DocumentUploadBottomSheet> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final controller = Get.put(DocumentUploadController());
|
||||
|
||||
final TextEditingController _docIdController = TextEditingController();
|
||||
final TextEditingController _docNameController = TextEditingController();
|
||||
final TextEditingController _descriptionController = TextEditingController();
|
||||
|
||||
File? selectedFile;
|
||||
String? existingFileName;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
// ✅ Pre-fill if editing
|
||||
if (widget.initialData != null) {
|
||||
final data = widget.initialData!;
|
||||
_docIdController.text = data["documentId"] ?? "";
|
||||
_docNameController.text = data["name"] ?? "";
|
||||
_descriptionController.text = data["description"] ?? "";
|
||||
|
||||
existingFileName = data["fileName"];
|
||||
|
||||
// Preselect category & type
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||
if (data["categoryName"] != null) {
|
||||
final category = controller.categories.firstWhereOrNull(
|
||||
(c) => c.name == data["categoryName"],
|
||||
);
|
||||
if (category != null) {
|
||||
setState(() => controller.selectedCategory = category);
|
||||
await controller.fetchDocumentTypes(category.id);
|
||||
|
||||
if (data["documentTypeName"] != null) {
|
||||
final type = controller.documentTypes.firstWhereOrNull(
|
||||
(t) => t.name == data["documentTypeName"],
|
||||
);
|
||||
if (type != null) {
|
||||
setState(() => controller.selectedType = type);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Prefill tags
|
||||
if (data["tags"] != null) {
|
||||
controller.enteredTags.value =
|
||||
List<String>.from(data["tags"].map((t) => t["name"]));
|
||||
}
|
||||
|
||||
// Prefill file info
|
||||
controller.selectedFileName = data["fileName"];
|
||||
controller.selectedFileContentType = data["contentType"];
|
||||
controller.selectedFileSize = data["fileSize"];
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_docIdController.dispose();
|
||||
_docNameController.dispose();
|
||||
_descriptionController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _handleSubmit() {
|
||||
if (!(_formKey.currentState?.validate() ?? false)) return;
|
||||
|
||||
if (selectedFile == null && existingFileName == null) {
|
||||
showAppSnackbar(
|
||||
title: "Error",
|
||||
message: "Please attach a document",
|
||||
type: SnackbarType.error,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// ✅ Validate file size
|
||||
final maxSizeMB = controller.selectedType?.maxSizeAllowedInMB;
|
||||
if (maxSizeMB != null && controller.selectedFileSize != null) {
|
||||
final fileSizeMB = controller.selectedFileSize! / (1024 * 1024);
|
||||
if (fileSizeMB > maxSizeMB) {
|
||||
showAppSnackbar(
|
||||
title: "Error",
|
||||
message: "File size exceeds $maxSizeMB MB limit",
|
||||
type: SnackbarType.error,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ Validate file type
|
||||
final allowedType = controller.selectedType?.allowedContentType;
|
||||
if (allowedType != null && controller.selectedFileContentType != null) {
|
||||
if (!allowedType
|
||||
.toLowerCase()
|
||||
.contains(controller.selectedFileContentType!.toLowerCase())) {
|
||||
showAppSnackbar(
|
||||
title: "Error",
|
||||
message: "Only $allowedType files are allowed for this type",
|
||||
type: SnackbarType.error,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
final payload = {
|
||||
"documentId": _docIdController.text.trim(),
|
||||
"name": _docNameController.text.trim(),
|
||||
"description": _descriptionController.text.trim(),
|
||||
"documentTypeId": controller.selectedType?.id,
|
||||
"attachment": {
|
||||
"fileName": controller.selectedFileName,
|
||||
"base64Data": controller.selectedFileBase64,
|
||||
"contentType": controller.selectedFileContentType,
|
||||
"fileSize": controller.selectedFileSize,
|
||||
"isActive": true,
|
||||
},
|
||||
"tags": controller.enteredTags
|
||||
.map((t) => {"name": t, "isActive": true})
|
||||
.toList(),
|
||||
};
|
||||
|
||||
widget.onSubmit(payload);
|
||||
showAppSnackbar(
|
||||
title: "Success",
|
||||
message: "Document submitted successfully",
|
||||
type: SnackbarType.success,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _pickFile() async {
|
||||
final result = await FilePicker.platform.pickFiles(
|
||||
type: FileType.custom,
|
||||
allowedExtensions: ['pdf', 'jpg', 'png', 'jpeg'],
|
||||
);
|
||||
|
||||
if (result != null && result.files.single.path != null) {
|
||||
final file = File(result.files.single.path!);
|
||||
final fileName = result.files.single.name;
|
||||
final fileBytes = await file.readAsBytes();
|
||||
final base64Data = base64Encode(fileBytes);
|
||||
|
||||
setState(() {
|
||||
selectedFile = file;
|
||||
existingFileName = null;
|
||||
controller.selectedFileName = fileName;
|
||||
controller.selectedFileBase64 = base64Data;
|
||||
controller.selectedFileContentType =
|
||||
result.files.single.extension?.toLowerCase() == "pdf"
|
||||
? "application/pdf"
|
||||
: "image/${result.files.single.extension?.toLowerCase()}";
|
||||
controller.selectedFileSize = (fileBytes.length / 1024).round();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BaseBottomSheet(
|
||||
title: widget.initialData == null ? "Upload Document" : "Edit Document",
|
||||
onCancel: () => Navigator.pop(context),
|
||||
onSubmit: _handleSubmit,
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
MySpacing.height(16),
|
||||
|
||||
/// Document ID
|
||||
LabeledInput(
|
||||
label: "Document ID",
|
||||
hint: "Enter Document ID",
|
||||
controller: _docIdController,
|
||||
validator: (value) =>
|
||||
value == null || value.trim().isEmpty ? "Required" : null,
|
||||
isRequired: true,
|
||||
),
|
||||
MySpacing.height(16),
|
||||
|
||||
/// Document Name
|
||||
LabeledInput(
|
||||
label: "Document Name",
|
||||
hint: "e.g., PAN Card",
|
||||
controller: _docNameController,
|
||||
validator: (value) =>
|
||||
value == null || value.trim().isEmpty ? "Required" : null,
|
||||
isRequired: true,
|
||||
),
|
||||
MySpacing.height(16),
|
||||
|
||||
/// Document Category
|
||||
Obx(() {
|
||||
if (controller.isLoading.value &&
|
||||
controller.categories.isEmpty) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
return LabeledDropdown(
|
||||
label: "Document Category",
|
||||
hint: "Select Category",
|
||||
value: controller.selectedCategory?.name,
|
||||
items: controller.categories.map((c) => c.name).toList(),
|
||||
onChanged: (selected) async {
|
||||
final category = controller.categories
|
||||
.firstWhere((c) => c.name == selected);
|
||||
setState(() => controller.selectedCategory = category);
|
||||
await controller.fetchDocumentTypes(category.id);
|
||||
},
|
||||
isRequired: true,
|
||||
);
|
||||
}),
|
||||
MySpacing.height(16),
|
||||
|
||||
/// Document Type
|
||||
Obx(() {
|
||||
if (controller.documentTypes.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return LabeledDropdown(
|
||||
label: "Document Type",
|
||||
hint: "Select Type",
|
||||
value: controller.selectedType?.name,
|
||||
items: controller.documentTypes.map((t) => t.name).toList(),
|
||||
onChanged: (selected) {
|
||||
final type = controller.documentTypes
|
||||
.firstWhere((t) => t.name == selected);
|
||||
setState(() => controller.selectedType = type);
|
||||
},
|
||||
isRequired: true,
|
||||
);
|
||||
}),
|
||||
MySpacing.height(24),
|
||||
|
||||
/// Single Attachment Section
|
||||
AttachmentSectionSingle(
|
||||
attachment: selectedFile,
|
||||
existingFileName: existingFileName,
|
||||
onPick: _pickFile,
|
||||
onRemove: () => setState(() {
|
||||
selectedFile = null;
|
||||
existingFileName = null;
|
||||
controller.selectedFileName = null;
|
||||
controller.selectedFileBase64 = null;
|
||||
controller.selectedFileContentType = null;
|
||||
controller.selectedFileSize = null;
|
||||
}),
|
||||
),
|
||||
|
||||
if (controller.selectedType?.maxSizeAllowedInMB != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Text(
|
||||
"Max file size: ${controller.selectedType!.maxSizeAllowedInMB} MB",
|
||||
style: const TextStyle(color: Colors.grey, fontSize: 12),
|
||||
),
|
||||
),
|
||||
MySpacing.height(16),
|
||||
|
||||
/// Tags
|
||||
MyText.labelMedium("Tags"),
|
||||
MySpacing.height(8),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 56,
|
||||
child: TextFormField(
|
||||
controller: controller.tagCtrl,
|
||||
onChanged: controller.filterSuggestions,
|
||||
onFieldSubmitted: (v) {
|
||||
controller.addEnteredTag(v);
|
||||
controller.tagCtrl.clear();
|
||||
controller.clearSuggestions();
|
||||
},
|
||||
decoration: InputDecoration(
|
||||
hintText: "Start typing to add tags",
|
||||
hintStyle: MyTextStyle.bodySmall(xMuted: true),
|
||||
filled: true,
|
||||
fillColor: Colors.grey.shade100,
|
||||
border: _inputBorder(),
|
||||
enabledBorder: _inputBorder(),
|
||||
focusedBorder: _inputFocusedBorder(),
|
||||
contentPadding: MySpacing.all(16),
|
||||
),
|
||||
),
|
||||
),
|
||||
Obx(() => controller.filteredSuggestions.isEmpty
|
||||
? const SizedBox.shrink()
|
||||
: Container(
|
||||
margin: const EdgeInsets.only(top: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
border: Border.all(color: Colors.grey.shade300),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
boxShadow: const [
|
||||
BoxShadow(color: Colors.black12, blurRadius: 4),
|
||||
],
|
||||
),
|
||||
child: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount: controller.filteredSuggestions.length,
|
||||
itemBuilder: (_, i) {
|
||||
final suggestion =
|
||||
controller.filteredSuggestions[i];
|
||||
return ListTile(
|
||||
dense: true,
|
||||
title: Text(suggestion),
|
||||
onTap: () {
|
||||
controller.addEnteredTag(suggestion);
|
||||
controller.tagCtrl.clear();
|
||||
controller.clearSuggestions();
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
)),
|
||||
MySpacing.height(8),
|
||||
Obx(() => Wrap(
|
||||
spacing: 8,
|
||||
children: controller.enteredTags
|
||||
.map((tag) => Chip(
|
||||
label: Text(tag),
|
||||
onDeleted: () =>
|
||||
controller.removeEnteredTag(tag),
|
||||
))
|
||||
.toList(),
|
||||
)),
|
||||
],
|
||||
),
|
||||
MySpacing.height(16),
|
||||
|
||||
/// Description
|
||||
LabeledInput(
|
||||
label: "Description",
|
||||
hint: "Enter short description",
|
||||
controller: _descriptionController,
|
||||
validator: (value) =>
|
||||
value == null || value.trim().isEmpty ? "Required" : null,
|
||||
isRequired: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
OutlineInputBorder _inputBorder() => OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(color: Colors.grey.shade300),
|
||||
);
|
||||
|
||||
OutlineInputBorder _inputFocusedBorder() => const OutlineInputBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(12)),
|
||||
borderSide: BorderSide(color: Colors.blueAccent, width: 1.5),
|
||||
);
|
||||
}
|
||||
|
||||
/// ---------------- Single Attachment Widget ----------------
|
||||
class AttachmentSectionSingle extends StatelessWidget {
|
||||
final File? attachment;
|
||||
final String? existingFileName; // ✅ new
|
||||
final VoidCallback onPick;
|
||||
final VoidCallback? onRemove;
|
||||
|
||||
const AttachmentSectionSingle({
|
||||
Key? key,
|
||||
this.attachment,
|
||||
this.existingFileName,
|
||||
required this.onPick,
|
||||
this.onRemove,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (attachment == null && existingFileName != null) {
|
||||
return Row(
|
||||
children: [
|
||||
const Icon(Icons.insert_drive_file, color: Colors.blueAccent),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(existingFileName!,
|
||||
style: const TextStyle(fontSize: 14, color: Colors.black87)),
|
||||
),
|
||||
if (onRemove != null)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close, color: Colors.red),
|
||||
onPressed: onRemove,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
final allowedImageExtensions = ['jpg', 'jpeg', 'png'];
|
||||
|
||||
Widget buildTile(File file) {
|
||||
final isImage = allowedImageExtensions
|
||||
.contains(file.path.split('.').last.toLowerCase());
|
||||
|
||||
final fileName = file.path.split('/').last;
|
||||
|
||||
IconData fileIcon = Icons.insert_drive_file;
|
||||
Color iconColor = Colors.blueGrey;
|
||||
|
||||
if (!isImage) {
|
||||
final ext = fileName.split('.').last.toLowerCase();
|
||||
switch (ext) {
|
||||
case 'pdf':
|
||||
fileIcon = Icons.picture_as_pdf;
|
||||
iconColor = Colors.redAccent;
|
||||
break;
|
||||
case 'doc':
|
||||
case 'docx':
|
||||
fileIcon = Icons.description;
|
||||
iconColor = Colors.blueAccent;
|
||||
break;
|
||||
case 'xls':
|
||||
case 'xlsx':
|
||||
fileIcon = Icons.table_chart;
|
||||
iconColor = Colors.green;
|
||||
break;
|
||||
case 'txt':
|
||||
fileIcon = Icons.article;
|
||||
iconColor = Colors.grey;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
if (isImage) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (_) => ImageViewerDialog(
|
||||
imageSources: [file],
|
||||
initialIndex: 0,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: Container(
|
||||
width: 100,
|
||||
height: 100,
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Colors.grey.shade300),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
color: Colors.grey.shade100,
|
||||
),
|
||||
child: isImage
|
||||
? ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Image.file(file, fit: BoxFit.cover),
|
||||
)
|
||||
: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(fileIcon, color: iconColor, size: 30),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
fileName.split('.').last.toUpperCase(),
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: iconColor),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (onRemove != null)
|
||||
Positioned(
|
||||
top: -6,
|
||||
right: -6,
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.close, color: Colors.red, size: 18),
|
||||
onPressed: onRemove,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: const [
|
||||
Text("Attachment", style: TextStyle(fontWeight: FontWeight.w600)),
|
||||
Text(" *",
|
||||
style:
|
||||
TextStyle(color: Colors.red, fontWeight: FontWeight.bold))
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
if (attachment != null)
|
||||
buildTile(attachment!)
|
||||
else
|
||||
GestureDetector(
|
||||
onTap: onPick,
|
||||
child: Container(
|
||||
width: 100,
|
||||
height: 100,
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Colors.grey.shade300),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
color: Colors.grey.shade100,
|
||||
),
|
||||
child: const Icon(Icons.add, size: 40, color: Colors.grey),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Reusable Widgets ----
|
||||
|
||||
class LabeledInput extends StatelessWidget {
|
||||
final String label;
|
||||
final String hint;
|
||||
final TextEditingController controller;
|
||||
final String? Function(String?) validator;
|
||||
final bool isRequired;
|
||||
|
||||
const LabeledInput({
|
||||
Key? key,
|
||||
required this.label,
|
||||
required this.hint,
|
||||
required this.controller,
|
||||
required this.validator,
|
||||
this.isRequired = false,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
MyText.labelMedium(label),
|
||||
if (isRequired)
|
||||
const Text(
|
||||
" *",
|
||||
style:
|
||||
TextStyle(color: Colors.red, fontWeight: FontWeight.bold),
|
||||
),
|
||||
],
|
||||
),
|
||||
MySpacing.height(8),
|
||||
TextFormField(
|
||||
controller: controller,
|
||||
validator: validator,
|
||||
decoration: _inputDecoration(context, hint),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
InputDecoration _inputDecoration(BuildContext context, String hint) =>
|
||||
InputDecoration(
|
||||
hintText: hint,
|
||||
hintStyle: MyTextStyle.bodySmall(xMuted: true),
|
||||
filled: true,
|
||||
fillColor: Colors.grey.shade100,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(color: Colors.grey.shade300),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(color: Colors.grey.shade300),
|
||||
),
|
||||
focusedBorder: const OutlineInputBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(12)),
|
||||
borderSide: BorderSide(color: Colors.blueAccent, width: 1.5),
|
||||
),
|
||||
contentPadding: MySpacing.all(16),
|
||||
);
|
||||
}
|
||||
|
||||
class LabeledDropdown extends StatefulWidget {
|
||||
final String label;
|
||||
final String hint;
|
||||
final String? value;
|
||||
final List<String> items;
|
||||
final ValueChanged<String> 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<LabeledDropdown> createState() => _LabeledDropdownState();
|
||||
}
|
||||
|
||||
class _LabeledDropdownState extends State<LabeledDropdown> {
|
||||
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<String>(
|
||||
context: context,
|
||||
position: position,
|
||||
items: widget.items
|
||||
.map((item) => PopupMenuItem<String>(
|
||||
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"),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
138
lib/model/document/document_version_model.dart
Normal file
138
lib/model/document/document_version_model.dart
Normal file
@ -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<String, dynamic> 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<DocumentVersionItem> data;
|
||||
|
||||
VersionDataWrapper({
|
||||
required this.currentPage,
|
||||
required this.totalPages,
|
||||
required this.totalEntites,
|
||||
required this.data,
|
||||
});
|
||||
|
||||
factory VersionDataWrapper.fromJson(Map<String, dynamic> json) {
|
||||
return VersionDataWrapper(
|
||||
currentPage: json['currentPage'] ?? 1,
|
||||
totalPages: json['totalPages'] ?? 1,
|
||||
totalEntites: json['totalEntites'] ?? 0,
|
||||
data: (json['data'] as List<dynamic>?)
|
||||
?.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<String, dynamic> 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<String, dynamic> json) {
|
||||
return UserInfo(
|
||||
id: json['id'] ?? "",
|
||||
firstName: json['firstName'] ?? "",
|
||||
lastName: json['lastName'] ?? "",
|
||||
photo: json['photo'] ?? "",
|
||||
jobRoleId: json['jobRoleId'] ?? "",
|
||||
jobRoleName: json['jobRoleName'] ?? "",
|
||||
);
|
||||
}
|
||||
}
|
273
lib/model/document/documents_list_model.dart
Normal file
273
lib/model/document/documents_list_model.dart
Normal file
@ -0,0 +1,273 @@
|
||||
class DocumentsResponse {
|
||||
final bool success;
|
||||
final String message;
|
||||
final DocumentDataWrapper data;
|
||||
final dynamic errors;
|
||||
final int statusCode;
|
||||
final DateTime timestamp;
|
||||
|
||||
DocumentsResponse({
|
||||
required this.success,
|
||||
required this.message,
|
||||
required this.data,
|
||||
this.errors,
|
||||
required this.statusCode,
|
||||
required this.timestamp,
|
||||
});
|
||||
|
||||
factory DocumentsResponse.fromJson(Map<String, dynamic> 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<String, dynamic> 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<DocumentItem> data;
|
||||
|
||||
DocumentDataWrapper({
|
||||
this.currentFilter,
|
||||
required this.currentPage,
|
||||
required this.totalPages,
|
||||
required this.totalEntites,
|
||||
required this.data,
|
||||
});
|
||||
|
||||
factory DocumentDataWrapper.fromJson(Map<String, dynamic> json) {
|
||||
return DocumentDataWrapper(
|
||||
currentFilter: json['currentFilter'],
|
||||
currentPage: json['currentPage'] ?? 0,
|
||||
totalPages: json['totalPages'] ?? 0,
|
||||
totalEntites: json['totalEntites'] ?? 0,
|
||||
data: (json['data'] as List<dynamic>? ?? [])
|
||||
.map((e) => DocumentItem.fromJson(e))
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> 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<String, dynamic> 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<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'name': name,
|
||||
'documentId': documentId,
|
||||
'description': description,
|
||||
'uploadedAt': uploadedAt.toIso8601String(),
|
||||
'parentAttachmentId': parentAttachmentId,
|
||||
'isCurrentVersion': isCurrentVersion,
|
||||
'version': version,
|
||||
'isActive': isActive,
|
||||
'isVerified': isVerified,
|
||||
'uploadedBy': uploadedBy.toJson(),
|
||||
'documentType': documentType.toJson(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class UploadedBy {
|
||||
final String id;
|
||||
final String firstName;
|
||||
final String? lastName;
|
||||
final String? photo;
|
||||
final String jobRoleId;
|
||||
final String jobRoleName;
|
||||
|
||||
UploadedBy({
|
||||
required this.id,
|
||||
required this.firstName,
|
||||
this.lastName,
|
||||
this.photo,
|
||||
required this.jobRoleId,
|
||||
required this.jobRoleName,
|
||||
});
|
||||
|
||||
factory UploadedBy.fromJson(Map<String, dynamic> json) {
|
||||
return UploadedBy(
|
||||
id: json['id'] ?? '',
|
||||
firstName: json['firstName'] ?? '',
|
||||
lastName: json['lastName'],
|
||||
photo: json['photo'],
|
||||
jobRoleId: json['jobRoleId'] ?? '',
|
||||
jobRoleName: json['jobRoleName'] ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> json) {
|
||||
return DocumentCategory(
|
||||
id: json['id'] ?? '',
|
||||
name: json['name'] ?? '',
|
||||
description: json['description'],
|
||||
entityTypeId: json['entityTypeId'],
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'name': name,
|
||||
'description': description,
|
||||
'entityTypeId': entityTypeId,
|
||||
};
|
||||
}
|
||||
}
|
31
lib/model/document/master_document_category_list_model.dart
Normal file
31
lib/model/document/master_document_category_list_model.dart
Normal file
@ -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<String, dynamic> json) {
|
||||
return MasterDocumentCategoryListModel(
|
||||
id: json['id'] as String,
|
||||
name: json['name'] as String,
|
||||
description: json['description'] as String,
|
||||
entityTypeId: json['entityTypeId'] as String,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'name': name,
|
||||
'description': description,
|
||||
'entityTypeId': entityTypeId,
|
||||
};
|
||||
}
|
||||
}
|
69
lib/model/document/master_document_tags.dart
Normal file
69
lib/model/document/master_document_tags.dart
Normal file
@ -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<String, dynamic> json) {
|
||||
return TagItem(
|
||||
id: json['id'] ?? '',
|
||||
name: json['name'] ?? '',
|
||||
isActive: json['isActive'] ?? true,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'name': name,
|
||||
'isActive': isActive,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class TagResponse {
|
||||
final bool success;
|
||||
final String message;
|
||||
final List<TagItem> 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<String, dynamic> json) {
|
||||
return TagResponse(
|
||||
success: json['success'] ?? false,
|
||||
message: json['message'] ?? '',
|
||||
data: (json['data'] as List<dynamic>? ?? [])
|
||||
.map((item) => TagItem.fromJson(item))
|
||||
.toList(),
|
||||
errors: json['errors'],
|
||||
statusCode: json['statusCode'] ?? 0,
|
||||
timestamp: DateTime.tryParse(json['timestamp'] ?? '') ?? DateTime.now(),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'success': success,
|
||||
'message': message,
|
||||
'data': data.map((e) => e.toJson()).toList(),
|
||||
'errors': errors,
|
||||
'statusCode': statusCode,
|
||||
'timestamp': timestamp.toIso8601String(),
|
||||
};
|
||||
}
|
||||
}
|
94
lib/model/document/master_document_type_model.dart
Normal file
94
lib/model/document/master_document_type_model.dart
Normal file
@ -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<String, dynamic> 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<String, dynamic> 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<DocumentType> data;
|
||||
|
||||
DocumentTypesResponse({
|
||||
required this.success,
|
||||
required this.data,
|
||||
this.message,
|
||||
});
|
||||
|
||||
factory DocumentTypesResponse.fromJson(Map<String, dynamic> json) {
|
||||
return DocumentTypesResponse(
|
||||
success: json['success'] ?? false,
|
||||
message: json['message'],
|
||||
data: (json['data'] as List<dynamic>?)
|
||||
?.map((item) => DocumentType.fromJson(item))
|
||||
.toList() ??
|
||||
[],
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'success': success,
|
||||
'message': message,
|
||||
'data': data.map((e) => e.toJson()).toList(),
|
||||
};
|
||||
}
|
||||
}
|
179
lib/model/document/user_document_filter_bottom_sheet.dart
Normal file
179
lib/model/document/user_document_filter_bottom_sheet.dart
Normal file
@ -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<DocumentController>();
|
||||
|
||||
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<Widget>().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<FilterItem> 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<FilterItem> items,
|
||||
String fallback, {
|
||||
required RxString selectedValue,
|
||||
}) {
|
||||
return Obx(() {
|
||||
final currentValue = _getCurrentName(selectedValue.value, items, fallback);
|
||||
return PopupMenuButton<String>(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
onSelected: (val) => selectedValue.value = val,
|
||||
itemBuilder: (context) => items
|
||||
.map(
|
||||
(f) => PopupMenuItem<String>(
|
||||
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<FilterItem> list, String fallback) {
|
||||
if (selectedId.isEmpty) return fallback;
|
||||
final match = list.firstWhereOrNull((f) => f.id == selectedId);
|
||||
return match?.name ?? fallback;
|
||||
}
|
||||
}
|
@ -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()),
|
||||
|
@ -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<DashboardScreen> createState() => _DashboardScreenState();
|
||||
@ -79,7 +80,7 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
|
||||
*/
|
||||
_buildDashboardStats(context),
|
||||
MySpacing.height(24),
|
||||
SizedBox(
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: DashboardOverviewWidgets.teamsOverview(),
|
||||
),
|
||||
@ -267,6 +268,8 @@ class _DashboardScreenState extends State<DashboardScreen> 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<ProjectController>();
|
||||
@ -277,10 +280,16 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
|
||||
children: [
|
||||
if (!isProjectSelected) _buildNoProjectMessage(),
|
||||
Wrap(
|
||||
spacing: 6, // horizontal spacing
|
||||
runSpacing: 6, // vertical spacing
|
||||
spacing: 6,
|
||||
runSpacing: 6,
|
||||
children: stats
|
||||
.where((stat) => menuController.isMenuAllowed(stat.title))
|
||||
.where((stat) {
|
||||
// ✅ Always allow Documents
|
||||
if (stat.title == "Documents") return true;
|
||||
|
||||
// For all other menus, respect sidebar permissions
|
||||
return menuController.isMenuAllowed(stat.title);
|
||||
})
|
||||
.map((stat) => _buildStatCard(stat, isProjectSelected))
|
||||
.toList(),
|
||||
),
|
||||
@ -290,49 +299,70 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
|
||||
}
|
||||
|
||||
/// Stat Card (Compact with wrapping text)
|
||||
Widget _buildStatCard(_StatItem statItem, bool isEnabled) {
|
||||
const double cardWidth = 80;
|
||||
const double cardHeight = 70;
|
||||
/// Stat Card (Compact with wrapping text)
|
||||
Widget _buildStatCard(_StatItem statItem, bool isProjectSelected) {
|
||||
const double cardWidth = 80;
|
||||
const double cardHeight = 70;
|
||||
|
||||
return Opacity(
|
||||
opacity: isEnabled ? 1.0 : 0.4,
|
||||
child: IgnorePointer(
|
||||
ignoring: !isEnabled,
|
||||
child: InkWell(
|
||||
onTap: () => _handleStatCardTap(statItem, isEnabled),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: MyCard.bordered(
|
||||
width: cardWidth,
|
||||
height: cardHeight,
|
||||
paddingAll: 4,
|
||||
borderRadiusAll: 8,
|
||||
border: Border.all(color: Colors.grey.withOpacity(0.15)),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
_buildStatCardIconCompact(statItem),
|
||||
MySpacing.height(4),
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: Text(
|
||||
statItem.title,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontSize: 10,
|
||||
overflow: TextOverflow.visible,
|
||||
),
|
||||
maxLines: 2,
|
||||
softWrap: true,
|
||||
// ✅ Attendance should always be enabled
|
||||
final bool isEnabled = statItem.title == "Attendance" || isProjectSelected;
|
||||
|
||||
return Opacity(
|
||||
opacity: isEnabled ? 1.0 : 0.4,
|
||||
child: IgnorePointer(
|
||||
ignoring: !isEnabled,
|
||||
child: InkWell(
|
||||
onTap: () => _handleStatCardTap(statItem, isEnabled),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: MyCard.bordered(
|
||||
width: cardWidth,
|
||||
height: cardHeight,
|
||||
paddingAll: 4,
|
||||
borderRadiusAll: 8,
|
||||
border: Border.all(color: Colors.grey.withOpacity(0.15)),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
_buildStatCardIconCompact(statItem),
|
||||
MySpacing.height(4),
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: Text(
|
||||
statItem.title,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontSize: 10,
|
||||
overflow: TextOverflow.visible,
|
||||
),
|
||||
maxLines: 2,
|
||||
softWrap: true,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Handle Tap
|
||||
void _handleStatCardTap(_StatItem statItem, bool isEnabled) {
|
||||
if (!isEnabled) {
|
||||
Get.defaultDialog(
|
||||
title: "No Project Selected",
|
||||
middleText: "You need to select a project before accessing this section.",
|
||||
confirm: ElevatedButton(
|
||||
onPressed: () => Get.back(),
|
||||
child: const Text("OK"),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
Get.toNamed(statItem.route);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Compact Icon
|
||||
Widget _buildStatCardIconCompact(_StatItem statItem) {
|
||||
@ -348,21 +378,7 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
|
||||
}
|
||||
|
||||
/// Handle Tap
|
||||
void _handleStatCardTap(_StatItem statItem, bool isEnabled) {
|
||||
if (!isEnabled) {
|
||||
Get.defaultDialog(
|
||||
title: "No Project Selected",
|
||||
middleText:
|
||||
"You need to select a project before accessing this section.",
|
||||
confirm: ElevatedButton(
|
||||
onPressed: () => Get.back(),
|
||||
child: const Text("OK"),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
Get.toNamed(statItem.route);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class _StatItem {
|
||||
|
287
lib/view/document/document_details_page.dart
Normal file
287
lib/view/document/document_details_page.dart
Normal file
@ -0,0 +1,287 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:marco/helpers/widgets/my_text.dart';
|
||||
import 'package:marco/helpers/widgets/my_spacing.dart';
|
||||
import 'package:marco/helpers/widgets/my_custom_skeleton.dart';
|
||||
import 'package:marco/helpers/widgets/my_refresh_indicator.dart';
|
||||
import 'package:marco/model/document/document_details_model.dart';
|
||||
import 'package:marco/controller/document/document_details_controller.dart';
|
||||
import 'package:marco/helpers/widgets/custom_app_bar.dart';
|
||||
import 'package:marco/helpers/widgets/my_snackbar.dart';
|
||||
|
||||
class DocumentDetailsPage extends StatefulWidget {
|
||||
final String documentId;
|
||||
|
||||
const DocumentDetailsPage({super.key, required this.documentId});
|
||||
|
||||
@override
|
||||
State<DocumentDetailsPage> createState() => _DocumentDetailsPageState();
|
||||
}
|
||||
|
||||
class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
|
||||
final DocumentDetailsController controller =
|
||||
Get.put(DocumentDetailsController());
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_fetchDetails();
|
||||
}
|
||||
|
||||
Future<void> _fetchDetails() async {
|
||||
await controller.fetchDocumentDetails(widget.documentId);
|
||||
final parentId = controller.documentDetails.value?.data?.parentAttachmentId;
|
||||
if (parentId != null && parentId.isNotEmpty) {
|
||||
await controller.fetchDocumentVersions(parentId);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onRefresh() async {
|
||||
await _fetchDetails();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFFF1F1F1),
|
||||
appBar: CustomAppBar(
|
||||
title: 'Document Details',
|
||||
onBackPressed: () {
|
||||
Get.back();
|
||||
},
|
||||
),
|
||||
body: Obx(() {
|
||||
if (controller.isLoading.value) {
|
||||
return SkeletonLoaders.documentDetailsSkeletonLoader();
|
||||
}
|
||||
|
||||
final docResponse = controller.documentDetails.value;
|
||||
if (docResponse == null || docResponse.data == null) {
|
||||
return Center(
|
||||
child: MyText.bodyMedium(
|
||||
"Failed to load document details.",
|
||||
color: Colors.grey,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final doc = docResponse.data!;
|
||||
|
||||
return MyRefreshIndicator(
|
||||
onRefresh: _onRefresh,
|
||||
child: SingleChildScrollView(
|
||||
physics:
|
||||
const AlwaysScrollableScrollPhysics(), // ensures pull works
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildDetailsCard(doc),
|
||||
const SizedBox(height: 20),
|
||||
MyText.titleMedium("Versions",
|
||||
fontWeight: 700, color: Colors.black),
|
||||
const SizedBox(height: 10),
|
||||
_buildVersionsSection(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/// ---------------- DOCUMENT DETAILS CARD ----------------
|
||||
Widget _buildDetailsCard(DocumentDetails doc) {
|
||||
final uploadDate =
|
||||
DateFormat("dd MMM yyyy, hh:mm a").format(doc.uploadedAt.toLocal());
|
||||
final updateDate = doc.updatedAt != null
|
||||
? DateFormat("dd MMM yyyy, hh:mm a").format(doc.updatedAt!.toLocal())
|
||||
: "-";
|
||||
|
||||
return Container(
|
||||
constraints: const BoxConstraints(maxWidth: 460),
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.06),
|
||||
blurRadius: 16,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Header
|
||||
Row(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
backgroundColor: Colors.blue.shade50,
|
||||
radius: 28,
|
||||
child:
|
||||
const Icon(Icons.description, color: Colors.blue, size: 28),
|
||||
),
|
||||
MySpacing.width(16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
MyText.titleLarge(doc.name,
|
||||
fontWeight: 700, color: Colors.black),
|
||||
MyText.bodySmall(
|
||||
doc.documentType.name,
|
||||
color: Colors.blueGrey,
|
||||
fontWeight: 600,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
MySpacing.height(12),
|
||||
|
||||
// Tags
|
||||
if (doc.tags.isNotEmpty)
|
||||
Wrap(
|
||||
children: doc.tags.map((t) => _buildTagChip(t.name)).toList(),
|
||||
),
|
||||
MySpacing.height(16),
|
||||
|
||||
// Info rows
|
||||
_buildDetailRow("Document ID", doc.documentId),
|
||||
_buildDetailRow("Description", doc.description ?? "-"),
|
||||
_buildDetailRow(
|
||||
"Category", doc.documentType.documentCategory?.name ?? "-"),
|
||||
_buildDetailRow("Version", "v${doc.version}"),
|
||||
_buildDetailRow(
|
||||
"Current Version", doc.isCurrentVersion ? "Yes" : "No"),
|
||||
_buildDetailRow("Verified",
|
||||
doc.isVerified == null ? '-' : (doc.isVerified! ? "Yes" : "No")),
|
||||
_buildDetailRow("Uploaded By",
|
||||
"${doc.uploadedBy.firstName} ${doc.uploadedBy.lastName}"),
|
||||
_buildDetailRow("Uploaded On", uploadDate),
|
||||
if (doc.updatedAt != null)
|
||||
_buildDetailRow("Last Updated On", updateDate),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// ---------------- VERSIONS SECTION ----------------
|
||||
Widget _buildVersionsSection() {
|
||||
return Obx(() {
|
||||
if (controller.isVersionsLoading.value) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
if (controller.versions.isEmpty) {
|
||||
return MyText.bodySmall("No versions found", color: Colors.grey);
|
||||
}
|
||||
|
||||
final sorted = [...controller.versions];
|
||||
sorted.sort((a, b) => b.uploadedAt.compareTo(a.uploadedAt));
|
||||
|
||||
return ListView.separated(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemCount: sorted.length,
|
||||
separatorBuilder: (_, __) => Divider(height: 1),
|
||||
itemBuilder: (context, index) {
|
||||
final version = sorted[index];
|
||||
final uploadDate =
|
||||
DateFormat("dd MMM yyyy, hh:mm a").format(version.uploadedAt);
|
||||
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.description, color: Colors.blue),
|
||||
title: MyText.bodyMedium(
|
||||
"${version.name} (v${version.version})",
|
||||
fontWeight: 600,
|
||||
color: Colors.black,
|
||||
),
|
||||
subtitle: MyText.bodySmall(
|
||||
"Uploaded by ${version.uploadedBy.firstName} ${version.uploadedBy.lastName} • $uploadDate",
|
||||
color: Colors.grey.shade600,
|
||||
),
|
||||
trailing: IconButton(
|
||||
icon: const Icon(Icons.open_in_new, color: Colors.blue),
|
||||
onPressed: () async {
|
||||
final url = await controller.fetchPresignedUrl(version.id);
|
||||
if (url != null) {
|
||||
_openDocument(url);
|
||||
} else {
|
||||
showAppSnackbar(
|
||||
title: "Error",
|
||||
message: "Failed to fetch document link",
|
||||
type: SnackbarType.error,
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// ---------------- HELPERS ----------------
|
||||
Widget _buildTagChip(String label) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
margin: const EdgeInsets.only(right: 6, bottom: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.blue.shade100,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: MyText.bodySmall(
|
||||
label,
|
||||
color: Colors.blue.shade900,
|
||||
fontWeight: 600,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDetailRow(String title, String value) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 120,
|
||||
child: MyText.bodySmall(
|
||||
"$title:",
|
||||
fontWeight: 600,
|
||||
color: Colors.grey.shade800,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: MyText.bodySmall(
|
||||
value,
|
||||
color: Colors.grey.shade600,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
0
lib/view/document/project_document_screen.dart
Normal file
0
lib/view/document/project_document_screen.dart
Normal file
317
lib/view/document/user_document_screen.dart
Normal file
317
lib/view/document/user_document_screen.dart
Normal file
@ -0,0 +1,317 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:marco/controller/document/user_document_controller.dart';
|
||||
import 'package:marco/controller/project_controller.dart';
|
||||
import 'package:marco/helpers/widgets/my_spacing.dart';
|
||||
import 'package:marco/helpers/widgets/my_text.dart';
|
||||
import 'package:marco/helpers/widgets/my_refresh_indicator.dart';
|
||||
import 'package:marco/helpers/utils/permission_constants.dart';
|
||||
import 'package:marco/model/document/user_document_filter_bottom_sheet.dart';
|
||||
import 'package:marco/model/document/documents_list_model.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:marco/helpers/widgets/my_custom_skeleton.dart';
|
||||
import 'package:marco/model/document/document_upload_bottom_sheet.dart';
|
||||
import 'package:marco/controller/document/document_upload_controller.dart';
|
||||
import 'package:marco/view/document/document_details_page.dart';
|
||||
import 'package:marco/helpers/widgets/custom_app_bar.dart';
|
||||
|
||||
class UserDocumentsPage extends StatefulWidget {
|
||||
final String? entityId;
|
||||
final bool isEmployee;
|
||||
|
||||
const UserDocumentsPage({
|
||||
super.key,
|
||||
this.entityId,
|
||||
this.isEmployee = false,
|
||||
});
|
||||
|
||||
@override
|
||||
State<UserDocumentsPage> createState() => _UserDocumentsPageState();
|
||||
}
|
||||
|
||||
class _UserDocumentsPageState extends State<UserDocumentsPage> {
|
||||
final DocumentController docController = Get.put(DocumentController());
|
||||
|
||||
String get entityTypeId => widget.isEmployee
|
||||
? Permissions.employeeEntity
|
||||
: Permissions.projectEntity;
|
||||
|
||||
String get resolvedEntityId => widget.isEmployee
|
||||
? widget.entityId ?? ""
|
||||
: Get.find<ProjectController>().selectedProject?.id ?? "";
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
docController.fetchFilters(entityTypeId);
|
||||
docController.fetchDocuments(
|
||||
entityTypeId: entityTypeId,
|
||||
entityId: resolvedEntityId,
|
||||
reset: true,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
docController.documents.clear();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Widget _buildDocumentTile(DocumentItem doc) {
|
||||
final uploadDate =
|
||||
DateFormat("dd MMM yyyy").format(doc.uploadedAt.toLocal());
|
||||
|
||||
final uploader = doc.uploadedBy.firstName.isNotEmpty
|
||||
? "Added by ${doc.uploadedBy.firstName} ${doc.uploadedBy.lastName}"
|
||||
.trim()
|
||||
: "Added by you";
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
|
||||
child: MyText.bodySmall(
|
||||
uploadDate,
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
InkWell(
|
||||
onTap: () {
|
||||
// 👉 Navigate to details page
|
||||
Get.to(() => DocumentDetailsPage(documentId: doc.id));
|
||||
},
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.blue.shade50,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Icon(Icons.description, color: Colors.blue),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
MyText.bodySmall(
|
||||
doc.documentType.name,
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
color: Colors.grey,
|
||||
),
|
||||
MySpacing.height(2),
|
||||
MyText.bodyMedium(
|
||||
doc.name,
|
||||
fontSize: 15,
|
||||
fontWeight: 600,
|
||||
color: Colors.black,
|
||||
),
|
||||
MySpacing.height(2),
|
||||
MyText.bodySmall(
|
||||
uploader,
|
||||
fontSize: 13,
|
||||
color: Colors.grey,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.arrow_forward_ios, color: Colors.black54),
|
||||
onPressed: () {/* future actions */},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmptyState() {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.inbox_outlined, size: 60, color: Colors.grey),
|
||||
MySpacing.height(18),
|
||||
MyText.titleMedium(
|
||||
'No documents found.',
|
||||
fontWeight: 600,
|
||||
color: Colors.grey,
|
||||
),
|
||||
MySpacing.height(10),
|
||||
MyText.bodySmall(
|
||||
'Try adjusting your filters or refresh to reload.',
|
||||
color: Colors.grey,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFilterRow(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
alignment: Alignment.centerRight,
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.tune, color: Colors.black),
|
||||
onPressed: () {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (_) => UserDocumentFilterBottomSheet(
|
||||
entityId: resolvedEntityId,
|
||||
entityTypeId: entityTypeId,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBody(BuildContext context) {
|
||||
return Obx(() {
|
||||
if (docController.isLoading.value && docController.documents.isEmpty) {
|
||||
return SingleChildScrollView(
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
child: SkeletonLoaders.documentSkeletonLoader(),
|
||||
);
|
||||
}
|
||||
|
||||
final docs = docController.documents;
|
||||
return SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
_buildFilterRow(context),
|
||||
Expanded(
|
||||
child: MyRefreshIndicator(
|
||||
onRefresh: () async {
|
||||
await docController.fetchDocuments(
|
||||
entityTypeId: entityTypeId,
|
||||
entityId: resolvedEntityId,
|
||||
filter: docController.selectedFilter.value,
|
||||
reset: true,
|
||||
);
|
||||
},
|
||||
child: ListView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
padding: docs.isEmpty
|
||||
? null
|
||||
: const EdgeInsets.fromLTRB(0, 0, 0, 80),
|
||||
children: docs.isEmpty
|
||||
? [
|
||||
SizedBox(
|
||||
height: MediaQuery.of(context).size.height * 0.6,
|
||||
child: _buildEmptyState(),
|
||||
),
|
||||
]
|
||||
: [
|
||||
...docs.map(_buildDocumentTile),
|
||||
if (docController.isLoading.value)
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(12),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
if (!docController.hasMore.value)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Center(
|
||||
child: MyText.bodySmall(
|
||||
"No more documents",
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFFF1F1F1),
|
||||
appBar: CustomAppBar(
|
||||
title: 'Documents',
|
||||
onBackPressed: () {
|
||||
Get.back();
|
||||
},
|
||||
),
|
||||
body: _buildBody(context),
|
||||
floatingActionButton: FloatingActionButton.extended(
|
||||
onPressed: () {
|
||||
final uploadController = Get.put(DocumentUploadController());
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (_) => DocumentUploadBottomSheet(
|
||||
onSubmit: (data) async {
|
||||
final success = await uploadController.uploadDocument(
|
||||
name: data["name"],
|
||||
description: data["description"],
|
||||
documentId: data["documentId"],
|
||||
entityId: resolvedEntityId,
|
||||
documentTypeId: data["documentTypeId"],
|
||||
fileName: data["attachment"]["fileName"],
|
||||
base64Data: data["attachment"]["base64Data"],
|
||||
contentType: data["attachment"]["contentType"],
|
||||
fileSize: data["attachment"]["fileSize"],
|
||||
);
|
||||
|
||||
if (success) {
|
||||
// ✅ Only close on success
|
||||
Navigator.pop(context);
|
||||
|
||||
// Refresh list
|
||||
docController.fetchDocuments(
|
||||
entityTypeId: entityTypeId,
|
||||
entityId: resolvedEntityId,
|
||||
reset: true,
|
||||
);
|
||||
} else {
|
||||
// ❌ Don’t close, show error
|
||||
Get.snackbar("Error", "Upload failed, please try again");
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
icon: const Icon(Icons.add, color: Colors.white),
|
||||
label: MyText.bodyMedium("Add Document",
|
||||
color: Colors.white, fontWeight: 600),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
floatingActionButtonLocation: FloatingActionButtonLocation.endFloat,
|
||||
);
|
||||
}
|
||||
}
|
@ -10,6 +10,8 @@ import 'package:marco/helpers/widgets/avatar.dart';
|
||||
import 'package:marco/model/employees/employee_info.dart';
|
||||
import 'package:marco/controller/auth/mpin_controller.dart';
|
||||
import 'package:marco/view/employees/employee_detail_screen.dart';
|
||||
import 'package:marco/view/document/user_document_screen.dart';
|
||||
|
||||
|
||||
class UserProfileBar extends StatefulWidget {
|
||||
final bool isCondensed;
|
||||
@ -177,6 +179,12 @@ class _UserProfileBarState extends State<UserProfileBar>
|
||||
onTap: _onProfileTap,
|
||||
),
|
||||
SizedBox(height: spacingHeight),
|
||||
_menuItemRow(
|
||||
icon: LucideIcons.file_text,
|
||||
label: 'My Documents',
|
||||
onTap: _onDocumentsTap,
|
||||
),
|
||||
SizedBox(height: spacingHeight),
|
||||
_menuItemRow(
|
||||
icon: LucideIcons.settings,
|
||||
label: 'Settings',
|
||||
@ -244,6 +252,13 @@ class _UserProfileBarState extends State<UserProfileBar>
|
||||
));
|
||||
}
|
||||
|
||||
void _onDocumentsTap() {
|
||||
Get.to(() => UserDocumentsPage(
|
||||
entityId: "${employeeInfo.id}",
|
||||
isEmployee: true,
|
||||
));
|
||||
}
|
||||
|
||||
void _onMpinTap() {
|
||||
final controller = Get.put(MPINController());
|
||||
if (hasMpin) controller.setChangeMpinMode();
|
||||
|
Loading…
x
Reference in New Issue
Block a user