Feature_Document #68
@ -75,6 +75,9 @@ class LoginController extends MyController {
|
|||||||
basicValidator.clearErrors();
|
basicValidator.clearErrors();
|
||||||
} else {
|
} else {
|
||||||
await _handleRememberMe();
|
await _handleRememberMe();
|
||||||
|
// ✅ Enable remote logging after successful login
|
||||||
|
enableRemoteLogging();
|
||||||
|
logSafe("✅ Remote logging enabled after login.");
|
||||||
|
|
||||||
// ✅ Commented out FCM token registration after login
|
// ✅ Commented out FCM token registration after login
|
||||||
/*
|
/*
|
||||||
|
|||||||
82
lib/controller/document/document_details_controller.dart
Normal file
82
lib/controller/document/document_details_controller.dart
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
import 'package:get/get.dart';
|
||||||
|
import 'package:marco/helpers/services/api_service.dart';
|
||||||
|
import 'package:marco/model/document/document_details_model.dart';
|
||||||
|
import 'package:marco/model/document/document_version_model.dart';
|
||||||
|
|
||||||
|
class DocumentDetailsController extends GetxController {
|
||||||
|
/// Observables
|
||||||
|
var isLoading = false.obs;
|
||||||
|
var documentDetails = Rxn<DocumentDetailsResponse>();
|
||||||
|
|
||||||
|
var versions = <DocumentVersionItem>[].obs;
|
||||||
|
var isVersionsLoading = false.obs;
|
||||||
|
|
||||||
|
// Loading states for buttons
|
||||||
|
var isVerifyLoading = false.obs;
|
||||||
|
var isRejectLoading = false.obs;
|
||||||
|
|
||||||
|
/// Fetch document details by id
|
||||||
|
Future<void> fetchDocumentDetails(String documentId) async {
|
||||||
|
try {
|
||||||
|
isLoading.value = true;
|
||||||
|
final response = await ApiService.getDocumentDetailsApi(documentId);
|
||||||
|
documentDetails.value = response;
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch document versions by parentAttachmentId
|
||||||
|
Future<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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verify document
|
||||||
|
Future<bool> verifyDocument(String documentId) async {
|
||||||
|
try {
|
||||||
|
isVerifyLoading.value = true;
|
||||||
|
final result =
|
||||||
|
await ApiService.verifyDocumentApi(id: documentId, isVerify: true);
|
||||||
|
if (result) await fetchDocumentDetails(documentId);
|
||||||
|
return result;
|
||||||
|
} finally {
|
||||||
|
isVerifyLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reject document
|
||||||
|
Future<bool> rejectDocument(String documentId) async {
|
||||||
|
try {
|
||||||
|
isRejectLoading.value = true;
|
||||||
|
final result =
|
||||||
|
await ApiService.verifyDocumentApi(id: documentId, isVerify: false);
|
||||||
|
if (result) await fetchDocumentDetails(documentId);
|
||||||
|
return result;
|
||||||
|
} finally {
|
||||||
|
isRejectLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch Pre-Signed URL for a given version
|
||||||
|
Future<String?> fetchPresignedUrl(String versionId) async {
|
||||||
|
return await ApiService.getPresignedUrlApi(versionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear data when leaving the screen
|
||||||
|
void clearDetails() {
|
||||||
|
documentDetails.value = null;
|
||||||
|
versions.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
239
lib/controller/document/document_upload_controller.dart
Normal file
239
lib/controller/document/document_upload_controller.dart
Normal file
@ -0,0 +1,239 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
import 'package:marco/helpers/services/app_logger.dart';
|
||||||
|
import 'package:marco/helpers/services/api_service.dart';
|
||||||
|
import 'package:marco/model/document/master_document_type_model.dart';
|
||||||
|
import 'package:marco/model/document/master_document_tags.dart';
|
||||||
|
import 'package:marco/helpers/widgets/my_snackbar.dart';
|
||||||
|
|
||||||
|
class DocumentUploadController extends GetxController {
|
||||||
|
// Observables
|
||||||
|
var isLoading = false.obs;
|
||||||
|
var isUploading = false.obs;
|
||||||
|
|
||||||
|
var categories = <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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String?> fetchPresignedUrl(String versionId) async {
|
||||||
|
return await ApiService.getPresignedUrlApi(versionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> editDocument(Map<String, dynamic> payload) async {
|
||||||
|
try {
|
||||||
|
isUploading.value = true;
|
||||||
|
|
||||||
|
final attachment = payload["attachment"];
|
||||||
|
|
||||||
|
final success = await ApiService.editDocumentApi(
|
||||||
|
id: payload["id"],
|
||||||
|
name: payload["name"],
|
||||||
|
documentId: payload["documentId"],
|
||||||
|
description: payload["description"],
|
||||||
|
tags: (payload["tags"] as List).cast<Map<String, dynamic>>(),
|
||||||
|
attachment: attachment,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
showAppSnackbar(
|
||||||
|
title: "Success",
|
||||||
|
message: "Document updated successfully",
|
||||||
|
type: SnackbarType.success,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
showAppSnackbar(
|
||||||
|
title: "Error",
|
||||||
|
message: "Failed to update document",
|
||||||
|
type: SnackbarType.error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return success;
|
||||||
|
} catch (e, stack) {
|
||||||
|
logSafe("Edit error: $e", level: LogLevel.error);
|
||||||
|
logSafe("Stacktrace: $stack", level: LogLevel.debug);
|
||||||
|
showAppSnackbar(
|
||||||
|
title: "Error",
|
||||||
|
message: "An unexpected error occurred",
|
||||||
|
type: SnackbarType.error,
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
isUploading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
173
lib/controller/document/user_document_controller.dart
Normal file
173
lib/controller/document/user_document_controller.dart
Normal file
@ -0,0 +1,173 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
import 'package:marco/helpers/services/api_service.dart';
|
||||||
|
import 'package:marco/model/document/document_filter_model.dart';
|
||||||
|
import 'package:marco/model/document/documents_list_model.dart';
|
||||||
|
|
||||||
|
class DocumentController extends GetxController {
|
||||||
|
// ------------------ Observables ---------------------
|
||||||
|
var isLoading = false.obs;
|
||||||
|
var documents = <DocumentItem>[].obs;
|
||||||
|
var filters = Rxn<DocumentFiltersData>();
|
||||||
|
|
||||||
|
// Selected filters
|
||||||
|
var selectedFilter = "".obs;
|
||||||
|
var selectedUploadedBy = "".obs;
|
||||||
|
var selectedCategory = "".obs;
|
||||||
|
var selectedType = "".obs;
|
||||||
|
var selectedTag = "".obs;
|
||||||
|
|
||||||
|
// Pagination state
|
||||||
|
var pageNumber = 1.obs;
|
||||||
|
final int pageSize = 20;
|
||||||
|
var hasMore = true.obs;
|
||||||
|
|
||||||
|
// Error message
|
||||||
|
var errorMessage = "".obs;
|
||||||
|
|
||||||
|
// NEW: show inactive toggle
|
||||||
|
var showInactive = false.obs;
|
||||||
|
|
||||||
|
// NEW: search
|
||||||
|
var searchQuery = ''.obs;
|
||||||
|
var searchController = TextEditingController();
|
||||||
|
|
||||||
|
// ------------------ API Calls -----------------------
|
||||||
|
|
||||||
|
/// Fetch Document Filters for an Entity
|
||||||
|
Future<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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Toggle document active/inactive state
|
||||||
|
Future<bool> toggleDocumentActive(
|
||||||
|
String id, {
|
||||||
|
required bool isActive,
|
||||||
|
required String entityTypeId,
|
||||||
|
required String entityId,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
isLoading.value = true;
|
||||||
|
final success =
|
||||||
|
await ApiService.deleteDocumentApi(id: id, isActive: isActive);
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
// 🔥 Always fetch fresh list after toggle
|
||||||
|
await fetchDocuments(
|
||||||
|
entityTypeId: entityTypeId,
|
||||||
|
entityId: entityId,
|
||||||
|
reset: true,
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
errorMessage.value = "Failed to update document state";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
errorMessage.value = "Error updating document: $e";
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Permanently delete a document (or deactivate depending on API)
|
||||||
|
Future<bool> deleteDocument(String id, {bool isActive = false}) async {
|
||||||
|
try {
|
||||||
|
isLoading.value = true;
|
||||||
|
final success =
|
||||||
|
await ApiService.deleteDocumentApi(id: id, isActive: isActive);
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
// remove from local list immediately for better UX
|
||||||
|
documents.removeWhere((doc) => doc.id == id);
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
errorMessage.value = "Failed to delete document";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
errorMessage.value = "Error deleting document: $e";
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch Documents for an entity
|
||||||
|
Future<void> fetchDocuments({
|
||||||
|
required String entityTypeId,
|
||||||
|
required String entityId,
|
||||||
|
String? filter,
|
||||||
|
String? searchString,
|
||||||
|
bool reset = false,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
if (reset) {
|
||||||
|
pageNumber.value = 1;
|
||||||
|
documents.clear();
|
||||||
|
hasMore.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasMore.value) return;
|
||||||
|
|
||||||
|
isLoading.value = true;
|
||||||
|
|
||||||
|
final response = await ApiService.getDocumentListApi(
|
||||||
|
entityTypeId: entityTypeId,
|
||||||
|
entityId: entityId,
|
||||||
|
filter: filter ?? "",
|
||||||
|
searchString: searchString ?? searchQuery.value,
|
||||||
|
pageNumber: pageNumber.value,
|
||||||
|
pageSize: pageSize,
|
||||||
|
isActive: !showInactive.value, // 👈 active or inactive
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response != null && response.success) {
|
||||||
|
if (response.data.data.isNotEmpty) {
|
||||||
|
documents.addAll(response.data.data);
|
||||||
|
pageNumber.value++;
|
||||||
|
} else {
|
||||||
|
hasMore.value = false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
errorMessage.value = response?.message ?? "Failed to fetch documents";
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
errorMessage.value = "Error fetching documents: $e";
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------ Helpers -----------------------
|
||||||
|
|
||||||
|
/// Clear selected filters
|
||||||
|
void clearFilters() {
|
||||||
|
selectedUploadedBy.value = "";
|
||||||
|
selectedCategory.value = "";
|
||||||
|
selectedType.value = "";
|
||||||
|
selectedTag.value = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if any filters are active (for red dot indicator)
|
||||||
|
bool hasActiveFilters() {
|
||||||
|
return selectedUploadedBy.value.isNotEmpty ||
|
||||||
|
selectedCategory.value.isNotEmpty ||
|
||||||
|
selectedType.value.isNotEmpty ||
|
||||||
|
selectedTag.value.isNotEmpty;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -3,7 +3,6 @@ import 'package:get/get.dart';
|
|||||||
import 'package:marco/model/dynamicMenu/dynamic_menu_model.dart';
|
import 'package:marco/model/dynamicMenu/dynamic_menu_model.dart';
|
||||||
import 'package:marco/helpers/services/api_service.dart';
|
import 'package:marco/helpers/services/api_service.dart';
|
||||||
import 'package:marco/helpers/services/app_logger.dart';
|
import 'package:marco/helpers/services/app_logger.dart';
|
||||||
import 'package:marco/helpers/services/storage/local_storage.dart';
|
|
||||||
|
|
||||||
class DynamicMenuController extends GetxController {
|
class DynamicMenuController extends GetxController {
|
||||||
// UI reactive states
|
// UI reactive states
|
||||||
@ -12,20 +11,14 @@ class DynamicMenuController extends GetxController {
|
|||||||
final RxString errorMessage = ''.obs;
|
final RxString errorMessage = ''.obs;
|
||||||
final RxList<MenuItem> menuItems = <MenuItem>[].obs;
|
final RxList<MenuItem> menuItems = <MenuItem>[].obs;
|
||||||
|
|
||||||
Timer? _autoRefreshTimer;
|
|
||||||
@override
|
@override
|
||||||
void onInit() {
|
void onInit() {
|
||||||
super.onInit();
|
super.onInit();
|
||||||
|
// Fetch menus directly from API at startup
|
||||||
fetchMenu();
|
fetchMenu();
|
||||||
|
|
||||||
/// Auto refresh every 5 minutes (adjust as needed)
|
|
||||||
_autoRefreshTimer = Timer.periodic(
|
|
||||||
const Duration(minutes: 15),
|
|
||||||
(_) => fetchMenu(),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fetch dynamic menu from API with error and local storage support
|
/// Fetch dynamic menu from API (no local cache)
|
||||||
Future<void> fetchMenu() async {
|
Future<void> fetchMenu() async {
|
||||||
isLoading.value = true;
|
isLoading.value = true;
|
||||||
hasError.value = false;
|
hasError.value = false;
|
||||||
@ -34,53 +27,36 @@ class DynamicMenuController extends GetxController {
|
|||||||
try {
|
try {
|
||||||
final responseData = await ApiService.getMenuApi();
|
final responseData = await ApiService.getMenuApi();
|
||||||
if (responseData != null) {
|
if (responseData != null) {
|
||||||
// Directly parse full JSON into MenuResponse
|
|
||||||
final menuResponse = MenuResponse.fromJson(responseData);
|
final menuResponse = MenuResponse.fromJson(responseData);
|
||||||
|
|
||||||
menuItems.assignAll(menuResponse.data);
|
menuItems.assignAll(menuResponse.data);
|
||||||
|
|
||||||
// Save menus for offline use
|
logSafe("✅ Menu loaded from API with ${menuItems.length} items");
|
||||||
await LocalStorage.setMenus(menuItems);
|
|
||||||
|
|
||||||
logSafe("Menu loaded from API with ${menuItems.length} items");
|
|
||||||
} else {
|
} else {
|
||||||
// If API fails, load from cache
|
_handleApiFailure("Menu API returned null response");
|
||||||
final cachedMenus = LocalStorage.getMenus();
|
|
||||||
if (cachedMenus.isNotEmpty) {
|
|
||||||
menuItems.assignAll(cachedMenus);
|
|
||||||
logSafe("Loaded menus from cache: ${menuItems.length} items");
|
|
||||||
} else {
|
|
||||||
hasError.value = true;
|
|
||||||
errorMessage.value = "Failed to fetch menu";
|
|
||||||
menuItems.clear();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logSafe("Menu fetch exception: $e", level: LogLevel.error);
|
_handleApiFailure("Menu fetch exception: $e");
|
||||||
|
|
||||||
// On error, load cached menus
|
|
||||||
final cachedMenus = LocalStorage.getMenus();
|
|
||||||
if (cachedMenus.isNotEmpty) {
|
|
||||||
menuItems.assignAll(cachedMenus);
|
|
||||||
logSafe("Loaded menus from cache after error: ${menuItems.length}");
|
|
||||||
} else {
|
|
||||||
hasError.value = true;
|
|
||||||
errorMessage.value = e.toString();
|
|
||||||
menuItems.clear();
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
isLoading.value = false;
|
isLoading.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _handleApiFailure(String logMessage) {
|
||||||
|
logSafe(logMessage, level: LogLevel.error);
|
||||||
|
|
||||||
|
// No cache available, show error state
|
||||||
|
hasError.value = true;
|
||||||
|
errorMessage.value = "❌ Unable to load menus. Please try again later.";
|
||||||
|
menuItems.clear();
|
||||||
|
}
|
||||||
|
|
||||||
bool isMenuAllowed(String menuName) {
|
bool isMenuAllowed(String menuName) {
|
||||||
final menu = menuItems.firstWhereOrNull((m) => m.name == menuName);
|
final menu = menuItems.firstWhereOrNull((m) => m.name == menuName);
|
||||||
return menu?.available ?? false; // default false if not found
|
return menu?.available ?? false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void onClose() {
|
void onClose() {
|
||||||
_autoRefreshTimer?.cancel(); // clean up timer
|
|
||||||
super.onClose();
|
super.onClose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -25,6 +25,7 @@ class AddEmployeeController extends MyController {
|
|||||||
String selectedCountryCode = "+91";
|
String selectedCountryCode = "+91";
|
||||||
bool showOnline = true;
|
bool showOnline = true;
|
||||||
final List<String> categories = [];
|
final List<String> categories = [];
|
||||||
|
DateTime? joiningDate;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void onInit() {
|
void onInit() {
|
||||||
@ -34,6 +35,12 @@ class AddEmployeeController extends MyController {
|
|||||||
fetchRoles();
|
fetchRoles();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void setJoiningDate(DateTime date) {
|
||||||
|
joiningDate = date;
|
||||||
|
logSafe("Joining date selected: $date");
|
||||||
|
update();
|
||||||
|
}
|
||||||
|
|
||||||
void _initializeFields() {
|
void _initializeFields() {
|
||||||
basicValidator.addField(
|
basicValidator.addField(
|
||||||
'first_name',
|
'first_name',
|
||||||
@ -109,6 +116,7 @@ class AddEmployeeController extends MyController {
|
|||||||
phoneNumber: phoneNumber!,
|
phoneNumber: phoneNumber!,
|
||||||
gender: selectedGender!.name,
|
gender: selectedGender!.name,
|
||||||
jobRoleId: selectedRoleId!,
|
jobRoleId: selectedRoleId!,
|
||||||
|
joiningDate: joiningDate?.toIso8601String() ?? "",
|
||||||
);
|
);
|
||||||
|
|
||||||
logSafe("Response: $response");
|
logSafe("Response: $response");
|
||||||
|
|||||||
@ -16,6 +16,7 @@ import 'package:marco/model/employees/employee_model.dart';
|
|||||||
import 'package:marco/model/expense/expense_type_model.dart';
|
import 'package:marco/model/expense/expense_type_model.dart';
|
||||||
import 'package:marco/model/expense/payment_types_model.dart';
|
import 'package:marco/model/expense/payment_types_model.dart';
|
||||||
import 'package:mime/mime.dart';
|
import 'package:mime/mime.dart';
|
||||||
|
import 'package:image_picker/image_picker.dart';
|
||||||
|
|
||||||
class AddExpenseController extends GetxController {
|
class AddExpenseController extends GetxController {
|
||||||
// --- Text Controllers ---
|
// --- Text Controllers ---
|
||||||
@ -57,7 +58,7 @@ class AddExpenseController extends GetxController {
|
|||||||
String? editingExpenseId;
|
String? editingExpenseId;
|
||||||
|
|
||||||
final expenseController = Get.find<ExpenseController>();
|
final expenseController = Get.find<ExpenseController>();
|
||||||
|
final ImagePicker _picker = ImagePicker();
|
||||||
@override
|
@override
|
||||||
void onInit() {
|
void onInit() {
|
||||||
super.onInit();
|
super.onInit();
|
||||||
@ -189,7 +190,7 @@ class AddExpenseController extends GetxController {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (pickedDate != null) {
|
if (pickedDate != null) {
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
final finalDateTime = DateTime(
|
final finalDateTime = DateTime(
|
||||||
pickedDate.year,
|
pickedDate.year,
|
||||||
pickedDate.month,
|
pickedDate.month,
|
||||||
@ -308,6 +309,17 @@ class AddExpenseController extends GetxController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> pickFromCamera() async {
|
||||||
|
try {
|
||||||
|
final pickedFile = await _picker.pickImage(source: ImageSource.camera);
|
||||||
|
if (pickedFile != null) {
|
||||||
|
attachments.add(File(pickedFile.path));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_errorSnackbar("Camera error: $e");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- Submission ---
|
// --- Submission ---
|
||||||
Future<void> submitOrUpdateExpense() async {
|
Future<void> submitOrUpdateExpense() async {
|
||||||
if (isSubmitting.value) return;
|
if (isSubmitting.value) return;
|
||||||
@ -360,33 +372,52 @@ class AddExpenseController extends GetxController {
|
|||||||
|
|
||||||
Future<Map<String, dynamic>> _buildExpensePayload() async {
|
Future<Map<String, dynamic>> _buildExpensePayload() async {
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
final existingAttachmentPayloads = existingAttachments
|
|
||||||
.map((e) => {
|
|
||||||
"documentId": e['documentId'],
|
|
||||||
"fileName": e['fileName'],
|
|
||||||
"contentType": e['contentType'],
|
|
||||||
"fileSize": 0,
|
|
||||||
"description": "",
|
|
||||||
"url": e['url'],
|
|
||||||
"isActive": e['isActive'] ?? true,
|
|
||||||
"base64Data": e['isActive'] == false ? null : e['base64Data'],
|
|
||||||
})
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
final newAttachmentPayloads =
|
// --- Existing Attachments Payload (for edit mode only) ---
|
||||||
await Future.wait(attachments.map((file) async {
|
final List<Map<String, dynamic>> existingAttachmentPayloads =
|
||||||
final bytes = await file.readAsBytes();
|
isEditMode.value
|
||||||
return {
|
? existingAttachments
|
||||||
"fileName": file.path.split('/').last,
|
.map<Map<String, dynamic>>((e) => {
|
||||||
"base64Data": base64Encode(bytes),
|
"documentId": e['documentId'],
|
||||||
"contentType": lookupMimeType(file.path) ?? 'application/octet-stream',
|
"fileName": e['fileName'],
|
||||||
"fileSize": await file.length(),
|
"contentType": e['contentType'],
|
||||||
"description": "",
|
"fileSize": 0,
|
||||||
};
|
"description": "",
|
||||||
}));
|
"url": e['url'],
|
||||||
|
"isActive": e['isActive'] ?? true,
|
||||||
|
"base64Data": "", // <-- always empty now
|
||||||
|
})
|
||||||
|
.toList()
|
||||||
|
: <Map<String, dynamic>>[];
|
||||||
|
|
||||||
|
// --- New Attachments Payload (always include if attachments exist) ---
|
||||||
|
final List<Map<String, dynamic>> newAttachmentPayloads =
|
||||||
|
attachments.isNotEmpty
|
||||||
|
? await Future.wait(attachments.map((file) async {
|
||||||
|
final bytes = await file.readAsBytes();
|
||||||
|
final length = await file.length();
|
||||||
|
return <String, dynamic>{
|
||||||
|
"fileName": file.path.split('/').last,
|
||||||
|
"base64Data": base64Encode(bytes),
|
||||||
|
"contentType":
|
||||||
|
lookupMimeType(file.path) ?? 'application/octet-stream',
|
||||||
|
"fileSize": length,
|
||||||
|
"description": "",
|
||||||
|
};
|
||||||
|
}))
|
||||||
|
: <Map<String, dynamic>>[];
|
||||||
|
|
||||||
|
// --- Selected Expense Type ---
|
||||||
final type = selectedExpenseType.value!;
|
final type = selectedExpenseType.value!;
|
||||||
return {
|
|
||||||
|
// --- Combine all attachments ---
|
||||||
|
final List<Map<String, dynamic>> combinedAttachments = [
|
||||||
|
...existingAttachmentPayloads,
|
||||||
|
...newAttachmentPayloads
|
||||||
|
];
|
||||||
|
|
||||||
|
// --- Build Payload ---
|
||||||
|
final payload = <String, dynamic>{
|
||||||
if (isEditMode.value && editingExpenseId != null) "id": editingExpenseId,
|
if (isEditMode.value && editingExpenseId != null) "id": editingExpenseId,
|
||||||
"projectId": projectsMap[selectedProject.value]!,
|
"projectId": projectsMap[selectedProject.value]!,
|
||||||
"expensesTypeId": type.id,
|
"expensesTypeId": type.id,
|
||||||
@ -402,11 +433,11 @@ class AddExpenseController extends GetxController {
|
|||||||
"noOfPersons": type.noOfPersonsRequired == true
|
"noOfPersons": type.noOfPersonsRequired == true
|
||||||
? int.tryParse(noOfPersonsController.text.trim()) ?? 0
|
? int.tryParse(noOfPersonsController.text.trim()) ?? 0
|
||||||
: 0,
|
: 0,
|
||||||
"billAttachments": [
|
"billAttachments":
|
||||||
...existingAttachmentPayloads,
|
combinedAttachments.isEmpty ? null : combinedAttachments,
|
||||||
...newAttachmentPayloads
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
return payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
String validateForm() {
|
String validateForm() {
|
||||||
|
|||||||
@ -1,15 +1,16 @@
|
|||||||
class ApiEndpoints {
|
class ApiEndpoints {
|
||||||
// static const String baseUrl = "https://stageapi.marcoaiot.com/api";
|
static const String baseUrl = "https://stageapi.marcoaiot.com/api";
|
||||||
static const String baseUrl = "https://api.marcoaiot.com/api";
|
// static const String baseUrl = "https://api.marcoaiot.com/api";
|
||||||
|
// static const String baseUrl = "https://devapi.marcoaiot.com/api";
|
||||||
|
|
||||||
// Dashboard Module API Endpoints
|
// Dashboard Module API Endpoints
|
||||||
static const String getDashboardAttendanceOverview = "/dashboard/attendance-overview";
|
static const String getDashboardAttendanceOverview =
|
||||||
|
"/dashboard/attendance-overview";
|
||||||
static const String getDashboardProjectProgress = "/dashboard/progression";
|
static const String getDashboardProjectProgress = "/dashboard/progression";
|
||||||
static const String getDashboardTasks = "/dashboard/tasks";
|
static const String getDashboardTasks = "/dashboard/tasks";
|
||||||
static const String getDashboardTeams = "/dashboard/teams";
|
static const String getDashboardTeams = "/dashboard/teams";
|
||||||
static const String getDashboardProjects = "/dashboard/projects";
|
static const String getDashboardProjects = "/dashboard/projects";
|
||||||
|
|
||||||
|
|
||||||
// Attendance Module API Endpoints
|
// Attendance Module API Endpoints
|
||||||
static const String getProjects = "/project/list";
|
static const String getProjects = "/project/list";
|
||||||
static const String getGlobalProjects = "/project/list/basic";
|
static const String getGlobalProjects = "/project/list/basic";
|
||||||
@ -45,7 +46,8 @@ class ApiEndpoints {
|
|||||||
static const String getDirectoryContacts = "/directory";
|
static const String getDirectoryContacts = "/directory";
|
||||||
static const String getDirectoryBucketList = "/directory/buckets";
|
static const String getDirectoryBucketList = "/directory/buckets";
|
||||||
static const String getDirectoryContactDetail = "/directory/notes";
|
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 getDirectoryContactTags = "/master/contact-tags";
|
||||||
static const String getDirectoryOrganization = "/directory/organization";
|
static const String getDirectoryOrganization = "/directory/organization";
|
||||||
static const String createContact = "/directory";
|
static const String createContact = "/directory";
|
||||||
@ -70,4 +72,22 @@ class ApiEndpoints {
|
|||||||
|
|
||||||
////// Dynamic Menu Module API Endpoints
|
////// Dynamic Menu Module API Endpoints
|
||||||
static const String getDynamicMenu = "/appmenu/get/menu-mobile";
|
static const String getDynamicMenu = "/appmenu/get/menu-mobile";
|
||||||
|
|
||||||
|
///// Document Module API Endpoints
|
||||||
|
static const String getMasterDocumentCategories =
|
||||||
|
"/master/document-category/list";
|
||||||
|
static const String getMasterDocumentTags = "/document/get/tags";
|
||||||
|
static const String getDocumentList = "/document/list";
|
||||||
|
static const String getDocumentDetails = "/document/get/details";
|
||||||
|
static const String uploadDocument = "/document/upload";
|
||||||
|
static const String deleteDocument = "/document/delete";
|
||||||
|
static const String getDocumentFilter = "/document/get/filter";
|
||||||
|
static const String getDocumentTypesByCategory = "/master/document-type/list";
|
||||||
|
static const String getDocumentVersion = "/document/get/version";
|
||||||
|
static const String getDocumentVersions = "/document/list/versions";
|
||||||
|
static const String editDocument = "/document/edit";
|
||||||
|
static const String verifyDocument = "/document/verify";
|
||||||
|
|
||||||
|
/// Logs Module API Endpoints
|
||||||
|
static const String uploadLogs = "/log";
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,6 +12,12 @@ import 'package:marco/model/dashboard/project_progress_model.dart';
|
|||||||
import 'package:marco/model/dashboard/dashboard_tasks_model.dart';
|
import 'package:marco/model/dashboard/dashboard_tasks_model.dart';
|
||||||
import 'package:marco/model/dashboard/dashboard_teams_model.dart';
|
import 'package:marco/model/dashboard/dashboard_teams_model.dart';
|
||||||
import 'package:marco/helpers/services/app_logger.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 {
|
class ApiService {
|
||||||
static const Duration timeout = Duration(seconds: 30);
|
static const Duration timeout = Duration(seconds: 30);
|
||||||
@ -241,6 +247,527 @@ class ApiService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static Future<bool> postLogsApi(List<Map<String, dynamic>> logs) async {
|
||||||
|
const endpoint = "${ApiEndpoints.uploadLogs}";
|
||||||
|
logSafe("Posting logs... count=${logs.length}");
|
||||||
|
|
||||||
|
try {
|
||||||
|
final response =
|
||||||
|
await _postRequest(endpoint, logs, customTimeout: extendedTimeout);
|
||||||
|
|
||||||
|
if (response == null) {
|
||||||
|
logSafe("Post logs failed: null response", level: LogLevel.error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
logSafe("Post logs response status: ${response.statusCode}");
|
||||||
|
logSafe("Post logs response body: ${response.body}");
|
||||||
|
|
||||||
|
if (response.statusCode == 200 && response.body.isNotEmpty) {
|
||||||
|
final json = jsonDecode(response.body);
|
||||||
|
if (json['success'] == true) {
|
||||||
|
logSafe("Logs posted successfully.");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logSafe("Failed to post logs: ${response.body}", level: LogLevel.warning);
|
||||||
|
} catch (e, stack) {
|
||||||
|
logSafe("Exception during postLogsApi: $e", level: LogLevel.error);
|
||||||
|
logSafe("StackTrace: $stack", level: LogLevel.debug);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verify Document API
|
||||||
|
static Future<bool> verifyDocumentApi({
|
||||||
|
required String id,
|
||||||
|
bool isVerify = true,
|
||||||
|
}) async {
|
||||||
|
final endpoint = "${ApiEndpoints.verifyDocument}/$id";
|
||||||
|
final queryParams = {"isVerify": isVerify.toString()};
|
||||||
|
logSafe("Verifying document with id: $id | isVerify: $isVerify");
|
||||||
|
|
||||||
|
try {
|
||||||
|
final uri = Uri.parse("${ApiEndpoints.baseUrl}$endpoint")
|
||||||
|
.replace(queryParameters: queryParams);
|
||||||
|
|
||||||
|
String? token = await _getToken();
|
||||||
|
if (token == null) return false;
|
||||||
|
|
||||||
|
final headers = _headers(token);
|
||||||
|
logSafe("POST (verify) $uri\nHeaders: $headers");
|
||||||
|
|
||||||
|
final response =
|
||||||
|
await http.post(uri, headers: headers).timeout(extendedTimeout);
|
||||||
|
|
||||||
|
if (response.statusCode == 401) {
|
||||||
|
logSafe("Unauthorized VERIFY. Attempting token refresh...");
|
||||||
|
if (await AuthService.refreshToken()) {
|
||||||
|
return await verifyDocumentApi(id: id, isVerify: isVerify);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logSafe("Verify document response status: ${response.statusCode}");
|
||||||
|
logSafe("Verify document response body: ${response.body}");
|
||||||
|
|
||||||
|
final json = jsonDecode(response.body);
|
||||||
|
if (json['success'] == true) {
|
||||||
|
logSafe("Document verify success: ${json['data']}");
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
logSafe(
|
||||||
|
"Failed to verify document: ${json['message'] ?? 'Unknown error'}",
|
||||||
|
level: LogLevel.warning,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e, stack) {
|
||||||
|
logSafe("Exception during verifyDocumentApi: $e", level: LogLevel.error);
|
||||||
|
logSafe("StackTrace: $stack", level: LogLevel.debug);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get Pre-Signed URL for Old Version
|
||||||
|
static Future<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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete (Soft Delete / Deactivate) Document API
|
||||||
|
static Future<bool> deleteDocumentApi({
|
||||||
|
required String id,
|
||||||
|
bool isActive = false, // default false = delete
|
||||||
|
}) async {
|
||||||
|
final endpoint = "${ApiEndpoints.deleteDocument}/$id";
|
||||||
|
final queryParams = {"isActive": isActive.toString()};
|
||||||
|
logSafe("Deleting document with id: $id | isActive: $isActive");
|
||||||
|
|
||||||
|
try {
|
||||||
|
final uri = Uri.parse("${ApiEndpoints.baseUrl}$endpoint")
|
||||||
|
.replace(queryParameters: queryParams);
|
||||||
|
|
||||||
|
String? token = await _getToken();
|
||||||
|
if (token == null) return false;
|
||||||
|
|
||||||
|
final headers = _headers(token);
|
||||||
|
logSafe("DELETE (PUT/POST style) $uri\nHeaders: $headers");
|
||||||
|
|
||||||
|
// some backends use PUT instead of DELETE for soft deletes
|
||||||
|
final response =
|
||||||
|
await http.delete(uri, headers: headers).timeout(extendedTimeout);
|
||||||
|
|
||||||
|
if (response.statusCode == 401) {
|
||||||
|
logSafe("Unauthorized DELETE. Attempting token refresh...");
|
||||||
|
if (await AuthService.refreshToken()) {
|
||||||
|
return await deleteDocumentApi(id: id, isActive: isActive);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logSafe("Delete document response status: ${response.statusCode}");
|
||||||
|
logSafe("Delete document response body: ${response.body}");
|
||||||
|
|
||||||
|
final json = jsonDecode(response.body);
|
||||||
|
if (json['success'] == true) {
|
||||||
|
logSafe("Document delete/update success: ${json['data']}");
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
logSafe(
|
||||||
|
"Failed to delete document: ${json['message'] ?? 'Unknown error'}",
|
||||||
|
level: LogLevel.warning,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e, stack) {
|
||||||
|
logSafe("Exception during deleteDocumentApi: $e", level: LogLevel.error);
|
||||||
|
logSafe("StackTrace: $stack", level: LogLevel.debug);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Edit Document API
|
||||||
|
static Future<bool> editDocumentApi({
|
||||||
|
required String id,
|
||||||
|
required String name,
|
||||||
|
required String documentId,
|
||||||
|
String? description,
|
||||||
|
List<Map<String, dynamic>> tags = const [],
|
||||||
|
Map<String, dynamic>? attachment, // 👈 can be null
|
||||||
|
}) async {
|
||||||
|
final endpoint = "${ApiEndpoints.editDocument}/$id";
|
||||||
|
logSafe("Editing document with id: $id");
|
||||||
|
|
||||||
|
final Map<String, dynamic> payload = {
|
||||||
|
"id": id,
|
||||||
|
"name": name,
|
||||||
|
"documentId": documentId,
|
||||||
|
"description": description ?? "",
|
||||||
|
"tags": tags.isNotEmpty
|
||||||
|
? tags
|
||||||
|
: [
|
||||||
|
{"name": "default", "isActive": true}
|
||||||
|
],
|
||||||
|
"attachment": attachment, // 👈 null or object
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
final response =
|
||||||
|
await _putRequest(endpoint, payload, customTimeout: extendedTimeout);
|
||||||
|
|
||||||
|
if (response == null) {
|
||||||
|
logSafe("Edit document failed: null response", level: LogLevel.error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
logSafe("Edit document response status: ${response.statusCode}");
|
||||||
|
logSafe("Edit document response body: ${response.body}");
|
||||||
|
|
||||||
|
final json = jsonDecode(response.body);
|
||||||
|
if (json['success'] == true) {
|
||||||
|
logSafe("Document edited successfully: ${json['data']}");
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
logSafe(
|
||||||
|
"Failed to edit document: ${json['message'] ?? 'Unknown error'}",
|
||||||
|
level: LogLevel.warning,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e, stack) {
|
||||||
|
logSafe("Exception during editDocumentApi: $e", level: LogLevel.error);
|
||||||
|
logSafe("StackTrace: $stack", level: LogLevel.debug);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get List of Versions by ParentAttachmentId
|
||||||
|
static Future<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 === //
|
// === Menu APIs === //
|
||||||
|
|
||||||
/// Get Sidebar Menu API
|
/// Get Sidebar Menu API
|
||||||
@ -1358,6 +1885,7 @@ class ApiService {
|
|||||||
required String phoneNumber,
|
required String phoneNumber,
|
||||||
required String gender,
|
required String gender,
|
||||||
required String jobRoleId,
|
required String jobRoleId,
|
||||||
|
required String joiningDate,
|
||||||
}) async {
|
}) async {
|
||||||
final body = {
|
final body = {
|
||||||
"firstName": firstName,
|
"firstName": firstName,
|
||||||
@ -1365,6 +1893,7 @@ class ApiService {
|
|||||||
"phoneNumber": phoneNumber,
|
"phoneNumber": phoneNumber,
|
||||||
"gender": gender,
|
"gender": gender,
|
||||||
"jobRoleId": jobRoleId,
|
"jobRoleId": jobRoleId,
|
||||||
|
"joiningDate": joiningDate
|
||||||
};
|
};
|
||||||
|
|
||||||
final response = await _postRequest(
|
final response = await _postRequest(
|
||||||
|
|||||||
@ -2,16 +2,41 @@ import 'dart:io';
|
|||||||
import 'package:logger/logger.dart';
|
import 'package:logger/logger.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
import 'package:marco/helpers/services/api_service.dart';
|
||||||
|
|
||||||
/// Global logger instance
|
/// Global logger instance
|
||||||
late final Logger appLogger;
|
Logger? _appLogger;
|
||||||
late final FileLogOutput fileLogOutput;
|
late final FileLogOutput _fileLogOutput;
|
||||||
|
|
||||||
|
/// Store logs temporarily for API posting
|
||||||
|
final List<Map<String, dynamic>> _logBuffer = [];
|
||||||
|
|
||||||
|
/// Lock flag to prevent concurrent posting
|
||||||
|
bool _isPosting = false;
|
||||||
|
|
||||||
|
/// Flag to allow API posting only after login
|
||||||
|
bool _canPostLogs = false;
|
||||||
|
|
||||||
|
/// Maximum number of logs before triggering API post
|
||||||
|
const int _maxLogsBeforePost = 100;
|
||||||
|
|
||||||
|
/// Maximum logs in memory buffer
|
||||||
|
const int _maxBufferSize = 500;
|
||||||
|
|
||||||
|
/// Enum → logger level mapping
|
||||||
|
const _levelMap = {
|
||||||
|
LogLevel.debug: Level.debug,
|
||||||
|
LogLevel.info: Level.info,
|
||||||
|
LogLevel.warning: Level.warning,
|
||||||
|
LogLevel.error: Level.error,
|
||||||
|
LogLevel.verbose: Level.verbose,
|
||||||
|
};
|
||||||
|
|
||||||
/// Initialize logging
|
/// Initialize logging
|
||||||
Future<void> initLogging() async {
|
Future<void> initLogging() async {
|
||||||
fileLogOutput = FileLogOutput();
|
_fileLogOutput = FileLogOutput();
|
||||||
|
|
||||||
appLogger = Logger(
|
_appLogger = Logger(
|
||||||
printer: PrettyPrinter(
|
printer: PrettyPrinter(
|
||||||
methodCount: 0,
|
methodCount: 0,
|
||||||
printTime: true,
|
printTime: true,
|
||||||
@ -20,12 +45,18 @@ Future<void> initLogging() async {
|
|||||||
),
|
),
|
||||||
output: MultiOutput([
|
output: MultiOutput([
|
||||||
ConsoleOutput(),
|
ConsoleOutput(),
|
||||||
fileLogOutput,
|
_fileLogOutput,
|
||||||
]),
|
]),
|
||||||
level: Level.debug,
|
level: Level.debug,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Enable API posting after login
|
||||||
|
void enableRemoteLogging() {
|
||||||
|
_canPostLogs = true;
|
||||||
|
_postBufferedLogs(); // flush logs if any
|
||||||
|
}
|
||||||
|
|
||||||
/// Safe logger wrapper
|
/// Safe logger wrapper
|
||||||
void logSafe(
|
void logSafe(
|
||||||
String message, {
|
String message, {
|
||||||
@ -34,27 +65,60 @@ void logSafe(
|
|||||||
StackTrace? stackTrace,
|
StackTrace? stackTrace,
|
||||||
bool sensitive = false,
|
bool sensitive = false,
|
||||||
}) {
|
}) {
|
||||||
if (sensitive) return;
|
if (sensitive || _appLogger == null) return;
|
||||||
|
|
||||||
switch (level) {
|
final loggerLevel = _levelMap[level] ?? Level.info;
|
||||||
case LogLevel.debug:
|
_appLogger!.log(loggerLevel, message, error: error, stackTrace: stackTrace);
|
||||||
appLogger.d(message, error: error, stackTrace: stackTrace);
|
|
||||||
break;
|
// Buffer logs for API posting
|
||||||
case LogLevel.warning:
|
_logBuffer.add({
|
||||||
appLogger.w(message, error: error, stackTrace: stackTrace);
|
"logLevel": level.name,
|
||||||
break;
|
"message": message,
|
||||||
case LogLevel.error:
|
"timeStamp": DateTime.now().toUtc().toIso8601String(),
|
||||||
appLogger.e(message, error: error, stackTrace: stackTrace);
|
"ipAddress": "this is test IP", // TODO: real IP
|
||||||
break;
|
"userAgent": "FlutterApp/1.0", // TODO: device_info_plus
|
||||||
case LogLevel.verbose:
|
"details": error?.toString() ?? stackTrace?.toString(),
|
||||||
appLogger.v(message, error: error, stackTrace: stackTrace);
|
});
|
||||||
break;
|
|
||||||
default:
|
if (_logBuffer.length >= _maxLogsBeforePost) {
|
||||||
appLogger.i(message, error: error, stackTrace: stackTrace);
|
_postBufferedLogs();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Log output to file (safe path, no permission required)
|
/// Post buffered logs to API
|
||||||
|
Future<void> _postBufferedLogs() async {
|
||||||
|
if (!_canPostLogs) return; // 🚫 skip if not logged in
|
||||||
|
if (_isPosting || _logBuffer.isEmpty) return;
|
||||||
|
|
||||||
|
_isPosting = true;
|
||||||
|
final logsToSend = List<Map<String, dynamic>>.from(_logBuffer);
|
||||||
|
_logBuffer.clear();
|
||||||
|
|
||||||
|
try {
|
||||||
|
final success = await ApiService.postLogsApi(logsToSend);
|
||||||
|
if (!success) {
|
||||||
|
_reinsertLogs(logsToSend, reason: "API call returned false");
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_reinsertLogs(logsToSend, reason: "API exception: $e");
|
||||||
|
} finally {
|
||||||
|
_isPosting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reinsert logs into buffer if posting fails
|
||||||
|
void _reinsertLogs(List<Map<String, dynamic>> logs, {required String reason}) {
|
||||||
|
_appLogger?.w("Failed to post logs, re-queuing. Reason: $reason");
|
||||||
|
|
||||||
|
if (_logBuffer.length + logs.length > _maxBufferSize) {
|
||||||
|
_appLogger?.e("Buffer full. Dropping ${logs.length} logs to prevent crash.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_logBuffer.insertAll(0, logs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// File-based log output (safe storage)
|
||||||
class FileLogOutput extends LogOutput {
|
class FileLogOutput extends LogOutput {
|
||||||
File? _logFile;
|
File? _logFile;
|
||||||
|
|
||||||
@ -81,7 +145,6 @@ class FileLogOutput extends LogOutput {
|
|||||||
@override
|
@override
|
||||||
void output(OutputEvent event) async {
|
void output(OutputEvent event) async {
|
||||||
await _init();
|
await _init();
|
||||||
|
|
||||||
if (event.lines.isEmpty) return;
|
if (event.lines.isEmpty) return;
|
||||||
|
|
||||||
final logMessage = event.lines.join('\n') + '\n';
|
final logMessage = event.lines.join('\n') + '\n';
|
||||||
@ -122,22 +185,5 @@ class FileLogOutput extends LogOutput {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Simple log printer for file output
|
/// Custom log levels
|
||||||
class SimpleFileLogPrinter extends LogPrinter {
|
|
||||||
@override
|
|
||||||
List<String> log(LogEvent event) {
|
|
||||||
final message = event.message.toString();
|
|
||||||
|
|
||||||
if (message.contains('[SENSITIVE]')) return [];
|
|
||||||
|
|
||||||
final timestamp = DateFormat('yyyy-MM-dd HH:mm:ss').format(DateTime.now());
|
|
||||||
final level = event.level.name.toUpperCase();
|
|
||||||
final error = event.error != null ? ' | ERROR: ${event.error}' : '';
|
|
||||||
final stack =
|
|
||||||
event.stackTrace != null ? '\nSTACKTRACE:\n${event.stackTrace}' : '';
|
|
||||||
return ['[$timestamp] [$level] $message$error$stack'];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Optional enum for log levels
|
|
||||||
enum LogLevel { debug, info, warning, error, verbose }
|
enum LogLevel { debug, info, warning, error, verbose }
|
||||||
|
|||||||
@ -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 {
|
class Permissions {
|
||||||
// ------------------- Project Management ------------------------------
|
// ------------------- Project Management ------------------------------
|
||||||
/// Permission to manage master data (like dropdowns, configurations)
|
/// Permission to manage master data (like dropdowns, configurations)
|
||||||
@ -91,4 +91,30 @@ class Permissions {
|
|||||||
// ------------------- Application Roles -------------------------------
|
// ------------------- Application Roles -------------------------------
|
||||||
/// Application role ID for users with full expense management rights
|
/// Application role ID for users with full expense management rights
|
||||||
static const String expenseManagement = "a4e25142-449b-4334-a6e5-22f70e4732d7";
|
static const String expenseManagement = "a4e25142-449b-4334-a6e5-22f70e4732d7";
|
||||||
|
|
||||||
|
// ------------------- Document Entities -------------------------------
|
||||||
|
/// Entity ID for project documents
|
||||||
|
static const String projectEntity = "c8fe7115-aa27-43bc-99f4-7b05fabe436e";
|
||||||
|
|
||||||
|
/// Entity ID for employee documents
|
||||||
|
static const String employeeEntity = "dbb9555a-7a0c-40f2-a9ed-f0463f1ceed7";
|
||||||
|
|
||||||
|
// ------------------- Document Permissions ----------------------------
|
||||||
|
/// Permission to view documents
|
||||||
|
static const String viewDocument = "71189504-f1c8-4ca5-8db6-810497be2854";
|
||||||
|
|
||||||
|
/// Permission to upload documents
|
||||||
|
static const String uploadDocument = "3f6d1f67-6fa5-4b7c-b17b-018d4fe4aab8";
|
||||||
|
|
||||||
|
/// Permission to modify documents
|
||||||
|
static const String modifyDocument = "c423fd81-6273-4b9d-bb5e-76a0fb343833";
|
||||||
|
|
||||||
|
/// Permission to delete documents
|
||||||
|
static const String deleteDocument = "40863a13-5a66-469d-9b48-135bc5dbf486";
|
||||||
|
|
||||||
|
/// Permission to download documents
|
||||||
|
static const String downloadDocument = "404373d0-860f-490e-a575-1c086ffbce1d";
|
||||||
|
|
||||||
|
/// Permission to verify documents
|
||||||
|
static const String verifyDocument = "13a1f30f-38d1-41bf-8e7a-b75189aab8e0";
|
||||||
}
|
}
|
||||||
|
|||||||
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);
|
||||||
|
}
|
||||||
@ -110,8 +110,8 @@ class DashboardOverviewWidgets {
|
|||||||
final double percent = total > 0 ? completed / total : 0.0;
|
final double percent = total > 0 ? completed / total : 0.0;
|
||||||
|
|
||||||
// Task colors
|
// Task colors
|
||||||
const completedColor = Color(0xFFE57373); // red
|
const completedColor = Color(0xFF64B5F6);
|
||||||
const remainingColor = Color(0xFF64B5F6); // blue
|
const remainingColor =Color(0xFFE57373);
|
||||||
|
|
||||||
final List<_ChartData> pieData = [
|
final List<_ChartData> pieData = [
|
||||||
_ChartData('Completed', completed.toDouble(), completedColor),
|
_ChartData('Completed', completed.toDouble(), completedColor),
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
import 'package:marco/helpers/widgets/my_text.dart';
|
import 'package:marco/helpers/widgets/my_text.dart';
|
||||||
|
|
||||||
|
|
||||||
class ConfirmDialog extends StatelessWidget {
|
class ConfirmDialog extends StatelessWidget {
|
||||||
final String title;
|
final String title;
|
||||||
final String message;
|
final String message;
|
||||||
|
|||||||
@ -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
|
// Employee List - Card Style
|
||||||
static Widget employeeListSkeletonLoader() {
|
static Widget employeeListSkeletonLoader() {
|
||||||
return Column(
|
return Column(
|
||||||
|
|||||||
@ -13,7 +13,7 @@ Future<void> main() async {
|
|||||||
|
|
||||||
await initLogging();
|
await initLogging();
|
||||||
logSafe("App starting...");
|
logSafe("App starting...");
|
||||||
|
enableRemoteLogging();
|
||||||
try {
|
try {
|
||||||
await initializeApp();
|
await initializeApp();
|
||||||
logSafe("App initialized successfully.");
|
logSafe("App initialized successfully.");
|
||||||
@ -73,9 +73,11 @@ class _MainWrapperState extends State<MainWrapper> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final bool isOffline = _connectivityStatus.contains(ConnectivityResult.none);
|
final bool isOffline =
|
||||||
|
_connectivityStatus.contains(ConnectivityResult.none);
|
||||||
return isOffline
|
return isOffline
|
||||||
? const MaterialApp(debugShowCheckedModeBanner: false, home: OfflineScreen())
|
? const MaterialApp(
|
||||||
|
debugShowCheckedModeBanner: false, home: OfflineScreen())
|
||||||
: const MyApp();
|
: const MyApp();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
255
lib/model/document/document_details_model.dart
Normal file
255
lib/model/document/document_details_model.dart
Normal file
@ -0,0 +1,255 @@
|
|||||||
|
class DocumentDetailsResponse {
|
||||||
|
final bool success;
|
||||||
|
final String message;
|
||||||
|
final DocumentDetails? data;
|
||||||
|
final dynamic errors;
|
||||||
|
final int statusCode;
|
||||||
|
final DateTime timestamp;
|
||||||
|
|
||||||
|
DocumentDetailsResponse({
|
||||||
|
required this.success,
|
||||||
|
required this.message,
|
||||||
|
required this.data,
|
||||||
|
required this.errors,
|
||||||
|
required this.statusCode,
|
||||||
|
required this.timestamp,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory DocumentDetailsResponse.fromJson(Map<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'] ?? '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
required 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'name': name,
|
||||||
|
'isActive': isActive,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
753
lib/model/document/document_edit_bottom_sheet.dart
Normal file
753
lib/model/document/document_edit_bottom_sheet.dart
Normal file
@ -0,0 +1,753 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'package:file_picker/file_picker.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
import 'package:marco/controller/document/document_upload_controller.dart';
|
||||||
|
import 'package:marco/helpers/utils/base_bottom_sheet.dart';
|
||||||
|
import 'package:marco/helpers/widgets/my_spacing.dart';
|
||||||
|
import 'package:marco/helpers/widgets/my_snackbar.dart';
|
||||||
|
import 'package:marco/helpers/widgets/my_text.dart';
|
||||||
|
import 'package:marco/helpers/widgets/image_viewer_dialog.dart';
|
||||||
|
import 'package:marco/model/document/master_document_type_model.dart';
|
||||||
|
import 'package:marco/helpers/widgets/my_text_style.dart';
|
||||||
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
|
||||||
|
class DocumentEditBottomSheet extends StatefulWidget {
|
||||||
|
final Map<String, dynamic> documentData;
|
||||||
|
final Function(Map<String, dynamic>) onSubmit;
|
||||||
|
|
||||||
|
const DocumentEditBottomSheet({
|
||||||
|
Key? key,
|
||||||
|
required this.documentData,
|
||||||
|
required this.onSubmit,
|
||||||
|
}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<DocumentEditBottomSheet> createState() =>
|
||||||
|
_DocumentEditBottomSheetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DocumentEditBottomSheetState extends State<DocumentEditBottomSheet> {
|
||||||
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
final controller = Get.put(DocumentUploadController());
|
||||||
|
|
||||||
|
final TextEditingController _docIdController = TextEditingController();
|
||||||
|
final TextEditingController _docNameController = TextEditingController();
|
||||||
|
final TextEditingController _descriptionController = TextEditingController();
|
||||||
|
String? latestVersionUrl;
|
||||||
|
|
||||||
|
File? selectedFile;
|
||||||
|
bool fileChanged = false;
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
|
||||||
|
_docIdController.text = widget.documentData["documentId"] ?? "";
|
||||||
|
_docNameController.text = widget.documentData["name"] ?? "";
|
||||||
|
_descriptionController.text = widget.documentData["description"] ?? "";
|
||||||
|
|
||||||
|
// Tags
|
||||||
|
if (widget.documentData["tags"] != null) {
|
||||||
|
controller.enteredTags.assignAll(
|
||||||
|
List<String>.from(
|
||||||
|
(widget.documentData["tags"] as List)
|
||||||
|
.map((t) => t is String ? t : t["name"]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Convert category map to DocumentType ---
|
||||||
|
if (widget.documentData["category"] != null) {
|
||||||
|
controller.selectedCategory =
|
||||||
|
DocumentType.fromJson(widget.documentData["category"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Type (if separate)
|
||||||
|
if (widget.documentData["type"] != null) {
|
||||||
|
controller.selectedType =
|
||||||
|
DocumentType.fromJson(widget.documentData["type"]);
|
||||||
|
}
|
||||||
|
// Fetch latest version URL if attachment exists
|
||||||
|
final latestVersion = widget.documentData["attachment"];
|
||||||
|
if (latestVersion != null) {
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||||
|
final url = await controller.fetchPresignedUrl(latestVersion["id"]);
|
||||||
|
if (url != null) {
|
||||||
|
setState(() {
|
||||||
|
latestVersionUrl = url;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_docIdController.dispose();
|
||||||
|
_docNameController.dispose();
|
||||||
|
_descriptionController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleSubmit() async {
|
||||||
|
if (!(_formKey.currentState?.validate() ?? false)) return;
|
||||||
|
|
||||||
|
// ✅ Validate only if user picked a new file
|
||||||
|
if (fileChanged && selectedFile != null) {
|
||||||
|
final maxSizeMB = controller.selectedType?.maxSizeAllowedInMB;
|
||||||
|
if (maxSizeMB != null && controller.selectedFileSize != null) {
|
||||||
|
final fileSizeMB = controller.selectedFileSize! / (1024 * 1024);
|
||||||
|
if (fileSizeMB > maxSizeMB) {
|
||||||
|
showAppSnackbar(
|
||||||
|
title: "Error",
|
||||||
|
message: "File size exceeds $maxSizeMB MB limit",
|
||||||
|
type: SnackbarType.error,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final payload = {
|
||||||
|
"id": widget.documentData["id"],
|
||||||
|
"documentId": _docIdController.text.trim(),
|
||||||
|
"name": _docNameController.text.trim(),
|
||||||
|
"description": _descriptionController.text.trim(),
|
||||||
|
"documentTypeId": controller.selectedType?.id,
|
||||||
|
"tags": controller.enteredTags
|
||||||
|
.map((t) => {"name": t, "isActive": true})
|
||||||
|
.toList(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ✅ Always include attachment logic
|
||||||
|
if (fileChanged) {
|
||||||
|
if (selectedFile != null) {
|
||||||
|
// User picked new file
|
||||||
|
payload["attachment"] = {
|
||||||
|
"fileName": controller.selectedFileName,
|
||||||
|
"base64Data": controller.selectedFileBase64,
|
||||||
|
"contentType": controller.selectedFileContentType,
|
||||||
|
"fileSize": controller.selectedFileSize,
|
||||||
|
"isActive": true,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// User explicitly removed file
|
||||||
|
payload["attachment"] = null;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// ✅ User did NOT touch the attachment → send null explicitly
|
||||||
|
payload["attachment"] = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// else: do nothing → existing attachment remains as is
|
||||||
|
|
||||||
|
final success = await controller.editDocument(payload);
|
||||||
|
if (success) {
|
||||||
|
widget.onSubmit(payload);
|
||||||
|
Navigator.pop(context);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<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;
|
||||||
|
fileChanged = true;
|
||||||
|
controller.selectedFileName = fileName;
|
||||||
|
controller.selectedFileBase64 = base64Data;
|
||||||
|
controller.selectedFileContentType =
|
||||||
|
result.files.single.extension?.toLowerCase() == "pdf"
|
||||||
|
? "application/pdf"
|
||||||
|
: "image/${result.files.single.extension?.toLowerCase()}";
|
||||||
|
controller.selectedFileSize = (fileBytes.length / 1024).round();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return BaseBottomSheet(
|
||||||
|
title: "Edit Document",
|
||||||
|
onCancel: () => Navigator.pop(context),
|
||||||
|
onSubmit: _handleSubmit,
|
||||||
|
child: Form(
|
||||||
|
key: _formKey,
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
MySpacing.height(16),
|
||||||
|
|
||||||
|
/// Document ID
|
||||||
|
LabeledInput(
|
||||||
|
label: "Document ID",
|
||||||
|
hint: "Enter Document ID",
|
||||||
|
controller: _docIdController,
|
||||||
|
validator: (v) =>
|
||||||
|
v == null || v.trim().isEmpty ? "Required" : null,
|
||||||
|
isRequired: true,
|
||||||
|
),
|
||||||
|
MySpacing.height(16),
|
||||||
|
|
||||||
|
/// Document Name
|
||||||
|
LabeledInput(
|
||||||
|
label: "Document Name",
|
||||||
|
hint: "e.g., PAN Card",
|
||||||
|
controller: _docNameController,
|
||||||
|
validator: (v) =>
|
||||||
|
v == null || v.trim().isEmpty ? "Required" : null,
|
||||||
|
isRequired: true,
|
||||||
|
),
|
||||||
|
MySpacing.height(16),
|
||||||
|
|
||||||
|
/// Document Category (Read-only, non-editable)
|
||||||
|
LabeledInput(
|
||||||
|
label: "Document Category",
|
||||||
|
hint: "",
|
||||||
|
controller: TextEditingController(
|
||||||
|
text: controller.selectedCategory?.name ?? ""),
|
||||||
|
validator: (_) => null,
|
||||||
|
isRequired: false,
|
||||||
|
// Disable interaction
|
||||||
|
readOnly: true,
|
||||||
|
),
|
||||||
|
|
||||||
|
MySpacing.height(16),
|
||||||
|
|
||||||
|
/// Document Type (Read-only, non-editable)
|
||||||
|
LabeledInput(
|
||||||
|
label: "Document Type",
|
||||||
|
hint: "",
|
||||||
|
controller: TextEditingController(
|
||||||
|
text: controller.selectedType?.name ?? ""),
|
||||||
|
validator: (_) => null,
|
||||||
|
isRequired: false,
|
||||||
|
readOnly: true,
|
||||||
|
),
|
||||||
|
|
||||||
|
MySpacing.height(24),
|
||||||
|
|
||||||
|
/// Attachment Section
|
||||||
|
AttachmentSectionSingle(
|
||||||
|
attachmentFile: selectedFile,
|
||||||
|
attachmentUrl: latestVersionUrl,
|
||||||
|
onPick: _pickFile,
|
||||||
|
onRemove: () => setState(() {
|
||||||
|
selectedFile = null;
|
||||||
|
fileChanged = true;
|
||||||
|
controller.selectedFileName = null;
|
||||||
|
controller.selectedFileBase64 = null;
|
||||||
|
controller.selectedFileContentType = null;
|
||||||
|
controller.selectedFileSize = null;
|
||||||
|
latestVersionUrl = null;
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
|
||||||
|
if (controller.selectedType?.maxSizeAllowedInMB != null)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 8),
|
||||||
|
child: Text(
|
||||||
|
"Max file size: ${controller.selectedType!.maxSizeAllowedInMB} MB",
|
||||||
|
style: const TextStyle(color: Colors.grey, fontSize: 12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
MySpacing.height(16),
|
||||||
|
|
||||||
|
/// Tags Section
|
||||||
|
MyText.labelMedium("Tags"),
|
||||||
|
MySpacing.height(8),
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
height: 56,
|
||||||
|
child: TextFormField(
|
||||||
|
controller: controller.tagCtrl,
|
||||||
|
onChanged: controller.filterSuggestions,
|
||||||
|
onFieldSubmitted: (v) {
|
||||||
|
controller.addEnteredTag(v);
|
||||||
|
controller.tagCtrl.clear();
|
||||||
|
controller.clearSuggestions();
|
||||||
|
},
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: "Start typing to add tags",
|
||||||
|
hintStyle: MyTextStyle.bodySmall(xMuted: true),
|
||||||
|
filled: true,
|
||||||
|
fillColor: Colors.grey.shade100,
|
||||||
|
border: _inputBorder(),
|
||||||
|
enabledBorder: _inputBorder(),
|
||||||
|
focusedBorder: _inputFocusedBorder(),
|
||||||
|
contentPadding: MySpacing.all(16),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Obx(() => controller.filteredSuggestions.isEmpty
|
||||||
|
? const SizedBox.shrink()
|
||||||
|
: Container(
|
||||||
|
margin: const EdgeInsets.only(top: 4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
border: Border.all(color: Colors.grey.shade300),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
boxShadow: const [
|
||||||
|
BoxShadow(color: Colors.black12, blurRadius: 4),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: ListView.builder(
|
||||||
|
shrinkWrap: true,
|
||||||
|
itemCount: controller.filteredSuggestions.length,
|
||||||
|
itemBuilder: (_, i) {
|
||||||
|
final suggestion =
|
||||||
|
controller.filteredSuggestions[i];
|
||||||
|
return ListTile(
|
||||||
|
dense: true,
|
||||||
|
title: Text(suggestion),
|
||||||
|
onTap: () {
|
||||||
|
controller.addEnteredTag(suggestion);
|
||||||
|
controller.tagCtrl.clear();
|
||||||
|
controller.clearSuggestions();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)),
|
||||||
|
MySpacing.height(8),
|
||||||
|
Obx(() => Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
children: controller.enteredTags
|
||||||
|
.map((tag) => Chip(
|
||||||
|
label: Text(tag),
|
||||||
|
onDeleted: () =>
|
||||||
|
controller.removeEnteredTag(tag),
|
||||||
|
))
|
||||||
|
.toList(),
|
||||||
|
)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
MySpacing.height(16),
|
||||||
|
|
||||||
|
/// Description
|
||||||
|
LabeledInput(
|
||||||
|
label: "Description",
|
||||||
|
hint: "Enter short description",
|
||||||
|
controller: _descriptionController,
|
||||||
|
validator: (v) =>
|
||||||
|
v == null || v.trim().isEmpty ? "Required" : null,
|
||||||
|
isRequired: true,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
OutlineInputBorder _inputBorder() => OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: BorderSide(color: Colors.grey.shade300),
|
||||||
|
);
|
||||||
|
|
||||||
|
OutlineInputBorder _inputFocusedBorder() => const OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.all(Radius.circular(12)),
|
||||||
|
borderSide: BorderSide(color: Colors.blueAccent, width: 1.5),
|
||||||
|
);
|
||||||
|
|
||||||
|
/// ---------------- Single Attachment Widget (Rewritten) ----------------
|
||||||
|
class AttachmentSectionSingle extends StatelessWidget {
|
||||||
|
final File? attachmentFile; // Local file
|
||||||
|
final String? attachmentUrl; // Online latest version URL
|
||||||
|
final VoidCallback onPick;
|
||||||
|
final VoidCallback? onRemove;
|
||||||
|
|
||||||
|
const AttachmentSectionSingle({
|
||||||
|
Key? key,
|
||||||
|
this.attachmentFile,
|
||||||
|
this.attachmentUrl,
|
||||||
|
required this.onPick,
|
||||||
|
this.onRemove,
|
||||||
|
}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final allowedImageExtensions = ['jpg', 'jpeg', 'png'];
|
||||||
|
|
||||||
|
Widget buildTile({File? file, String? url}) {
|
||||||
|
final isImage = file != null
|
||||||
|
? allowedImageExtensions
|
||||||
|
.contains(file.path.split('.').last.toLowerCase())
|
||||||
|
: url != null
|
||||||
|
? allowedImageExtensions
|
||||||
|
.contains(url.split('.').last.toLowerCase())
|
||||||
|
: false;
|
||||||
|
|
||||||
|
final fileName = file != null
|
||||||
|
? file.path.split('/').last
|
||||||
|
: url != null
|
||||||
|
? url.split('/').last
|
||||||
|
: '';
|
||||||
|
|
||||||
|
IconData fileIcon = Icons.insert_drive_file;
|
||||||
|
Color iconColor = Colors.blueGrey;
|
||||||
|
|
||||||
|
if (!isImage) {
|
||||||
|
final ext = fileName.split('.').last.toLowerCase();
|
||||||
|
switch (ext) {
|
||||||
|
case 'pdf':
|
||||||
|
fileIcon = Icons.picture_as_pdf;
|
||||||
|
iconColor = Colors.redAccent;
|
||||||
|
break;
|
||||||
|
case 'doc':
|
||||||
|
case 'docx':
|
||||||
|
fileIcon = Icons.description;
|
||||||
|
iconColor = Colors.blueAccent;
|
||||||
|
break;
|
||||||
|
case 'xls':
|
||||||
|
case 'xlsx':
|
||||||
|
fileIcon = Icons.table_chart;
|
||||||
|
iconColor = Colors.green;
|
||||||
|
break;
|
||||||
|
case 'txt':
|
||||||
|
fileIcon = Icons.article;
|
||||||
|
iconColor = Colors.grey;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Stack(
|
||||||
|
clipBehavior: Clip.none,
|
||||||
|
children: [
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () async {
|
||||||
|
if (isImage && file != null) {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (_) => ImageViewerDialog(
|
||||||
|
imageSources: [file],
|
||||||
|
initialIndex: 0,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else if (url != null) {
|
||||||
|
final uri = Uri.parse(url);
|
||||||
|
if (await canLaunchUrl(uri)) {
|
||||||
|
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||||
|
} else {
|
||||||
|
showAppSnackbar(
|
||||||
|
title: "Error",
|
||||||
|
message: "Could not open document",
|
||||||
|
type: SnackbarType.error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: Container(
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
margin: const EdgeInsets.only(right: 8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border.all(color: Colors.grey.shade300),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
color: Colors.grey.shade100,
|
||||||
|
),
|
||||||
|
child: isImage && file != null
|
||||||
|
? ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: Image.file(file, fit: BoxFit.cover),
|
||||||
|
)
|
||||||
|
: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(fileIcon, color: iconColor, size: 30),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (onRemove != null)
|
||||||
|
Positioned(
|
||||||
|
top: -6,
|
||||||
|
right: -6,
|
||||||
|
child: IconButton(
|
||||||
|
icon: const Icon(Icons.close, color: Colors.red, size: 18),
|
||||||
|
onPressed: onRemove,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min, // prevent overflow
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: const [
|
||||||
|
Text("Attachment", style: TextStyle(fontWeight: FontWeight.w600)),
|
||||||
|
Text(" *",
|
||||||
|
style:
|
||||||
|
TextStyle(color: Colors.red, fontWeight: FontWeight.bold))
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
SingleChildScrollView(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
if (attachmentFile != null)
|
||||||
|
buildTile(file: attachmentFile)
|
||||||
|
else if (attachmentUrl != null)
|
||||||
|
buildTile(url: attachmentUrl)
|
||||||
|
else
|
||||||
|
GestureDetector(
|
||||||
|
onTap: onPick,
|
||||||
|
child: Container(
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border.all(color: Colors.grey.shade300),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
color: Colors.grey.shade100,
|
||||||
|
),
|
||||||
|
child: const Icon(Icons.add, size: 40, color: Colors.grey),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Reusable Widgets ----
|
||||||
|
|
||||||
|
class LabeledInput extends StatelessWidget {
|
||||||
|
final String label;
|
||||||
|
final String hint;
|
||||||
|
final TextEditingController controller;
|
||||||
|
final String? Function(String?) validator;
|
||||||
|
final bool isRequired;
|
||||||
|
final bool readOnly; // <-- Add this
|
||||||
|
|
||||||
|
const LabeledInput({
|
||||||
|
Key? key,
|
||||||
|
required this.label,
|
||||||
|
required this.hint,
|
||||||
|
required this.controller,
|
||||||
|
required this.validator,
|
||||||
|
this.isRequired = false,
|
||||||
|
this.readOnly = false, // default false
|
||||||
|
}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
MyText.labelMedium(label),
|
||||||
|
if (isRequired)
|
||||||
|
const Text(
|
||||||
|
" *",
|
||||||
|
style:
|
||||||
|
TextStyle(color: Colors.red, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
MySpacing.height(8),
|
||||||
|
TextFormField(
|
||||||
|
controller: controller,
|
||||||
|
validator: validator,
|
||||||
|
readOnly: readOnly, // <-- Use the new property here
|
||||||
|
decoration: _inputDecoration(context, hint),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
InputDecoration _inputDecoration(BuildContext context, String hint) =>
|
||||||
|
InputDecoration(
|
||||||
|
hintText: hint,
|
||||||
|
hintStyle: MyTextStyle.bodySmall(xMuted: true),
|
||||||
|
filled: true,
|
||||||
|
fillColor: Colors.grey.shade100,
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: BorderSide(color: Colors.grey.shade300),
|
||||||
|
),
|
||||||
|
enabledBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: BorderSide(color: Colors.grey.shade300),
|
||||||
|
),
|
||||||
|
focusedBorder: const OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.all(Radius.circular(12)),
|
||||||
|
borderSide: BorderSide(color: Colors.blueAccent, width: 1.5),
|
||||||
|
),
|
||||||
|
contentPadding: MySpacing.all(16),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class LabeledDropdown extends StatefulWidget {
|
||||||
|
final String label;
|
||||||
|
final String hint;
|
||||||
|
final String? value;
|
||||||
|
final List<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"),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
776
lib/model/document/document_upload_bottom_sheet.dart
Normal file
776
lib/model/document/document_upload_bottom_sheet.dart
Normal file
@ -0,0 +1,776 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'package:file_picker/file_picker.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
import 'package:marco/controller/document/document_upload_controller.dart';
|
||||||
|
import 'package:marco/helpers/utils/base_bottom_sheet.dart';
|
||||||
|
import 'package:marco/helpers/widgets/my_spacing.dart';
|
||||||
|
import 'package:marco/helpers/widgets/my_text.dart';
|
||||||
|
import 'package:marco/helpers/widgets/my_text_style.dart';
|
||||||
|
import 'package:marco/helpers/widgets/image_viewer_dialog.dart';
|
||||||
|
import 'package:marco/helpers/widgets/my_snackbar.dart';
|
||||||
|
|
||||||
|
class DocumentUploadBottomSheet extends StatefulWidget {
|
||||||
|
final Function(Map<String, dynamic>) onSubmit;
|
||||||
|
final bool isEmployee;
|
||||||
|
const DocumentUploadBottomSheet({
|
||||||
|
Key? key,
|
||||||
|
required this.onSubmit,
|
||||||
|
this.isEmployee = false,
|
||||||
|
}) : 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;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_docIdController.dispose();
|
||||||
|
_docNameController.dispose();
|
||||||
|
_descriptionController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleSubmit() {
|
||||||
|
final formState = _formKey.currentState;
|
||||||
|
|
||||||
|
// 1️⃣ Validate form fields
|
||||||
|
if (!(formState?.validate() ?? false)) {
|
||||||
|
// Collect first validation error
|
||||||
|
final errorFields = [
|
||||||
|
{"label": "Document ID", "value": _docIdController.text.trim()},
|
||||||
|
{"label": "Document Name", "value": _docNameController.text.trim()},
|
||||||
|
{"label": "Description", "value": _descriptionController.text.trim()},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (var field in errorFields) {
|
||||||
|
if (field["value"] == null || (field["value"] as String).isEmpty) {
|
||||||
|
showAppSnackbar(
|
||||||
|
title: "Error",
|
||||||
|
message: "${field["label"]} is required",
|
||||||
|
type: SnackbarType.error,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2️⃣ Validate file attachment
|
||||||
|
if (selectedFile == null) {
|
||||||
|
showAppSnackbar(
|
||||||
|
title: "Error",
|
||||||
|
message: "Please attach a document",
|
||||||
|
type: SnackbarType.error,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3️⃣ Validate document category based on employee/project
|
||||||
|
if (controller.selectedCategory != null) {
|
||||||
|
final selectedCategoryName = controller.selectedCategory!.name;
|
||||||
|
|
||||||
|
if (widget.isEmployee && selectedCategoryName != 'Employee Documents') {
|
||||||
|
showAppSnackbar(
|
||||||
|
title: "Error",
|
||||||
|
message:
|
||||||
|
"Only 'Employee Documents' can be uploaded from the Employee screen. Please select the correct document type.",
|
||||||
|
type: SnackbarType.error,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
} else if (!widget.isEmployee &&
|
||||||
|
selectedCategoryName != 'Project Documents') {
|
||||||
|
showAppSnackbar(
|
||||||
|
title: "Error",
|
||||||
|
message:
|
||||||
|
"Only 'Project Documents' can be uploaded from the Project screen. Please select the correct document type.",
|
||||||
|
type: SnackbarType.error,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
showAppSnackbar(
|
||||||
|
title: "Error",
|
||||||
|
message: "Please select a Document Category before uploading.",
|
||||||
|
type: SnackbarType.error,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4️⃣ Validate file size
|
||||||
|
final maxSizeMB = controller.selectedType?.maxSizeAllowedInMB;
|
||||||
|
if (maxSizeMB != null && controller.selectedFileSize != null) {
|
||||||
|
final fileSizeMB = controller.selectedFileSize! / (1024 * 1024);
|
||||||
|
if (fileSizeMB > maxSizeMB) {
|
||||||
|
showAppSnackbar(
|
||||||
|
title: "Error",
|
||||||
|
message: "File size exceeds $maxSizeMB MB limit",
|
||||||
|
type: SnackbarType.error,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5️⃣ Validate file type
|
||||||
|
final allowedType = controller.selectedType?.allowedContentType;
|
||||||
|
if (allowedType != null && controller.selectedFileContentType != null) {
|
||||||
|
if (!allowedType
|
||||||
|
.toLowerCase()
|
||||||
|
.contains(controller.selectedFileContentType!.toLowerCase())) {
|
||||||
|
showAppSnackbar(
|
||||||
|
title: "Error",
|
||||||
|
message: "Only $allowedType files are allowed for this type",
|
||||||
|
type: SnackbarType.error,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6️⃣ Prepare payload
|
||||||
|
final payload = {
|
||||||
|
"documentId": _docIdController.text.trim(),
|
||||||
|
"name": _docNameController.text.trim(),
|
||||||
|
"description": _descriptionController.text.trim(),
|
||||||
|
"documentTypeId": controller.selectedType?.id,
|
||||||
|
"attachment": {
|
||||||
|
"fileName": controller.selectedFileName,
|
||||||
|
"base64Data": controller.selectedFileBase64,
|
||||||
|
"contentType": controller.selectedFileContentType,
|
||||||
|
"fileSize": controller.selectedFileSize,
|
||||||
|
"isActive": true,
|
||||||
|
},
|
||||||
|
"tags": controller.enteredTags
|
||||||
|
.map((t) => {"name": t, "isActive": true})
|
||||||
|
.toList(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// 7️⃣ Submit
|
||||||
|
widget.onSubmit(payload);
|
||||||
|
|
||||||
|
// 8️⃣ Show success message
|
||||||
|
showAppSnackbar(
|
||||||
|
title: "Success",
|
||||||
|
message: "Document submitted successfully",
|
||||||
|
type: SnackbarType.success,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<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;
|
||||||
|
controller.selectedFileName = fileName;
|
||||||
|
controller.selectedFileBase64 = base64Data;
|
||||||
|
controller.selectedFileContentType =
|
||||||
|
result.files.single.extension?.toLowerCase() == "pdf"
|
||||||
|
? "application/pdf"
|
||||||
|
: "image/${result.files.single.extension?.toLowerCase()}";
|
||||||
|
controller.selectedFileSize = (fileBytes.length / 1024).round();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return BaseBottomSheet(
|
||||||
|
title: "Upload Document",
|
||||||
|
onCancel: () => Navigator.pop(context),
|
||||||
|
onSubmit: _handleSubmit,
|
||||||
|
child: Form(
|
||||||
|
key: _formKey,
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
MySpacing.height(16),
|
||||||
|
|
||||||
|
/// Document ID
|
||||||
|
LabeledInput(
|
||||||
|
label: "Document ID",
|
||||||
|
hint: "Enter Document ID",
|
||||||
|
controller: _docIdController,
|
||||||
|
validator: (value) {
|
||||||
|
if (value == null || value.trim().isEmpty) {
|
||||||
|
return "Required";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ Regex validation if enabled
|
||||||
|
final selectedType = controller.selectedType;
|
||||||
|
if (selectedType != null &&
|
||||||
|
selectedType.isValidationRequired &&
|
||||||
|
selectedType.regexExpression != null &&
|
||||||
|
selectedType.regexExpression!.isNotEmpty) {
|
||||||
|
final regExp = RegExp(selectedType.regexExpression!);
|
||||||
|
if (!regExp.hasMatch(value.trim())) {
|
||||||
|
return "Invalid ${selectedType.name} format";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
isRequired: true,
|
||||||
|
),
|
||||||
|
|
||||||
|
MySpacing.height(16),
|
||||||
|
|
||||||
|
/// Document Name
|
||||||
|
LabeledInput(
|
||||||
|
label: "Document Name",
|
||||||
|
hint: "e.g., PAN Card",
|
||||||
|
controller: _docNameController,
|
||||||
|
validator: (value) =>
|
||||||
|
value == null || value.trim().isEmpty ? "Required" : null,
|
||||||
|
isRequired: true,
|
||||||
|
),
|
||||||
|
MySpacing.height(16),
|
||||||
|
|
||||||
|
/// Document Category
|
||||||
|
Obx(() {
|
||||||
|
if (controller.isLoading.value &&
|
||||||
|
controller.categories.isEmpty) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
|
||||||
|
return LabeledDropdown(
|
||||||
|
label: "Document Category",
|
||||||
|
hint: "Select Category",
|
||||||
|
value: controller.selectedCategory?.name,
|
||||||
|
items: controller.categories.map((c) => c.name).toList(),
|
||||||
|
onChanged: (selected) async {
|
||||||
|
final category = controller.categories
|
||||||
|
.firstWhere((c) => c.name == selected);
|
||||||
|
setState(() => controller.selectedCategory = category);
|
||||||
|
await controller.fetchDocumentTypes(category.id);
|
||||||
|
},
|
||||||
|
isRequired: true,
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
MySpacing.height(16),
|
||||||
|
|
||||||
|
/// Document Type
|
||||||
|
Obx(() {
|
||||||
|
if (controller.documentTypes.isEmpty) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
return LabeledDropdown(
|
||||||
|
label: "Document Type",
|
||||||
|
hint: "Select Type",
|
||||||
|
value: controller.selectedType?.name,
|
||||||
|
items: controller.documentTypes.map((t) => t.name).toList(),
|
||||||
|
onChanged: (selected) {
|
||||||
|
final type = controller.documentTypes
|
||||||
|
.firstWhere((t) => t.name == selected);
|
||||||
|
setState(() => controller.selectedType = type);
|
||||||
|
},
|
||||||
|
isRequired: true,
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
MySpacing.height(24),
|
||||||
|
|
||||||
|
/// Single Attachment Section
|
||||||
|
AttachmentSectionSingle(
|
||||||
|
attachment: selectedFile,
|
||||||
|
onPick: _pickFile,
|
||||||
|
onRemove: () => setState(() {
|
||||||
|
selectedFile = null;
|
||||||
|
controller.selectedFileName = null;
|
||||||
|
controller.selectedFileBase64 = null;
|
||||||
|
controller.selectedFileContentType = null;
|
||||||
|
controller.selectedFileSize = null;
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
|
||||||
|
if (controller.selectedType?.maxSizeAllowedInMB != null)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 8),
|
||||||
|
child: Text(
|
||||||
|
"Max file size: ${controller.selectedType!.maxSizeAllowedInMB} MB",
|
||||||
|
style: const TextStyle(color: Colors.grey, fontSize: 12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
MySpacing.height(16),
|
||||||
|
|
||||||
|
/// Tags
|
||||||
|
MyText.labelMedium("Tags"),
|
||||||
|
MySpacing.height(8),
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
height: 56,
|
||||||
|
child: TextFormField(
|
||||||
|
controller: controller.tagCtrl,
|
||||||
|
onChanged: controller.filterSuggestions,
|
||||||
|
onFieldSubmitted: (v) {
|
||||||
|
controller.addEnteredTag(v);
|
||||||
|
controller.tagCtrl.clear();
|
||||||
|
controller.clearSuggestions();
|
||||||
|
},
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: "Start typing to add tags",
|
||||||
|
hintStyle: MyTextStyle.bodySmall(xMuted: true),
|
||||||
|
filled: true,
|
||||||
|
fillColor: Colors.grey.shade100,
|
||||||
|
border: _inputBorder(),
|
||||||
|
enabledBorder: _inputBorder(),
|
||||||
|
focusedBorder: _inputFocusedBorder(),
|
||||||
|
contentPadding: MySpacing.all(16),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Obx(() => controller.filteredSuggestions.isEmpty
|
||||||
|
? const SizedBox.shrink()
|
||||||
|
: Container(
|
||||||
|
margin: const EdgeInsets.only(top: 4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
border: Border.all(color: Colors.grey.shade300),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
boxShadow: const [
|
||||||
|
BoxShadow(color: Colors.black12, blurRadius: 4),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: ListView.builder(
|
||||||
|
shrinkWrap: true,
|
||||||
|
itemCount: controller.filteredSuggestions.length,
|
||||||
|
itemBuilder: (_, i) {
|
||||||
|
final suggestion =
|
||||||
|
controller.filteredSuggestions[i];
|
||||||
|
return ListTile(
|
||||||
|
dense: true,
|
||||||
|
title: Text(suggestion),
|
||||||
|
onTap: () {
|
||||||
|
controller.addEnteredTag(suggestion);
|
||||||
|
controller.tagCtrl.clear();
|
||||||
|
controller.clearSuggestions();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)),
|
||||||
|
MySpacing.height(8),
|
||||||
|
Obx(() => Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
children: controller.enteredTags
|
||||||
|
.map((tag) => Chip(
|
||||||
|
label: Text(tag),
|
||||||
|
onDeleted: () =>
|
||||||
|
controller.removeEnteredTag(tag),
|
||||||
|
))
|
||||||
|
.toList(),
|
||||||
|
)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
MySpacing.height(16),
|
||||||
|
|
||||||
|
/// Description
|
||||||
|
LabeledInput(
|
||||||
|
label: "Description",
|
||||||
|
hint: "Enter short description",
|
||||||
|
controller: _descriptionController,
|
||||||
|
validator: (value) =>
|
||||||
|
value == null || value.trim().isEmpty ? "Required" : null,
|
||||||
|
isRequired: true,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
OutlineInputBorder _inputBorder() => OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: BorderSide(color: Colors.grey.shade300),
|
||||||
|
);
|
||||||
|
|
||||||
|
OutlineInputBorder _inputFocusedBorder() => const OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.all(Radius.circular(12)),
|
||||||
|
borderSide: BorderSide(color: Colors.blueAccent, width: 1.5),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ---------------- Single Attachment Widget ----------------
|
||||||
|
class AttachmentSectionSingle extends StatelessWidget {
|
||||||
|
final File? attachment;
|
||||||
|
final VoidCallback onPick;
|
||||||
|
final VoidCallback? onRemove;
|
||||||
|
|
||||||
|
const AttachmentSectionSingle({
|
||||||
|
Key? key,
|
||||||
|
this.attachment,
|
||||||
|
required this.onPick,
|
||||||
|
this.onRemove,
|
||||||
|
}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final allowedImageExtensions = ['jpg', 'jpeg', 'png'];
|
||||||
|
|
||||||
|
Widget buildTile(File file) {
|
||||||
|
final isImage = allowedImageExtensions
|
||||||
|
.contains(file.path.split('.').last.toLowerCase());
|
||||||
|
|
||||||
|
final fileName = file.path.split('/').last;
|
||||||
|
|
||||||
|
IconData fileIcon = Icons.insert_drive_file;
|
||||||
|
Color iconColor = Colors.blueGrey;
|
||||||
|
|
||||||
|
if (!isImage) {
|
||||||
|
final ext = fileName.split('.').last.toLowerCase();
|
||||||
|
switch (ext) {
|
||||||
|
case 'pdf':
|
||||||
|
fileIcon = Icons.picture_as_pdf;
|
||||||
|
iconColor = Colors.redAccent;
|
||||||
|
break;
|
||||||
|
case 'doc':
|
||||||
|
case 'docx':
|
||||||
|
fileIcon = Icons.description;
|
||||||
|
iconColor = Colors.blueAccent;
|
||||||
|
break;
|
||||||
|
case 'xls':
|
||||||
|
case 'xlsx':
|
||||||
|
fileIcon = Icons.table_chart;
|
||||||
|
iconColor = Colors.green;
|
||||||
|
break;
|
||||||
|
case 'txt':
|
||||||
|
fileIcon = Icons.article;
|
||||||
|
iconColor = Colors.grey;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Stack(
|
||||||
|
clipBehavior: Clip.none,
|
||||||
|
children: [
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () {
|
||||||
|
if (isImage) {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (_) => ImageViewerDialog(
|
||||||
|
imageSources: [file],
|
||||||
|
initialIndex: 0,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: Container(
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border.all(color: Colors.grey.shade300),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
color: Colors.grey.shade100,
|
||||||
|
),
|
||||||
|
child: isImage
|
||||||
|
? ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: Image.file(file, fit: BoxFit.cover),
|
||||||
|
)
|
||||||
|
: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(fileIcon, color: iconColor, size: 30),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
fileName.split('.').last.toUpperCase(),
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: iconColor),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (onRemove != null)
|
||||||
|
Positioned(
|
||||||
|
top: -6,
|
||||||
|
right: -6,
|
||||||
|
child: IconButton(
|
||||||
|
icon: const Icon(Icons.close, color: Colors.red, size: 18),
|
||||||
|
onPressed: onRemove,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: const [
|
||||||
|
Text("Attachment", style: TextStyle(fontWeight: FontWeight.w600)),
|
||||||
|
Text(" *",
|
||||||
|
style:
|
||||||
|
TextStyle(color: Colors.red, fontWeight: FontWeight.bold))
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
if (attachment != null)
|
||||||
|
buildTile(attachment!)
|
||||||
|
else
|
||||||
|
GestureDetector(
|
||||||
|
onTap: onPick,
|
||||||
|
child: Container(
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border.all(color: Colors.grey.shade300),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
color: Colors.grey.shade100,
|
||||||
|
),
|
||||||
|
child: const Icon(Icons.add, size: 40, color: Colors.grey),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Reusable Widgets ----
|
||||||
|
|
||||||
|
class LabeledInput extends StatelessWidget {
|
||||||
|
final String label;
|
||||||
|
final String hint;
|
||||||
|
final TextEditingController controller;
|
||||||
|
final String? Function(String?) validator;
|
||||||
|
final bool isRequired;
|
||||||
|
|
||||||
|
const LabeledInput({
|
||||||
|
Key? key,
|
||||||
|
required this.label,
|
||||||
|
required this.hint,
|
||||||
|
required this.controller,
|
||||||
|
required this.validator,
|
||||||
|
this.isRequired = false,
|
||||||
|
}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
MyText.labelMedium(label),
|
||||||
|
if (isRequired)
|
||||||
|
const Text(
|
||||||
|
" *",
|
||||||
|
style:
|
||||||
|
TextStyle(color: Colors.red, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
MySpacing.height(8),
|
||||||
|
TextFormField(
|
||||||
|
controller: controller,
|
||||||
|
validator: validator,
|
||||||
|
decoration: _inputDecoration(context, hint),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
InputDecoration _inputDecoration(BuildContext context, String hint) =>
|
||||||
|
InputDecoration(
|
||||||
|
hintText: hint,
|
||||||
|
hintStyle: MyTextStyle.bodySmall(xMuted: true),
|
||||||
|
filled: true,
|
||||||
|
fillColor: Colors.grey.shade100,
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: BorderSide(color: Colors.grey.shade300),
|
||||||
|
),
|
||||||
|
enabledBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: BorderSide(color: Colors.grey.shade300),
|
||||||
|
),
|
||||||
|
focusedBorder: const OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.all(Radius.circular(12)),
|
||||||
|
borderSide: BorderSide(color: Colors.blueAccent, width: 1.5),
|
||||||
|
),
|
||||||
|
contentPadding: MySpacing.all(16),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class LabeledDropdown extends StatefulWidget {
|
||||||
|
final String label;
|
||||||
|
final String hint;
|
||||||
|
final String? value;
|
||||||
|
final List<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'] ?? "",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
294
lib/model/document/documents_list_model.dart
Normal file
294
lib/model/document/documents_list_model.dart
Normal file
@ -0,0 +1,294 @@
|
|||||||
|
class DocumentsResponse {
|
||||||
|
final bool success;
|
||||||
|
final String message;
|
||||||
|
final DocumentDataWrapper data;
|
||||||
|
final dynamic errors;
|
||||||
|
final int statusCode;
|
||||||
|
final DateTime timestamp;
|
||||||
|
|
||||||
|
DocumentsResponse({
|
||||||
|
required this.success,
|
||||||
|
required this.message,
|
||||||
|
required this.data,
|
||||||
|
this.errors,
|
||||||
|
required this.statusCode,
|
||||||
|
required this.timestamp,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory DocumentsResponse.fromJson(Map<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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension DocumentItemCopy on DocumentItem {
|
||||||
|
DocumentItem copyWith({
|
||||||
|
bool? isActive,
|
||||||
|
}) {
|
||||||
|
return DocumentItem(
|
||||||
|
id: id,
|
||||||
|
name: name,
|
||||||
|
documentId: documentId,
|
||||||
|
description: description,
|
||||||
|
uploadedAt: uploadedAt,
|
||||||
|
parentAttachmentId: parentAttachmentId,
|
||||||
|
isCurrentVersion: isCurrentVersion,
|
||||||
|
version: version,
|
||||||
|
isActive: isActive ?? this.isActive,
|
||||||
|
isVerified: isVerified,
|
||||||
|
uploadedBy: uploadedBy,
|
||||||
|
documentType: documentType,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class UploadedBy {
|
||||||
|
final String id;
|
||||||
|
final String firstName;
|
||||||
|
final String? lastName;
|
||||||
|
final String? photo;
|
||||||
|
final String jobRoleId;
|
||||||
|
final String jobRoleName;
|
||||||
|
|
||||||
|
UploadedBy({
|
||||||
|
required this.id,
|
||||||
|
required this.firstName,
|
||||||
|
this.lastName,
|
||||||
|
this.photo,
|
||||||
|
required this.jobRoleId,
|
||||||
|
required this.jobRoleName,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory UploadedBy.fromJson(Map<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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -8,6 +8,9 @@ import 'package:marco/helpers/widgets/my_spacing.dart';
|
|||||||
import 'package:marco/helpers/widgets/my_text.dart';
|
import 'package:marco/helpers/widgets/my_text.dart';
|
||||||
import 'package:marco/helpers/widgets/my_text_style.dart';
|
import 'package:marco/helpers/widgets/my_text_style.dart';
|
||||||
import 'package:marco/helpers/utils/base_bottom_sheet.dart';
|
import 'package:marco/helpers/utils/base_bottom_sheet.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
import 'package:marco/helpers/widgets/my_snackbar.dart';
|
||||||
|
|
||||||
|
|
||||||
class AddEmployeeBottomSheet extends StatefulWidget {
|
class AddEmployeeBottomSheet extends StatefulWidget {
|
||||||
@override
|
@override
|
||||||
@ -54,6 +57,18 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
|
|||||||
_controller.basicValidator.getValidation('last_name'),
|
_controller.basicValidator.getValidation('last_name'),
|
||||||
),
|
),
|
||||||
MySpacing.height(16),
|
MySpacing.height(16),
|
||||||
|
_sectionLabel("Joining Details"),
|
||||||
|
MySpacing.height(16),
|
||||||
|
_buildDatePickerField(
|
||||||
|
label: "Joining Date",
|
||||||
|
value: _controller.joiningDate != null
|
||||||
|
? DateFormat("dd MMM yyyy")
|
||||||
|
.format(_controller.joiningDate!)
|
||||||
|
: "",
|
||||||
|
hint: "Select Joining Date",
|
||||||
|
onTap: () => _pickJoiningDate(context),
|
||||||
|
),
|
||||||
|
MySpacing.height(16),
|
||||||
_sectionLabel("Contact Details"),
|
_sectionLabel("Contact Details"),
|
||||||
MySpacing.height(16),
|
MySpacing.height(16),
|
||||||
_buildPhoneInput(context),
|
_buildPhoneInput(context),
|
||||||
@ -83,12 +98,113 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Submit logic
|
// --- Common label with red star ---
|
||||||
|
Widget _requiredLabel(String text) {
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
MyText.labelMedium(text),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
const Text("*", style: TextStyle(color: Colors.red)),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Date Picker field ---
|
||||||
|
Widget _buildDatePickerField({
|
||||||
|
required String label,
|
||||||
|
required String value,
|
||||||
|
required String hint,
|
||||||
|
required VoidCallback onTap,
|
||||||
|
}) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_requiredLabel(label),
|
||||||
|
MySpacing.height(8),
|
||||||
|
GestureDetector(
|
||||||
|
onTap: onTap,
|
||||||
|
child: AbsorbPointer(
|
||||||
|
child: TextFormField(
|
||||||
|
readOnly: true,
|
||||||
|
controller: TextEditingController(text: value),
|
||||||
|
validator: (val) {
|
||||||
|
if (val == null || val.trim().isEmpty) {
|
||||||
|
return "$label is required";
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
decoration: _inputDecoration(hint).copyWith(
|
||||||
|
suffixIcon: const Icon(Icons.calendar_today),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pickJoiningDate(BuildContext context) async {
|
||||||
|
final picked = await showDatePicker(
|
||||||
|
context: context,
|
||||||
|
initialDate: DateTime.now(),
|
||||||
|
firstDate: DateTime(2000),
|
||||||
|
lastDate: DateTime(2100),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (picked != null) {
|
||||||
|
_controller.setJoiningDate(picked);
|
||||||
|
_controller.update();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Submit logic ---
|
||||||
Future<void> _handleSubmit() async {
|
Future<void> _handleSubmit() async {
|
||||||
|
// Run form validation first
|
||||||
|
final isValid =
|
||||||
|
_controller.basicValidator.formKey.currentState?.validate() ?? false;
|
||||||
|
|
||||||
|
if (!isValid) {
|
||||||
|
showAppSnackbar(
|
||||||
|
title: "Missing Fields",
|
||||||
|
message: "Please fill all required fields before submitting.",
|
||||||
|
type: SnackbarType.warning,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Additional check for dropdowns & joining date
|
||||||
|
if (_controller.joiningDate == null) {
|
||||||
|
showAppSnackbar(
|
||||||
|
title: "Missing Fields",
|
||||||
|
message: "Please select Joining Date.",
|
||||||
|
type: SnackbarType.warning,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_controller.selectedGender == null) {
|
||||||
|
showAppSnackbar(
|
||||||
|
title: "Missing Fields",
|
||||||
|
message: "Please select Gender.",
|
||||||
|
type: SnackbarType.warning,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_controller.selectedRoleId == null) {
|
||||||
|
showAppSnackbar(
|
||||||
|
title: "Missing Fields",
|
||||||
|
message: "Please select Role.",
|
||||||
|
type: SnackbarType.warning,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// All validations passed → Call API
|
||||||
final result = await _controller.createEmployees();
|
final result = await _controller.createEmployees();
|
||||||
|
|
||||||
if (result != null && result['success'] == true) {
|
if (result != null && result['success'] == true) {
|
||||||
final employeeData = result['data']; // ✅ Safe now
|
final employeeData = result['data'];
|
||||||
final employeeController = Get.find<EmployeesScreenController>();
|
final employeeController = Get.find<EmployeesScreenController>();
|
||||||
final projectId = employeeController.selectedProjectId;
|
final projectId = employeeController.selectedProjectId;
|
||||||
|
|
||||||
@ -100,18 +216,20 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
|
|||||||
|
|
||||||
employeeController.update(['employee_screen_controller']);
|
employeeController.update(['employee_screen_controller']);
|
||||||
|
|
||||||
|
// Reset form
|
||||||
_controller.basicValidator.getController("first_name")?.clear();
|
_controller.basicValidator.getController("first_name")?.clear();
|
||||||
_controller.basicValidator.getController("last_name")?.clear();
|
_controller.basicValidator.getController("last_name")?.clear();
|
||||||
_controller.basicValidator.getController("phone_number")?.clear();
|
_controller.basicValidator.getController("phone_number")?.clear();
|
||||||
_controller.selectedGender = null;
|
_controller.selectedGender = null;
|
||||||
_controller.selectedRoleId = null;
|
_controller.selectedRoleId = null;
|
||||||
|
_controller.joiningDate = null;
|
||||||
_controller.update();
|
_controller.update();
|
||||||
|
|
||||||
Navigator.pop(context, employeeData);
|
Navigator.pop(context, employeeData);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Section label widget
|
// --- Section label widget ---
|
||||||
Widget _sectionLabel(String title) => Column(
|
Widget _sectionLabel(String title) => Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
@ -121,7 +239,7 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Input field with icon
|
// --- Input field with icon ---
|
||||||
Widget _inputWithIcon({
|
Widget _inputWithIcon({
|
||||||
required String label,
|
required String label,
|
||||||
required String hint,
|
required String hint,
|
||||||
@ -132,11 +250,16 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
|
|||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
MyText.labelMedium(label),
|
_requiredLabel(label),
|
||||||
MySpacing.height(8),
|
MySpacing.height(8),
|
||||||
TextFormField(
|
TextFormField(
|
||||||
controller: controller,
|
controller: controller,
|
||||||
validator: validator,
|
validator: (val) {
|
||||||
|
if (val == null || val.trim().isEmpty) {
|
||||||
|
return "$label is required";
|
||||||
|
}
|
||||||
|
return validator?.call(val);
|
||||||
|
},
|
||||||
decoration: _inputDecoration(hint).copyWith(
|
decoration: _inputDecoration(hint).copyWith(
|
||||||
prefixIcon: Icon(icon, size: 20),
|
prefixIcon: Icon(icon, size: 20),
|
||||||
),
|
),
|
||||||
@ -145,12 +268,12 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phone input with country code selector
|
// --- Phone input ---
|
||||||
Widget _buildPhoneInput(BuildContext context) {
|
Widget _buildPhoneInput(BuildContext context) {
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
MyText.labelMedium("Phone Number"),
|
_requiredLabel("Phone Number"),
|
||||||
MySpacing.height(8),
|
MySpacing.height(8),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
@ -161,7 +284,7 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
|
|||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
color: Colors.grey.shade100,
|
color: Colors.grey.shade100,
|
||||||
),
|
),
|
||||||
child: Text("+91"),
|
child: const Text("+91"),
|
||||||
),
|
),
|
||||||
MySpacing.width(12),
|
MySpacing.width(12),
|
||||||
Expanded(
|
Expanded(
|
||||||
@ -170,13 +293,11 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
|
|||||||
_controller.basicValidator.getController('phone_number'),
|
_controller.basicValidator.getController('phone_number'),
|
||||||
validator: (value) {
|
validator: (value) {
|
||||||
if (value == null || value.trim().isEmpty) {
|
if (value == null || value.trim().isEmpty) {
|
||||||
return "Phone number is required";
|
return "Phone Number is required";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!RegExp(r'^\d{10}$').hasMatch(value.trim())) {
|
if (!RegExp(r'^\d{10}$').hasMatch(value.trim())) {
|
||||||
return "Enter a valid 10-digit number";
|
return "Enter a valid 10-digit number";
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
keyboardType: TextInputType.phone,
|
keyboardType: TextInputType.phone,
|
||||||
@ -198,7 +319,7 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Gender/Role field (read-only dropdown)
|
// --- Dropdown (Gender/Role) ---
|
||||||
Widget _buildDropdownField({
|
Widget _buildDropdownField({
|
||||||
required String label,
|
required String label,
|
||||||
required String value,
|
required String value,
|
||||||
@ -208,7 +329,7 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
|
|||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
MyText.labelMedium(label),
|
_requiredLabel(label),
|
||||||
MySpacing.height(8),
|
MySpacing.height(8),
|
||||||
GestureDetector(
|
GestureDetector(
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
@ -216,6 +337,12 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
|
|||||||
child: TextFormField(
|
child: TextFormField(
|
||||||
readOnly: true,
|
readOnly: true,
|
||||||
controller: TextEditingController(text: value),
|
controller: TextEditingController(text: value),
|
||||||
|
validator: (val) {
|
||||||
|
if (val == null || val.trim().isEmpty) {
|
||||||
|
return "$label is required";
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
decoration: _inputDecoration(hint).copyWith(
|
decoration: _inputDecoration(hint).copyWith(
|
||||||
suffixIcon: const Icon(Icons.expand_more),
|
suffixIcon: const Icon(Icons.expand_more),
|
||||||
),
|
),
|
||||||
@ -226,7 +353,7 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Common input decoration
|
// --- Common input decoration ---
|
||||||
InputDecoration _inputDecoration(String hint) {
|
InputDecoration _inputDecoration(String hint) {
|
||||||
return InputDecoration(
|
return InputDecoration(
|
||||||
hintText: hint,
|
hintText: hint,
|
||||||
@ -249,7 +376,7 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Gender popup menu
|
// --- Gender popup ---
|
||||||
void _showGenderPopup(BuildContext context) async {
|
void _showGenderPopup(BuildContext context) async {
|
||||||
final selected = await showMenu<Gender>(
|
final selected = await showMenu<Gender>(
|
||||||
context: context,
|
context: context,
|
||||||
@ -268,7 +395,7 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Role popup menu
|
// --- Role popup ---
|
||||||
void _showRolePopup(BuildContext context) async {
|
void _showRolePopup(BuildContext context) async {
|
||||||
final selected = await showMenu<String>(
|
final selected = await showMenu<String>(
|
||||||
context: context,
|
context: context,
|
||||||
|
|||||||
@ -118,7 +118,61 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> {
|
|||||||
onCancel: Get.back,
|
onCancel: Get.back,
|
||||||
onSubmit: () {
|
onSubmit: () {
|
||||||
if (_formKey.currentState!.validate()) {
|
if (_formKey.currentState!.validate()) {
|
||||||
|
// Additional dropdown validation
|
||||||
|
if (controller.selectedProject.value.isEmpty) {
|
||||||
|
showAppSnackbar(
|
||||||
|
title: "Error",
|
||||||
|
message: "Please select a project",
|
||||||
|
type: SnackbarType.error,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (controller.selectedExpenseType.value == null) {
|
||||||
|
showAppSnackbar(
|
||||||
|
title: "Error",
|
||||||
|
message: "Please select an expense type",
|
||||||
|
type: SnackbarType.error,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (controller.selectedPaymentMode.value == null) {
|
||||||
|
showAppSnackbar(
|
||||||
|
title: "Error",
|
||||||
|
message: "Please select a payment mode",
|
||||||
|
type: SnackbarType.error,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (controller.selectedPaidBy.value == null) {
|
||||||
|
showAppSnackbar(
|
||||||
|
title: "Error",
|
||||||
|
message: "Please select a person who paid",
|
||||||
|
type: SnackbarType.error,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (controller.attachments.isEmpty &&
|
||||||
|
controller.existingAttachments.isEmpty) {
|
||||||
|
showAppSnackbar(
|
||||||
|
title: "Error",
|
||||||
|
message: "Please attach at least one document",
|
||||||
|
type: SnackbarType.error,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validation passed, submit
|
||||||
controller.submitOrUpdateExpense();
|
controller.submitOrUpdateExpense();
|
||||||
|
} else {
|
||||||
|
showAppSnackbar(
|
||||||
|
title: "Error",
|
||||||
|
message: "Please fill all required fields correctly",
|
||||||
|
type: SnackbarType.error,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
@ -186,12 +240,6 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> {
|
|||||||
_CustomTextField(
|
_CustomTextField(
|
||||||
controller: controller.gstController,
|
controller: controller.gstController,
|
||||||
hint: "Enter GST No.",
|
hint: "Enter GST No.",
|
||||||
validator: (value) {
|
|
||||||
if (value != null && value.isNotEmpty) {
|
|
||||||
return Validators.gstValidator(value);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
|
|
||||||
MySpacing.height(16),
|
MySpacing.height(16),
|
||||||
@ -284,7 +332,7 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> {
|
|||||||
if (value != null && value.isNotEmpty) {
|
if (value != null && value.isNotEmpty) {
|
||||||
return Validators.transactionIdValidator(value);
|
return Validators.transactionIdValidator(value);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
||||||
@ -743,6 +791,8 @@ class _AttachmentsSection extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
// 📎 File Picker Button
|
||||||
GestureDetector(
|
GestureDetector(
|
||||||
onTap: onAdd,
|
onTap: onAdd,
|
||||||
child: Container(
|
child: Container(
|
||||||
@ -753,7 +803,24 @@ class _AttachmentsSection extends StatelessWidget {
|
|||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
color: Colors.grey.shade100,
|
color: Colors.grey.shade100,
|
||||||
),
|
),
|
||||||
child: const Icon(Icons.add, size: 30, color: Colors.grey),
|
child: const Icon(Icons.attach_file,
|
||||||
|
size: 30, color: Colors.grey),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// 📷 Camera Button
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () => Get.find<AddExpenseController>().pickFromCamera(),
|
||||||
|
child: Container(
|
||||||
|
width: 80,
|
||||||
|
height: 80,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border.all(color: Colors.grey.shade400),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
color: Colors.grey.shade100,
|
||||||
|
),
|
||||||
|
child: const Icon(Icons.camera_alt,
|
||||||
|
size: 30, color: Colors.grey),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@ -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/auth/mpin_auth_screen.dart';
|
||||||
import 'package:marco/view/directory/directory_main_screen.dart';
|
import 'package:marco/view/directory/directory_main_screen.dart';
|
||||||
import 'package:marco/view/expense/expense_screen.dart';
|
import 'package:marco/view/expense/expense_screen.dart';
|
||||||
|
import 'package:marco/view/document/user_document_screen.dart';
|
||||||
|
|
||||||
class AuthMiddleware extends GetMiddleware {
|
class AuthMiddleware extends GetMiddleware {
|
||||||
@override
|
@override
|
||||||
@ -70,6 +71,11 @@ getPageRoute() {
|
|||||||
GetPage(
|
GetPage(
|
||||||
name: '/dashboard/expense-main-page',
|
name: '/dashboard/expense-main-page',
|
||||||
page: () => ExpenseMainScreen(),
|
page: () => ExpenseMainScreen(),
|
||||||
|
middlewares: [AuthMiddleware()]),
|
||||||
|
// Documents
|
||||||
|
GetPage(
|
||||||
|
name: '/dashboard/document-main-page',
|
||||||
|
page: () => UserDocumentsPage(),
|
||||||
middlewares: [AuthMiddleware()]),
|
middlewares: [AuthMiddleware()]),
|
||||||
// Authentication
|
// Authentication
|
||||||
GetPage(name: '/auth/login', page: () => LoginScreen()),
|
GetPage(name: '/auth/login', page: () => LoginScreen()),
|
||||||
|
|||||||
@ -31,6 +31,7 @@ class DashboardScreen extends StatefulWidget {
|
|||||||
"/dashboard/daily-task-progress";
|
"/dashboard/daily-task-progress";
|
||||||
static const String directoryMainPageRoute = "/dashboard/directory-main-page";
|
static const String directoryMainPageRoute = "/dashboard/directory-main-page";
|
||||||
static const String expenseMainPageRoute = "/dashboard/expense-main-page";
|
static const String expenseMainPageRoute = "/dashboard/expense-main-page";
|
||||||
|
static const String documentMainPageRoute = "/dashboard/document-main-page";
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<DashboardScreen> createState() => _DashboardScreenState();
|
State<DashboardScreen> createState() => _DashboardScreenState();
|
||||||
@ -39,8 +40,7 @@ class DashboardScreen extends StatefulWidget {
|
|||||||
class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
|
class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
|
||||||
final DashboardController dashboardController =
|
final DashboardController dashboardController =
|
||||||
Get.put(DashboardController(), permanent: true);
|
Get.put(DashboardController(), permanent: true);
|
||||||
final DynamicMenuController menuController =
|
final DynamicMenuController menuController = Get.put(DynamicMenuController());
|
||||||
Get.put(DynamicMenuController(), permanent: true);
|
|
||||||
|
|
||||||
bool hasMpin = true;
|
bool hasMpin = true;
|
||||||
|
|
||||||
@ -79,7 +79,7 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
|
|||||||
*/
|
*/
|
||||||
_buildDashboardStats(context),
|
_buildDashboardStats(context),
|
||||||
MySpacing.height(24),
|
MySpacing.height(24),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
child: DashboardOverviewWidgets.teamsOverview(),
|
child: DashboardOverviewWidgets.teamsOverview(),
|
||||||
),
|
),
|
||||||
@ -242,7 +242,8 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
|
|||||||
if (menuController.isLoading.value) {
|
if (menuController.isLoading.value) {
|
||||||
return _buildLoadingSkeleton(context);
|
return _buildLoadingSkeleton(context);
|
||||||
}
|
}
|
||||||
if (menuController.hasError.value) {
|
if (menuController.hasError.value && menuController.menuItems.isEmpty) {
|
||||||
|
// ❌ Only show error if there are no menus at all
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
child: Center(
|
child: Center(
|
||||||
@ -267,6 +268,8 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
|
|||||||
DashboardScreen.directoryMainPageRoute),
|
DashboardScreen.directoryMainPageRoute),
|
||||||
_StatItem(LucideIcons.badge_dollar_sign, "Expense", contentTheme.info,
|
_StatItem(LucideIcons.badge_dollar_sign, "Expense", contentTheme.info,
|
||||||
DashboardScreen.expenseMainPageRoute),
|
DashboardScreen.expenseMainPageRoute),
|
||||||
|
_StatItem(LucideIcons.file_text, "Documents", contentTheme.info,
|
||||||
|
DashboardScreen.documentMainPageRoute),
|
||||||
];
|
];
|
||||||
|
|
||||||
final projectController = Get.find<ProjectController>();
|
final projectController = Get.find<ProjectController>();
|
||||||
@ -277,10 +280,16 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
|
|||||||
children: [
|
children: [
|
||||||
if (!isProjectSelected) _buildNoProjectMessage(),
|
if (!isProjectSelected) _buildNoProjectMessage(),
|
||||||
Wrap(
|
Wrap(
|
||||||
spacing: 6, // horizontal spacing
|
spacing: 6,
|
||||||
runSpacing: 6, // vertical spacing
|
runSpacing: 6,
|
||||||
children: stats
|
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))
|
.map((stat) => _buildStatCard(stat, isProjectSelected))
|
||||||
.toList(),
|
.toList(),
|
||||||
),
|
),
|
||||||
@ -290,10 +299,14 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Stat Card (Compact with wrapping text)
|
/// Stat Card (Compact with wrapping text)
|
||||||
Widget _buildStatCard(_StatItem statItem, bool isEnabled) {
|
/// Stat Card (Compact with wrapping text)
|
||||||
|
Widget _buildStatCard(_StatItem statItem, bool isProjectSelected) {
|
||||||
const double cardWidth = 80;
|
const double cardWidth = 80;
|
||||||
const double cardHeight = 70;
|
const double cardHeight = 70;
|
||||||
|
|
||||||
|
// ✅ Attendance should always be enabled
|
||||||
|
final bool isEnabled = statItem.title == "Attendance" || isProjectSelected;
|
||||||
|
|
||||||
return Opacity(
|
return Opacity(
|
||||||
opacity: isEnabled ? 1.0 : 0.4,
|
opacity: isEnabled ? 1.0 : 0.4,
|
||||||
child: IgnorePointer(
|
child: IgnorePointer(
|
||||||
@ -334,19 +347,6 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Compact Icon
|
|
||||||
Widget _buildStatCardIconCompact(_StatItem statItem) {
|
|
||||||
return MyContainer.rounded(
|
|
||||||
paddingAll: 6,
|
|
||||||
color: statItem.color.withOpacity(0.1),
|
|
||||||
child: Icon(
|
|
||||||
statItem.icon,
|
|
||||||
size: 14,
|
|
||||||
color: statItem.color,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Handle Tap
|
/// Handle Tap
|
||||||
void _handleStatCardTap(_StatItem statItem, bool isEnabled) {
|
void _handleStatCardTap(_StatItem statItem, bool isEnabled) {
|
||||||
if (!isEnabled) {
|
if (!isEnabled) {
|
||||||
@ -363,6 +363,21 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
|
|||||||
Get.toNamed(statItem.route);
|
Get.toNamed(statItem.route);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Compact Icon
|
||||||
|
Widget _buildStatCardIconCompact(_StatItem statItem) {
|
||||||
|
return MyContainer.rounded(
|
||||||
|
paddingAll: 6,
|
||||||
|
color: statItem.color.withOpacity(0.1),
|
||||||
|
child: Icon(
|
||||||
|
statItem.icon,
|
||||||
|
size: 14,
|
||||||
|
color: statItem.color,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle Tap
|
||||||
}
|
}
|
||||||
|
|
||||||
class _StatItem {
|
class _StatItem {
|
||||||
|
|||||||
@ -16,6 +16,7 @@ import 'package:marco/model/directory/create_bucket_bottom_sheet.dart';
|
|||||||
import 'package:marco/view/directory/contact_detail_screen.dart';
|
import 'package:marco/view/directory/contact_detail_screen.dart';
|
||||||
import 'package:marco/view/directory/manage_bucket_screen.dart';
|
import 'package:marco/view/directory/manage_bucket_screen.dart';
|
||||||
import 'package:marco/controller/permission_controller.dart';
|
import 'package:marco/controller/permission_controller.dart';
|
||||||
|
import 'package:marco/helpers/utils/permission_constants.dart';
|
||||||
|
|
||||||
class DirectoryView extends StatefulWidget {
|
class DirectoryView extends StatefulWidget {
|
||||||
@override
|
@override
|
||||||
@ -288,35 +289,44 @@ class _DirectoryViewState extends State<DirectoryView> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Create Bucket option
|
// ✅ Conditionally show Create Bucket option
|
||||||
menuItems.add(
|
if (permissionController
|
||||||
PopupMenuItem<int>(
|
.hasPermission(Permissions.directoryAdmin) ||
|
||||||
value: 2,
|
permissionController
|
||||||
child: Row(
|
.hasPermission(Permissions.directoryManager) ||
|
||||||
children: const [
|
permissionController
|
||||||
Icon(Icons.add_box_outlined,
|
.hasPermission(Permissions.directoryUser)) {
|
||||||
size: 20, color: Colors.black87),
|
menuItems.add(
|
||||||
SizedBox(width: 10),
|
PopupMenuItem<int>(
|
||||||
Expanded(child: Text("Create Bucket")),
|
value: 2,
|
||||||
Icon(Icons.chevron_right,
|
child: Row(
|
||||||
size: 20, color: Colors.red),
|
children: const [
|
||||||
],
|
Icon(Icons.add_box_outlined,
|
||||||
|
size: 20, color: Colors.black87),
|
||||||
|
SizedBox(width: 10),
|
||||||
|
Expanded(child: Text("Create Bucket")),
|
||||||
|
Icon(Icons.chevron_right,
|
||||||
|
size: 20, color: Colors.red),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
onTap: () {
|
||||||
|
Future.delayed(Duration.zero, () async {
|
||||||
|
final created =
|
||||||
|
await showModalBottomSheet<bool>(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
builder: (_) =>
|
||||||
|
const CreateBucketBottomSheet(),
|
||||||
|
);
|
||||||
|
if (created == true) {
|
||||||
|
await controller.fetchBuckets();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
),
|
),
|
||||||
onTap: () {
|
);
|
||||||
Future.delayed(Duration.zero, () async {
|
}
|
||||||
final created = await showModalBottomSheet<bool>(
|
|
||||||
context: context,
|
|
||||||
isScrollControlled: true,
|
|
||||||
backgroundColor: Colors.transparent,
|
|
||||||
builder: (_) => const CreateBucketBottomSheet(),
|
|
||||||
);
|
|
||||||
if (created == true) {
|
|
||||||
await controller.fetchBuckets();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Manage Buckets option
|
// Manage Buckets option
|
||||||
menuItems.add(
|
menuItems.add(
|
||||||
@ -355,7 +365,7 @@ class _DirectoryViewState extends State<DirectoryView> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Show Inactive switch
|
// Show Inactive toggle
|
||||||
menuItems.add(
|
menuItems.add(
|
||||||
PopupMenuItem<int>(
|
PopupMenuItem<int>(
|
||||||
value: 0,
|
value: 0,
|
||||||
|
|||||||
428
lib/view/document/document_details_page.dart
Normal file
428
lib/view/document/document_details_page.dart
Normal file
@ -0,0 +1,428 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
import 'package:marco/helpers/widgets/my_text.dart';
|
||||||
|
import 'package:marco/helpers/widgets/my_spacing.dart';
|
||||||
|
import 'package:marco/helpers/widgets/my_custom_skeleton.dart';
|
||||||
|
import 'package:marco/helpers/widgets/my_refresh_indicator.dart';
|
||||||
|
import 'package:marco/model/document/document_details_model.dart';
|
||||||
|
import 'package:marco/controller/document/document_details_controller.dart';
|
||||||
|
import 'package:marco/helpers/widgets/custom_app_bar.dart';
|
||||||
|
import 'package:marco/helpers/widgets/my_snackbar.dart';
|
||||||
|
import 'package:marco/model/document/document_edit_bottom_sheet.dart';
|
||||||
|
import 'package:marco/controller/permission_controller.dart';
|
||||||
|
import 'package:marco/helpers/utils/permission_constants.dart';
|
||||||
|
|
||||||
|
class DocumentDetailsPage extends StatefulWidget {
|
||||||
|
final String documentId;
|
||||||
|
|
||||||
|
const DocumentDetailsPage({super.key, required this.documentId});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<DocumentDetailsPage> createState() => _DocumentDetailsPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
|
||||||
|
final DocumentDetailsController controller =
|
||||||
|
Get.put(DocumentDetailsController());
|
||||||
|
final PermissionController permissionController =
|
||||||
|
Get.find<PermissionController>();
|
||||||
|
@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(),
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_buildDetailsCard(doc),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
MyText.titleMedium("Versions",
|
||||||
|
fontWeight: 700, color: Colors.black),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
_buildVersionsSection(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ---------------- DOCUMENT DETAILS CARD ----------------
|
||||||
|
Widget _buildDetailsCard(DocumentDetails doc) {
|
||||||
|
final uploadDate =
|
||||||
|
DateFormat("dd MMM yyyy, hh:mm a").format(doc.uploadedAt.toLocal());
|
||||||
|
final updateDate = doc.updatedAt != null
|
||||||
|
? DateFormat("dd MMM yyyy, hh:mm a").format(doc.updatedAt!.toLocal())
|
||||||
|
: "-";
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 460),
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withOpacity(0.06),
|
||||||
|
blurRadius: 16,
|
||||||
|
offset: const Offset(0, 4),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Header with Edit button
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
CircleAvatar(
|
||||||
|
backgroundColor: Colors.blue.shade50,
|
||||||
|
radius: 28,
|
||||||
|
child: const Icon(Icons.description,
|
||||||
|
color: Colors.blue, size: 28),
|
||||||
|
),
|
||||||
|
MySpacing.width(16),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
MyText.titleLarge(doc.name,
|
||||||
|
fontWeight: 700, color: Colors.black),
|
||||||
|
MyText.bodySmall(
|
||||||
|
doc.documentType.name,
|
||||||
|
color: Colors.blueGrey,
|
||||||
|
fontWeight: 600,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (permissionController
|
||||||
|
.hasPermission(Permissions.modifyDocument))
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.edit, color: Colors.red),
|
||||||
|
onPressed: () async {
|
||||||
|
// existing bottom sheet flow
|
||||||
|
await controller
|
||||||
|
.fetchDocumentVersions(doc.parentAttachmentId);
|
||||||
|
|
||||||
|
final latestVersion = controller.versions.isNotEmpty
|
||||||
|
? controller.versions.reduce((a, b) =>
|
||||||
|
a.uploadedAt.isAfter(b.uploadedAt) ? a : b)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
final documentData = {
|
||||||
|
"id": doc.id,
|
||||||
|
"documentId": doc.documentId,
|
||||||
|
"name": doc.name,
|
||||||
|
"description": doc.description,
|
||||||
|
"tags": doc.tags
|
||||||
|
.map((t) => {"name": t.name, "isActive": t.isActive})
|
||||||
|
.toList(),
|
||||||
|
"category": doc.documentType.documentCategory?.toJson(),
|
||||||
|
"type": doc.documentType.toJson(),
|
||||||
|
"attachment": latestVersion != null
|
||||||
|
? {
|
||||||
|
"id": latestVersion.id,
|
||||||
|
"fileName": latestVersion.name,
|
||||||
|
"contentType": latestVersion.contentType,
|
||||||
|
"fileSize": latestVersion.fileSize,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
shape: const RoundedRectangleBorder(
|
||||||
|
borderRadius:
|
||||||
|
BorderRadius.vertical(top: Radius.circular(20)),
|
||||||
|
),
|
||||||
|
builder: (_) {
|
||||||
|
return DocumentEditBottomSheet(
|
||||||
|
documentData: documentData,
|
||||||
|
onSubmit: (updatedData) async {
|
||||||
|
await _fetchDetails();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
MySpacing.height(12),
|
||||||
|
|
||||||
|
// Tags
|
||||||
|
if (doc.tags.isNotEmpty)
|
||||||
|
Wrap(
|
||||||
|
children: doc.tags.map((t) => _buildTagChip(t.name)).toList(),
|
||||||
|
),
|
||||||
|
MySpacing.height(16),
|
||||||
|
|
||||||
|
// Info rows
|
||||||
|
_buildDetailRow("Document ID", doc.documentId),
|
||||||
|
_buildDetailRow("Description", doc.description ?? "-"),
|
||||||
|
_buildDetailRow(
|
||||||
|
"Category", doc.documentType.documentCategory?.name ?? "-"),
|
||||||
|
_buildDetailRow("Version", "v${doc.version}"),
|
||||||
|
_buildDetailRow(
|
||||||
|
"Current Version", doc.isCurrentVersion ? "Yes" : "No"),
|
||||||
|
_buildDetailRow("Verified",
|
||||||
|
doc.isVerified == null ? '-' : (doc.isVerified! ? "Yes" : "No")),
|
||||||
|
_buildDetailRow("Uploaded By",
|
||||||
|
"${doc.uploadedBy.firstName} ${doc.uploadedBy.lastName}"),
|
||||||
|
_buildDetailRow("Uploaded On", uploadDate),
|
||||||
|
if (doc.updatedAt != null)
|
||||||
|
_buildDetailRow("Last Updated On", updateDate),
|
||||||
|
MySpacing.height(12),
|
||||||
|
// Show buttons only if user has permission AND document is not verified yet
|
||||||
|
if (permissionController.hasPermission(Permissions.verifyDocument) &&
|
||||||
|
doc.isVerified == null) ...[
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: ElevatedButton.icon(
|
||||||
|
icon: const Icon(Icons.check, color: Colors.white),
|
||||||
|
label: MyText.bodyMedium(
|
||||||
|
"Verify",
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: 600,
|
||||||
|
),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: Colors.green,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
|
),
|
||||||
|
onPressed: () async {
|
||||||
|
final success = await controller.verifyDocument(doc.id);
|
||||||
|
if (success) {
|
||||||
|
showAppSnackbar(
|
||||||
|
title: "Success",
|
||||||
|
message: "Document verified successfully",
|
||||||
|
type: SnackbarType.success,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
showAppSnackbar(
|
||||||
|
title: "Error",
|
||||||
|
message: "Failed to verify document",
|
||||||
|
type: SnackbarType.error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: ElevatedButton.icon(
|
||||||
|
icon: const Icon(Icons.close, color: Colors.white),
|
||||||
|
label: MyText.bodyMedium(
|
||||||
|
"Reject",
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: 600,
|
||||||
|
),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
|
),
|
||||||
|
onPressed: () async {
|
||||||
|
final success = await controller.rejectDocument(doc.id);
|
||||||
|
if (success) {
|
||||||
|
showAppSnackbar(
|
||||||
|
title: "Rejected",
|
||||||
|
message: "Document rejected successfully",
|
||||||
|
type: SnackbarType.warning,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
showAppSnackbar(
|
||||||
|
title: "Error",
|
||||||
|
message: "Failed to reject document",
|
||||||
|
type: SnackbarType.error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ---------------- VERSIONS SECTION ----------------
|
||||||
|
Widget _buildVersionsSection() {
|
||||||
|
return Obx(() {
|
||||||
|
if (controller.isVersionsLoading.value) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (controller.versions.isEmpty) {
|
||||||
|
return MyText.bodySmall("No versions found", color: Colors.grey);
|
||||||
|
}
|
||||||
|
|
||||||
|
final sorted = [...controller.versions];
|
||||||
|
sorted.sort((a, b) => b.uploadedAt.compareTo(a.uploadedAt));
|
||||||
|
|
||||||
|
return ListView.separated(
|
||||||
|
shrinkWrap: true,
|
||||||
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
|
itemCount: sorted.length,
|
||||||
|
separatorBuilder: (_, __) => Divider(height: 1),
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final version = sorted[index];
|
||||||
|
final uploadDate =
|
||||||
|
DateFormat("dd MMM yyyy, hh:mm a").format(version.uploadedAt);
|
||||||
|
|
||||||
|
return ListTile(
|
||||||
|
leading: const Icon(Icons.description, color: Colors.blue),
|
||||||
|
title: MyText.bodyMedium(
|
||||||
|
"${version.name} (v${version.version})",
|
||||||
|
fontWeight: 600,
|
||||||
|
color: Colors.black,
|
||||||
|
),
|
||||||
|
subtitle: MyText.bodySmall(
|
||||||
|
"Uploaded by ${version.uploadedBy.firstName} ${version.uploadedBy.lastName} • $uploadDate",
|
||||||
|
color: Colors.grey.shade600,
|
||||||
|
),
|
||||||
|
trailing:
|
||||||
|
permissionController.hasPermission(Permissions.viewDocument)
|
||||||
|
? IconButton(
|
||||||
|
icon: const Icon(Icons.open_in_new, color: Colors.blue),
|
||||||
|
onPressed: () async {
|
||||||
|
final url =
|
||||||
|
await controller.fetchPresignedUrl(version.id);
|
||||||
|
if (url != null) {
|
||||||
|
_openDocument(url);
|
||||||
|
} else {
|
||||||
|
showAppSnackbar(
|
||||||
|
title: "Error",
|
||||||
|
message: "Failed to fetch document link",
|
||||||
|
type: SnackbarType.error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ---------------- HELPERS ----------------
|
||||||
|
Widget _buildTagChip(String label) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||||
|
margin: const EdgeInsets.only(right: 6, bottom: 6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.blue.shade100,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: MyText.bodySmall(
|
||||||
|
label,
|
||||||
|
color: Colors.blue.shade900,
|
||||||
|
fontWeight: 600,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildDetailRow(String title, String value) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 120,
|
||||||
|
child: MyText.bodySmall(
|
||||||
|
"$title:",
|
||||||
|
fontWeight: 600,
|
||||||
|
color: Colors.grey.shade800,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: MyText.bodySmall(
|
||||||
|
value,
|
||||||
|
color: Colors.grey.shade600,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<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
621
lib/view/document/user_document_screen.dart
Normal file
621
lib/view/document/user_document_screen.dart
Normal file
@ -0,0 +1,621 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
import 'package:marco/controller/document/user_document_controller.dart';
|
||||||
|
import 'package:marco/controller/project_controller.dart';
|
||||||
|
import 'package:marco/helpers/widgets/my_spacing.dart';
|
||||||
|
import 'package:marco/helpers/widgets/my_text.dart';
|
||||||
|
import 'package:marco/helpers/widgets/my_refresh_indicator.dart';
|
||||||
|
import 'package:marco/helpers/utils/permission_constants.dart';
|
||||||
|
import 'package:marco/model/document/user_document_filter_bottom_sheet.dart';
|
||||||
|
import 'package:marco/model/document/documents_list_model.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
import 'package:marco/helpers/widgets/my_custom_skeleton.dart';
|
||||||
|
import 'package:marco/model/document/document_upload_bottom_sheet.dart';
|
||||||
|
import 'package:marco/controller/document/document_upload_controller.dart';
|
||||||
|
import 'package:marco/view/document/document_details_page.dart';
|
||||||
|
import 'package:marco/helpers/widgets/custom_app_bar.dart';
|
||||||
|
import 'package:marco/helpers/widgets/my_confirmation_dialog.dart';
|
||||||
|
import 'package:marco/helpers/widgets/my_snackbar.dart';
|
||||||
|
import 'package:marco/controller/permission_controller.dart';
|
||||||
|
|
||||||
|
class UserDocumentsPage extends StatefulWidget {
|
||||||
|
final String? entityId;
|
||||||
|
final bool isEmployee;
|
||||||
|
|
||||||
|
const UserDocumentsPage({
|
||||||
|
super.key,
|
||||||
|
this.entityId,
|
||||||
|
this.isEmployee = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<UserDocumentsPage> createState() => _UserDocumentsPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _UserDocumentsPageState extends State<UserDocumentsPage> {
|
||||||
|
final DocumentController docController = Get.put(DocumentController());
|
||||||
|
final PermissionController permissionController =
|
||||||
|
Get.find<PermissionController>();
|
||||||
|
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
PopupMenuButton<String>(
|
||||||
|
icon: const Icon(Icons.more_vert, color: Colors.black54),
|
||||||
|
onSelected: (value) async {
|
||||||
|
if (value == "delete") {
|
||||||
|
// existing delete flow (unchanged)
|
||||||
|
final result = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (_) => ConfirmDialog(
|
||||||
|
title: "Delete Document",
|
||||||
|
message:
|
||||||
|
"Are you sure you want to delete \"${doc.name}\"?\nThis action cannot be undone.",
|
||||||
|
confirmText: "Delete",
|
||||||
|
cancelText: "Cancel",
|
||||||
|
icon: Icons.delete_forever,
|
||||||
|
confirmColor: Colors.redAccent,
|
||||||
|
onConfirm: () async {
|
||||||
|
final success =
|
||||||
|
await docController.toggleDocumentActive(
|
||||||
|
doc.id,
|
||||||
|
isActive: false,
|
||||||
|
entityTypeId: entityTypeId,
|
||||||
|
entityId: resolvedEntityId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
showAppSnackbar(
|
||||||
|
title: "Deleted",
|
||||||
|
message: "Document deleted successfully",
|
||||||
|
type: SnackbarType.success,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
showAppSnackbar(
|
||||||
|
title: "Error",
|
||||||
|
message: "Failed to delete document",
|
||||||
|
type: SnackbarType.error,
|
||||||
|
);
|
||||||
|
throw Exception(
|
||||||
|
"Failed to delete"); // keep dialog open
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (result == true) {
|
||||||
|
debugPrint("✅ Document deleted and removed from list");
|
||||||
|
}
|
||||||
|
} else if (value == "activate") {
|
||||||
|
// existing activate flow (unchanged)
|
||||||
|
final success = await docController.toggleDocumentActive(
|
||||||
|
doc.id,
|
||||||
|
isActive: true,
|
||||||
|
entityTypeId: entityTypeId,
|
||||||
|
entityId: resolvedEntityId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
showAppSnackbar(
|
||||||
|
title: "Reactivated",
|
||||||
|
message: "Document reactivated successfully",
|
||||||
|
type: SnackbarType.success,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
showAppSnackbar(
|
||||||
|
title: "Error",
|
||||||
|
message: "Failed to reactivate document",
|
||||||
|
type: SnackbarType.error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
itemBuilder: (context) => [
|
||||||
|
if (doc.isActive &&
|
||||||
|
permissionController
|
||||||
|
.hasPermission(Permissions.deleteDocument))
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: "delete",
|
||||||
|
child: Text("Delete"),
|
||||||
|
)
|
||||||
|
else if (!doc.isActive &&
|
||||||
|
permissionController
|
||||||
|
.hasPermission(Permissions.modifyDocument))
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: "activate",
|
||||||
|
child: Text("Activate"),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildEmptyState() {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.inbox_outlined, size: 60, color: Colors.grey),
|
||||||
|
MySpacing.height(18),
|
||||||
|
MyText.titleMedium(
|
||||||
|
'No documents found.',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: Colors.grey,
|
||||||
|
),
|
||||||
|
MySpacing.height(10),
|
||||||
|
MyText.bodySmall(
|
||||||
|
'Try adjusting your filters or refresh to reload.',
|
||||||
|
color: Colors.grey,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildFilterRow(BuildContext context) {
|
||||||
|
return Padding(
|
||||||
|
padding: MySpacing.xy(8, 8),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
// 🔍 Search Bar
|
||||||
|
Expanded(
|
||||||
|
child: SizedBox(
|
||||||
|
height: 35,
|
||||||
|
child: TextField(
|
||||||
|
controller: docController.searchController,
|
||||||
|
onChanged: (value) {
|
||||||
|
docController.searchQuery.value = value;
|
||||||
|
docController.fetchDocuments(
|
||||||
|
entityTypeId: entityTypeId,
|
||||||
|
entityId: resolvedEntityId,
|
||||||
|
reset: true,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
decoration: InputDecoration(
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
|
prefixIcon:
|
||||||
|
const Icon(Icons.search, size: 20, color: Colors.grey),
|
||||||
|
suffixIcon: ValueListenableBuilder<TextEditingValue>(
|
||||||
|
valueListenable: docController.searchController,
|
||||||
|
builder: (context, value, _) {
|
||||||
|
if (value.text.isEmpty) return const SizedBox.shrink();
|
||||||
|
return IconButton(
|
||||||
|
icon: const Icon(Icons.clear,
|
||||||
|
size: 20, color: Colors.grey),
|
||||||
|
onPressed: () {
|
||||||
|
docController.searchController.clear();
|
||||||
|
docController.searchQuery.value = '';
|
||||||
|
docController.fetchDocuments(
|
||||||
|
entityTypeId: entityTypeId,
|
||||||
|
entityId: resolvedEntityId,
|
||||||
|
reset: true,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
hintText: 'Search documents...',
|
||||||
|
filled: true,
|
||||||
|
fillColor: Colors.white,
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
borderSide: BorderSide(color: Colors.grey.shade300),
|
||||||
|
),
|
||||||
|
enabledBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
borderSide: BorderSide(color: Colors.grey.shade300),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
MySpacing.width(8),
|
||||||
|
|
||||||
|
// 🛠️ Filter Icon with indicator
|
||||||
|
Obx(() {
|
||||||
|
final isFilterActive = docController.hasActiveFilters();
|
||||||
|
return Stack(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
height: 35,
|
||||||
|
width: 35,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
border: Border.all(color: Colors.grey.shade300),
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
child: IconButton(
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
constraints: BoxConstraints(),
|
||||||
|
icon: Icon(
|
||||||
|
Icons.tune,
|
||||||
|
size: 20,
|
||||||
|
color: Colors.black87,
|
||||||
|
),
|
||||||
|
onPressed: () {
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
shape: const RoundedRectangleBorder(
|
||||||
|
borderRadius:
|
||||||
|
BorderRadius.vertical(top: Radius.circular(20)),
|
||||||
|
),
|
||||||
|
builder: (_) => UserDocumentFilterBottomSheet(
|
||||||
|
entityId: resolvedEntityId,
|
||||||
|
entityTypeId: entityTypeId,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (isFilterActive)
|
||||||
|
Positioned(
|
||||||
|
top: 6,
|
||||||
|
right: 6,
|
||||||
|
child: Container(
|
||||||
|
height: 8,
|
||||||
|
width: 8,
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: Colors.red,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
MySpacing.width(10),
|
||||||
|
|
||||||
|
// ⋮ Menu (Show Inactive toggle)
|
||||||
|
Container(
|
||||||
|
height: 35,
|
||||||
|
width: 35,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
border: Border.all(color: Colors.grey.shade300),
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
child: PopupMenuButton<int>(
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
icon:
|
||||||
|
const Icon(Icons.more_vert, size: 20, color: Colors.black87),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
itemBuilder: (context) => [
|
||||||
|
const PopupMenuItem<int>(
|
||||||
|
enabled: false,
|
||||||
|
height: 30,
|
||||||
|
child: Text(
|
||||||
|
"Preferences",
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Colors.grey,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
PopupMenuItem<int>(
|
||||||
|
value: 0,
|
||||||
|
enabled: false,
|
||||||
|
child: Obx(() => Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.visibility_off_outlined,
|
||||||
|
size: 20, color: Colors.black87),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
const Expanded(child: Text('Show Inactive')),
|
||||||
|
Switch.adaptive(
|
||||||
|
value: docController.showInactive.value,
|
||||||
|
activeColor: Colors.indigo,
|
||||||
|
onChanged: (val) {
|
||||||
|
docController.showInactive.value = val;
|
||||||
|
docController.fetchDocuments(
|
||||||
|
entityTypeId: entityTypeId,
|
||||||
|
entityId: resolvedEntityId,
|
||||||
|
reset: true,
|
||||||
|
);
|
||||||
|
Navigator.pop(context);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildStatusHeader() {
|
||||||
|
return Obx(() {
|
||||||
|
final isInactive = docController.showInactive.value;
|
||||||
|
return Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
|
||||||
|
color: isInactive ? Colors.red.shade50 : Colors.green.shade50,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
isInactive ? Icons.visibility_off : Icons.check_circle,
|
||||||
|
color: isInactive ? Colors.red : Colors.green,
|
||||||
|
size: 18,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
isInactive
|
||||||
|
? "Showing Inactive Documents"
|
||||||
|
: "Showing Active Documents",
|
||||||
|
style: TextStyle(
|
||||||
|
color: isInactive ? Colors.red : Colors.green,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildBody(BuildContext context) {
|
||||||
|
// 🔒 Check for viewDocument permission
|
||||||
|
if (!permissionController.hasPermission(Permissions.viewDocument)) {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.lock_outline, size: 60, color: Colors.grey),
|
||||||
|
MySpacing.height(18),
|
||||||
|
MyText.titleMedium(
|
||||||
|
'Access Denied',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: Colors.grey,
|
||||||
|
),
|
||||||
|
MySpacing.height(10),
|
||||||
|
MyText.bodySmall(
|
||||||
|
'You do not have permission to view documents.',
|
||||||
|
color: Colors.grey,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Obx(() {
|
||||||
|
if (docController.isLoading.value && docController.documents.isEmpty) {
|
||||||
|
return SingleChildScrollView(
|
||||||
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
|
child: SkeletonLoaders.documentSkeletonLoader(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final docs = docController.documents;
|
||||||
|
return SafeArea(
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
_buildFilterRow(context),
|
||||||
|
_buildStatusHeader(),
|
||||||
|
Expanded(
|
||||||
|
child: MyRefreshIndicator(
|
||||||
|
onRefresh: () async {
|
||||||
|
await docController.fetchDocuments(
|
||||||
|
entityTypeId: entityTypeId,
|
||||||
|
entityId: resolvedEntityId,
|
||||||
|
filter: docController.selectedFilter.value,
|
||||||
|
reset: true,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: ListView(
|
||||||
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
|
padding: docs.isEmpty
|
||||||
|
? null
|
||||||
|
: const EdgeInsets.fromLTRB(0, 0, 0, 80),
|
||||||
|
children: docs.isEmpty
|
||||||
|
? [
|
||||||
|
SizedBox(
|
||||||
|
height: MediaQuery.of(context).size.height * 0.6,
|
||||||
|
child: _buildEmptyState(),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
...docs.map(_buildDocumentTile),
|
||||||
|
if (docController.isLoading.value)
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.all(12),
|
||||||
|
child: Center(child: CircularProgressIndicator()),
|
||||||
|
),
|
||||||
|
if (!docController.hasMore.value)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
child: Center(
|
||||||
|
child: MyText.bodySmall(
|
||||||
|
"No more documents",
|
||||||
|
color: Colors.grey,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final bool showAppBar = !widget.isEmployee;
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: const Color(0xFFF1F1F1),
|
||||||
|
appBar: showAppBar
|
||||||
|
? CustomAppBar(
|
||||||
|
title: 'Documents',
|
||||||
|
onBackPressed: () {
|
||||||
|
Get.back();
|
||||||
|
},
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
body: _buildBody(context),
|
||||||
|
floatingActionButton: permissionController
|
||||||
|
.hasPermission(Permissions.uploadDocument)
|
||||||
|
? FloatingActionButton.extended(
|
||||||
|
onPressed: () {
|
||||||
|
final uploadController = Get.put(DocumentUploadController());
|
||||||
|
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
builder: (_) => DocumentUploadBottomSheet(
|
||||||
|
isEmployee:
|
||||||
|
widget.isEmployee, // 👈 Pass the employee flag here
|
||||||
|
onSubmit: (data) async {
|
||||||
|
final success = await uploadController.uploadDocument(
|
||||||
|
name: data["name"],
|
||||||
|
description: data["description"],
|
||||||
|
documentId: data["documentId"],
|
||||||
|
entityId: resolvedEntityId,
|
||||||
|
documentTypeId: data["documentTypeId"],
|
||||||
|
fileName: data["attachment"]["fileName"],
|
||||||
|
base64Data: data["attachment"]["base64Data"],
|
||||||
|
contentType: data["attachment"]["contentType"],
|
||||||
|
fileSize: data["attachment"]["fileSize"],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
Navigator.pop(context);
|
||||||
|
docController.fetchDocuments(
|
||||||
|
entityTypeId: entityTypeId,
|
||||||
|
entityId: resolvedEntityId,
|
||||||
|
reset: true,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
Get.snackbar(
|
||||||
|
"Error", "Upload failed, please try again");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.add, color: Colors.white),
|
||||||
|
label: MyText.bodyMedium(
|
||||||
|
"Add Document",
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: 600,
|
||||||
|
),
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
floatingActionButtonLocation: FloatingActionButtonLocation.endFloat,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
import 'package:marco/controller/employee/employees_screen_controller.dart';
|
import 'package:marco/controller/employee/employees_screen_controller.dart';
|
||||||
import 'package:marco/controller/project_controller.dart';
|
import 'package:marco/helpers/widgets/custom_app_bar.dart';
|
||||||
import 'package:marco/helpers/widgets/avatar.dart';
|
import 'package:marco/helpers/widgets/avatar.dart';
|
||||||
import 'package:marco/helpers/widgets/my_spacing.dart';
|
import 'package:marco/helpers/widgets/my_spacing.dart';
|
||||||
import 'package:marco/helpers/widgets/my_text.dart';
|
import 'package:marco/helpers/widgets/my_text.dart';
|
||||||
@ -15,6 +15,7 @@ import 'package:marco/helpers/widgets/my_refresh_indicator.dart';
|
|||||||
class EmployeeDetailPage extends StatefulWidget {
|
class EmployeeDetailPage extends StatefulWidget {
|
||||||
final String employeeId;
|
final String employeeId;
|
||||||
final bool fromProfile;
|
final bool fromProfile;
|
||||||
|
|
||||||
const EmployeeDetailPage({
|
const EmployeeDetailPage({
|
||||||
super.key,
|
super.key,
|
||||||
required this.employeeId,
|
required this.employeeId,
|
||||||
@ -30,6 +31,7 @@ class _EmployeeDetailPageState extends State<EmployeeDetailPage> {
|
|||||||
Get.put(EmployeesScreenController());
|
Get.put(EmployeesScreenController());
|
||||||
final PermissionController _permissionController =
|
final PermissionController _permissionController =
|
||||||
Get.find<PermissionController>();
|
Get.find<PermissionController>();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
@ -60,7 +62,6 @@ class _EmployeeDetailPageState extends State<EmployeeDetailPage> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Row builder with email/phone tap & copy support
|
|
||||||
Widget _buildLabelValueRow(String label, String value,
|
Widget _buildLabelValueRow(String label, String value,
|
||||||
{bool isMultiLine = false}) {
|
{bool isMultiLine = false}) {
|
||||||
final lowerLabel = label.toLowerCase();
|
final lowerLabel = label.toLowerCase();
|
||||||
@ -91,9 +92,8 @@ class _EmployeeDetailPageState extends State<EmployeeDetailPage> {
|
|||||||
fontWeight: FontWeight.normal,
|
fontWeight: FontWeight.normal,
|
||||||
color: (isEmail || isPhone) ? Colors.indigo : Colors.black54,
|
color: (isEmail || isPhone) ? Colors.indigo : Colors.black54,
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
decoration: (isEmail || isPhone)
|
decoration:
|
||||||
? TextDecoration.underline
|
(isEmail || isPhone) ? TextDecoration.underline : TextDecoration.none,
|
||||||
: TextDecoration.none,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@ -147,7 +147,6 @@ class _EmployeeDetailPageState extends State<EmployeeDetailPage> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Info card
|
|
||||||
Widget _buildInfoCard(employee) {
|
Widget _buildInfoCard(employee) {
|
||||||
return Card(
|
return Card(
|
||||||
elevation: 3,
|
elevation: 3,
|
||||||
@ -188,73 +187,22 @@ class _EmployeeDetailPageState extends State<EmployeeDetailPage> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final bool showAppBar = !widget.fromProfile;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: const Color(0xFFF1F1F1),
|
backgroundColor: const Color(0xFFF1F1F1),
|
||||||
appBar: PreferredSize(
|
appBar: showAppBar
|
||||||
preferredSize: const Size.fromHeight(72),
|
? CustomAppBar(
|
||||||
child: AppBar(
|
title: 'Employee Details',
|
||||||
backgroundColor: const Color(0xFFF5F5F5),
|
onBackPressed: () {
|
||||||
elevation: 0.5,
|
if (widget.fromProfile) {
|
||||||
automaticallyImplyLeading: false,
|
Get.back();
|
||||||
titleSpacing: 0,
|
} else {
|
||||||
title: Padding(
|
Get.offNamed('/dashboard/employees');
|
||||||
padding: MySpacing.xy(16, 0),
|
}
|
||||||
child: Row(
|
},
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
)
|
||||||
children: [
|
: null,
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.arrow_back_ios_new,
|
|
||||||
color: Colors.black, size: 20),
|
|
||||||
onPressed: () {
|
|
||||||
if (widget.fromProfile) {
|
|
||||||
Get.back();
|
|
||||||
} else {
|
|
||||||
Get.offNamed('/dashboard/employees');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
MySpacing.width(8),
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
MyText.titleLarge(
|
|
||||||
'Employee Details',
|
|
||||||
fontWeight: 700,
|
|
||||||
color: Colors.black,
|
|
||||||
),
|
|
||||||
MySpacing.height(2),
|
|
||||||
GetBuilder<ProjectController>(
|
|
||||||
builder: (projectController) {
|
|
||||||
final projectName =
|
|
||||||
projectController.selectedProject?.name ??
|
|
||||||
'Select Project';
|
|
||||||
return Row(
|
|
||||||
children: [
|
|
||||||
const Icon(Icons.work_outline,
|
|
||||||
size: 14, color: Colors.grey),
|
|
||||||
MySpacing.width(4),
|
|
||||||
Expanded(
|
|
||||||
child: MyText.bodySmall(
|
|
||||||
projectName,
|
|
||||||
fontWeight: 600,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
color: Colors.grey[700],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
body: Obx(() {
|
body: Obx(() {
|
||||||
if (controller.isLoadingEmployeeDetails.value) {
|
if (controller.isLoadingEmployeeDetails.value) {
|
||||||
return const Center(child: CircularProgressIndicator());
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
|||||||
80
lib/view/employees/employee_profile_screen.dart
Normal file
80
lib/view/employees/employee_profile_screen.dart
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
import 'package:marco/view/employees/employee_detail_screen.dart';
|
||||||
|
import 'package:marco/view/document/user_document_screen.dart';
|
||||||
|
import 'package:marco/helpers/widgets/custom_app_bar.dart';
|
||||||
|
|
||||||
|
class EmployeeProfilePage extends StatefulWidget {
|
||||||
|
final String employeeId;
|
||||||
|
|
||||||
|
const EmployeeProfilePage({super.key, required this.employeeId});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<EmployeeProfilePage> createState() => _EmployeeProfilePageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _EmployeeProfilePageState extends State<EmployeeProfilePage>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
|
late TabController _tabController;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_tabController = TabController(length: 2, vsync: this);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_tabController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: const Color(0xFFF1F1F1),
|
||||||
|
appBar: CustomAppBar(
|
||||||
|
title: "Employee Profile",
|
||||||
|
onBackPressed: () => Get.back(),
|
||||||
|
),
|
||||||
|
body: Column(
|
||||||
|
children: [
|
||||||
|
// ---------------- TabBar outside AppBar ----------------
|
||||||
|
Container(
|
||||||
|
color: Colors.white,
|
||||||
|
child: TabBar(
|
||||||
|
controller: _tabController,
|
||||||
|
labelColor: Colors.black,
|
||||||
|
unselectedLabelColor: Colors.grey,
|
||||||
|
indicatorColor: Colors.red,
|
||||||
|
tabs: const [
|
||||||
|
Tab(text: "Details"),
|
||||||
|
Tab(text: "Documents"),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// ---------------- TabBarView ----------------
|
||||||
|
Expanded(
|
||||||
|
child: TabBarView(
|
||||||
|
controller: _tabController,
|
||||||
|
children: [
|
||||||
|
// Details Tab
|
||||||
|
EmployeeDetailPage(
|
||||||
|
employeeId: widget.employeeId,
|
||||||
|
fromProfile: true,
|
||||||
|
),
|
||||||
|
|
||||||
|
// Documents Tab
|
||||||
|
UserDocumentsPage(
|
||||||
|
entityId: widget.employeeId,
|
||||||
|
isEmployee: true,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -9,13 +9,13 @@ import 'package:marco/controller/employee/employees_screen_controller.dart';
|
|||||||
import 'package:marco/helpers/widgets/avatar.dart';
|
import 'package:marco/helpers/widgets/avatar.dart';
|
||||||
import 'package:marco/controller/project_controller.dart';
|
import 'package:marco/controller/project_controller.dart';
|
||||||
import 'package:marco/helpers/widgets/my_custom_skeleton.dart';
|
import 'package:marco/helpers/widgets/my_custom_skeleton.dart';
|
||||||
import 'package:marco/view/employees/employee_detail_screen.dart';
|
|
||||||
import 'package:marco/model/employees/employee_model.dart';
|
import 'package:marco/model/employees/employee_model.dart';
|
||||||
import 'package:marco/helpers/utils/launcher_utils.dart';
|
import 'package:marco/helpers/utils/launcher_utils.dart';
|
||||||
import 'package:marco/view/employees/assign_employee_bottom_sheet.dart';
|
import 'package:marco/view/employees/assign_employee_bottom_sheet.dart';
|
||||||
import 'package:marco/controller/permission_controller.dart';
|
import 'package:marco/controller/permission_controller.dart';
|
||||||
import 'package:marco/helpers/utils/permission_constants.dart';
|
import 'package:marco/helpers/utils/permission_constants.dart';
|
||||||
import 'package:marco/helpers/widgets/my_refresh_indicator.dart';
|
import 'package:marco/helpers/widgets/my_refresh_indicator.dart';
|
||||||
|
import 'package:marco/view/employees/employee_profile_screen.dart';
|
||||||
|
|
||||||
class EmployeesScreen extends StatefulWidget {
|
class EmployeesScreen extends StatefulWidget {
|
||||||
const EmployeesScreen({super.key});
|
const EmployeesScreen({super.key});
|
||||||
@ -413,7 +413,7 @@ class _EmployeesScreenState extends State<EmployeesScreen> with UIMixin {
|
|||||||
final lastName = names.length > 1 ? names.last : '';
|
final lastName = names.length > 1 ? names.last : '';
|
||||||
|
|
||||||
return InkWell(
|
return InkWell(
|
||||||
onTap: () => Get.to(() => EmployeeDetailPage(employeeId: e.id)),
|
onTap: () => Get.to(() => EmployeeProfilePage(employeeId: e.id)),
|
||||||
child: Row(
|
child: Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
|
|||||||
@ -9,7 +9,7 @@ import 'package:marco/helpers/widgets/my_text.dart';
|
|||||||
import 'package:marco/helpers/widgets/avatar.dart';
|
import 'package:marco/helpers/widgets/avatar.dart';
|
||||||
import 'package:marco/model/employees/employee_info.dart';
|
import 'package:marco/model/employees/employee_info.dart';
|
||||||
import 'package:marco/controller/auth/mpin_controller.dart';
|
import 'package:marco/controller/auth/mpin_controller.dart';
|
||||||
import 'package:marco/view/employees/employee_detail_screen.dart';
|
import 'package:marco/view/employees/employee_profile_screen.dart';
|
||||||
|
|
||||||
class UserProfileBar extends StatefulWidget {
|
class UserProfileBar extends StatefulWidget {
|
||||||
final bool isCondensed;
|
final bool isCondensed;
|
||||||
@ -238,9 +238,8 @@ class _UserProfileBarState extends State<UserProfileBar>
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _onProfileTap() {
|
void _onProfileTap() {
|
||||||
Get.to(() => EmployeeDetailPage(
|
Get.to(() => EmployeeProfilePage(
|
||||||
employeeId: employeeInfo.id,
|
employeeId: employeeInfo.id,
|
||||||
fromProfile: true,
|
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user