Merge pull request 'Feature_Document' (#68) from Feature_Document into main
Reviewed-on: #68
This commit is contained in:
commit
d2712b8288
@ -75,6 +75,9 @@ class LoginController extends MyController {
|
||||
basicValidator.clearErrors();
|
||||
} else {
|
||||
await _handleRememberMe();
|
||||
// ✅ Enable remote logging after successful login
|
||||
enableRemoteLogging();
|
||||
logSafe("✅ Remote logging enabled after login.");
|
||||
|
||||
// ✅ Commented out FCM token registration after login
|
||||
/*
|
||||
|
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/helpers/services/api_service.dart';
|
||||
import 'package:marco/helpers/services/app_logger.dart';
|
||||
import 'package:marco/helpers/services/storage/local_storage.dart';
|
||||
|
||||
class DynamicMenuController extends GetxController {
|
||||
// UI reactive states
|
||||
@ -12,20 +11,14 @@ class DynamicMenuController extends GetxController {
|
||||
final RxString errorMessage = ''.obs;
|
||||
final RxList<MenuItem> menuItems = <MenuItem>[].obs;
|
||||
|
||||
Timer? _autoRefreshTimer;
|
||||
@override
|
||||
void onInit() {
|
||||
super.onInit();
|
||||
// Fetch menus directly from API at startup
|
||||
fetchMenu();
|
||||
|
||||
/// Auto refresh every 5 minutes (adjust as needed)
|
||||
_autoRefreshTimer = Timer.periodic(
|
||||
const Duration(minutes: 15),
|
||||
(_) => fetchMenu(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Fetch dynamic menu from API with error and local storage support
|
||||
/// Fetch dynamic menu from API (no local cache)
|
||||
Future<void> fetchMenu() async {
|
||||
isLoading.value = true;
|
||||
hasError.value = false;
|
||||
@ -34,53 +27,36 @@ class DynamicMenuController extends GetxController {
|
||||
try {
|
||||
final responseData = await ApiService.getMenuApi();
|
||||
if (responseData != null) {
|
||||
// Directly parse full JSON into MenuResponse
|
||||
final menuResponse = MenuResponse.fromJson(responseData);
|
||||
|
||||
menuItems.assignAll(menuResponse.data);
|
||||
|
||||
// Save menus for offline use
|
||||
await LocalStorage.setMenus(menuItems);
|
||||
|
||||
logSafe("Menu loaded from API with ${menuItems.length} items");
|
||||
logSafe("✅ Menu loaded from API with ${menuItems.length} items");
|
||||
} else {
|
||||
// If API fails, load from cache
|
||||
final cachedMenus = LocalStorage.getMenus();
|
||||
if (cachedMenus.isNotEmpty) {
|
||||
menuItems.assignAll(cachedMenus);
|
||||
logSafe("Loaded menus from cache: ${menuItems.length} items");
|
||||
} else {
|
||||
hasError.value = true;
|
||||
errorMessage.value = "Failed to fetch menu";
|
||||
menuItems.clear();
|
||||
}
|
||||
_handleApiFailure("Menu API returned null response");
|
||||
}
|
||||
} catch (e) {
|
||||
logSafe("Menu fetch exception: $e", level: LogLevel.error);
|
||||
|
||||
// On error, load cached menus
|
||||
final cachedMenus = LocalStorage.getMenus();
|
||||
if (cachedMenus.isNotEmpty) {
|
||||
menuItems.assignAll(cachedMenus);
|
||||
logSafe("Loaded menus from cache after error: ${menuItems.length}");
|
||||
} else {
|
||||
hasError.value = true;
|
||||
errorMessage.value = e.toString();
|
||||
menuItems.clear();
|
||||
}
|
||||
_handleApiFailure("Menu fetch exception: $e");
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
void _handleApiFailure(String logMessage) {
|
||||
logSafe(logMessage, level: LogLevel.error);
|
||||
|
||||
// No cache available, show error state
|
||||
hasError.value = true;
|
||||
errorMessage.value = "❌ Unable to load menus. Please try again later.";
|
||||
menuItems.clear();
|
||||
}
|
||||
|
||||
bool isMenuAllowed(String menuName) {
|
||||
final menu = menuItems.firstWhereOrNull((m) => m.name == menuName);
|
||||
return menu?.available ?? false; // default false if not found
|
||||
return menu?.available ?? false;
|
||||
}
|
||||
|
||||
@override
|
||||
void onClose() {
|
||||
_autoRefreshTimer?.cancel(); // clean up timer
|
||||
super.onClose();
|
||||
}
|
||||
}
|
||||
|
@ -25,6 +25,7 @@ class AddEmployeeController extends MyController {
|
||||
String selectedCountryCode = "+91";
|
||||
bool showOnline = true;
|
||||
final List<String> categories = [];
|
||||
DateTime? joiningDate;
|
||||
|
||||
@override
|
||||
void onInit() {
|
||||
@ -34,6 +35,12 @@ class AddEmployeeController extends MyController {
|
||||
fetchRoles();
|
||||
}
|
||||
|
||||
void setJoiningDate(DateTime date) {
|
||||
joiningDate = date;
|
||||
logSafe("Joining date selected: $date");
|
||||
update();
|
||||
}
|
||||
|
||||
void _initializeFields() {
|
||||
basicValidator.addField(
|
||||
'first_name',
|
||||
@ -109,6 +116,7 @@ class AddEmployeeController extends MyController {
|
||||
phoneNumber: phoneNumber!,
|
||||
gender: selectedGender!.name,
|
||||
jobRoleId: selectedRoleId!,
|
||||
joiningDate: joiningDate?.toIso8601String() ?? "",
|
||||
);
|
||||
|
||||
logSafe("Response: $response");
|
||||
|
@ -16,6 +16,7 @@ import 'package:marco/model/employees/employee_model.dart';
|
||||
import 'package:marco/model/expense/expense_type_model.dart';
|
||||
import 'package:marco/model/expense/payment_types_model.dart';
|
||||
import 'package:mime/mime.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
|
||||
class AddExpenseController extends GetxController {
|
||||
// --- Text Controllers ---
|
||||
@ -57,7 +58,7 @@ class AddExpenseController extends GetxController {
|
||||
String? editingExpenseId;
|
||||
|
||||
final expenseController = Get.find<ExpenseController>();
|
||||
|
||||
final ImagePicker _picker = ImagePicker();
|
||||
@override
|
||||
void onInit() {
|
||||
super.onInit();
|
||||
@ -189,7 +190,7 @@ class AddExpenseController extends GetxController {
|
||||
);
|
||||
|
||||
if (pickedDate != null) {
|
||||
final now = DateTime.now();
|
||||
final now = DateTime.now();
|
||||
final finalDateTime = DateTime(
|
||||
pickedDate.year,
|
||||
pickedDate.month,
|
||||
@ -308,6 +309,17 @@ class AddExpenseController extends GetxController {
|
||||
}
|
||||
}
|
||||
|
||||
Future<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 ---
|
||||
Future<void> submitOrUpdateExpense() async {
|
||||
if (isSubmitting.value) return;
|
||||
@ -360,33 +372,52 @@ class AddExpenseController extends GetxController {
|
||||
|
||||
Future<Map<String, dynamic>> _buildExpensePayload() async {
|
||||
final now = DateTime.now();
|
||||
final existingAttachmentPayloads = existingAttachments
|
||||
.map((e) => {
|
||||
"documentId": e['documentId'],
|
||||
"fileName": e['fileName'],
|
||||
"contentType": e['contentType'],
|
||||
"fileSize": 0,
|
||||
"description": "",
|
||||
"url": e['url'],
|
||||
"isActive": e['isActive'] ?? true,
|
||||
"base64Data": e['isActive'] == false ? null : e['base64Data'],
|
||||
})
|
||||
.toList();
|
||||
|
||||
final newAttachmentPayloads =
|
||||
await Future.wait(attachments.map((file) async {
|
||||
final bytes = await file.readAsBytes();
|
||||
return {
|
||||
"fileName": file.path.split('/').last,
|
||||
"base64Data": base64Encode(bytes),
|
||||
"contentType": lookupMimeType(file.path) ?? 'application/octet-stream',
|
||||
"fileSize": await file.length(),
|
||||
"description": "",
|
||||
};
|
||||
}));
|
||||
// --- Existing Attachments Payload (for edit mode only) ---
|
||||
final List<Map<String, dynamic>> existingAttachmentPayloads =
|
||||
isEditMode.value
|
||||
? existingAttachments
|
||||
.map<Map<String, dynamic>>((e) => {
|
||||
"documentId": e['documentId'],
|
||||
"fileName": e['fileName'],
|
||||
"contentType": e['contentType'],
|
||||
"fileSize": 0,
|
||||
"description": "",
|
||||
"url": e['url'],
|
||||
"isActive": e['isActive'] ?? true,
|
||||
"base64Data": "", // <-- always empty now
|
||||
})
|
||||
.toList()
|
||||
: <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!;
|
||||
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,
|
||||
"projectId": projectsMap[selectedProject.value]!,
|
||||
"expensesTypeId": type.id,
|
||||
@ -402,11 +433,11 @@ class AddExpenseController extends GetxController {
|
||||
"noOfPersons": type.noOfPersonsRequired == true
|
||||
? int.tryParse(noOfPersonsController.text.trim()) ?? 0
|
||||
: 0,
|
||||
"billAttachments": [
|
||||
...existingAttachmentPayloads,
|
||||
...newAttachmentPayloads
|
||||
],
|
||||
"billAttachments":
|
||||
combinedAttachments.isEmpty ? null : combinedAttachments,
|
||||
};
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
String validateForm() {
|
||||
|
@ -1,15 +1,16 @@
|
||||
class ApiEndpoints {
|
||||
// static const String baseUrl = "https://stageapi.marcoaiot.com/api";
|
||||
static const String baseUrl = "https://api.marcoaiot.com/api";
|
||||
static const String baseUrl = "https://stageapi.marcoaiot.com/api";
|
||||
// static const String baseUrl = "https://api.marcoaiot.com/api";
|
||||
// static const String baseUrl = "https://devapi.marcoaiot.com/api";
|
||||
|
||||
// Dashboard Module API Endpoints
|
||||
static const String getDashboardAttendanceOverview = "/dashboard/attendance-overview";
|
||||
// Dashboard Module API Endpoints
|
||||
static const String getDashboardAttendanceOverview =
|
||||
"/dashboard/attendance-overview";
|
||||
static const String getDashboardProjectProgress = "/dashboard/progression";
|
||||
static const String getDashboardTasks = "/dashboard/tasks";
|
||||
static const String getDashboardTeams = "/dashboard/teams";
|
||||
static const String getDashboardProjects = "/dashboard/projects";
|
||||
|
||||
|
||||
// Attendance Module API Endpoints
|
||||
static const String getProjects = "/project/list";
|
||||
static const String getGlobalProjects = "/project/list/basic";
|
||||
@ -45,7 +46,8 @@ class ApiEndpoints {
|
||||
static const String getDirectoryContacts = "/directory";
|
||||
static const String getDirectoryBucketList = "/directory/buckets";
|
||||
static const String getDirectoryContactDetail = "/directory/notes";
|
||||
static const String getDirectoryContactCategory = "/master/contact-categories";
|
||||
static const String getDirectoryContactCategory =
|
||||
"/master/contact-categories";
|
||||
static const String getDirectoryContactTags = "/master/contact-tags";
|
||||
static const String getDirectoryOrganization = "/directory/organization";
|
||||
static const String createContact = "/directory";
|
||||
@ -70,4 +72,22 @@ class ApiEndpoints {
|
||||
|
||||
////// Dynamic Menu Module API Endpoints
|
||||
static const String getDynamicMenu = "/appmenu/get/menu-mobile";
|
||||
|
||||
///// Document Module API Endpoints
|
||||
static const String getMasterDocumentCategories =
|
||||
"/master/document-category/list";
|
||||
static const String getMasterDocumentTags = "/document/get/tags";
|
||||
static const String getDocumentList = "/document/list";
|
||||
static const String getDocumentDetails = "/document/get/details";
|
||||
static const String uploadDocument = "/document/upload";
|
||||
static const String deleteDocument = "/document/delete";
|
||||
static const String getDocumentFilter = "/document/get/filter";
|
||||
static const String getDocumentTypesByCategory = "/master/document-type/list";
|
||||
static const String getDocumentVersion = "/document/get/version";
|
||||
static const String getDocumentVersions = "/document/list/versions";
|
||||
static const String editDocument = "/document/edit";
|
||||
static const String verifyDocument = "/document/verify";
|
||||
|
||||
/// Logs Module API Endpoints
|
||||
static const String uploadLogs = "/log";
|
||||
}
|
||||
|
@ -12,6 +12,12 @@ import 'package:marco/model/dashboard/project_progress_model.dart';
|
||||
import 'package:marco/model/dashboard/dashboard_tasks_model.dart';
|
||||
import 'package:marco/model/dashboard/dashboard_teams_model.dart';
|
||||
import 'package:marco/helpers/services/app_logger.dart';
|
||||
import 'package:marco/model/document/document_filter_model.dart';
|
||||
import 'package:marco/model/document/documents_list_model.dart';
|
||||
import 'package:marco/model/document/master_document_tags.dart';
|
||||
import 'package:marco/model/document/master_document_type_model.dart';
|
||||
import 'package:marco/model/document/document_details_model.dart';
|
||||
import 'package:marco/model/document/document_version_model.dart';
|
||||
|
||||
class ApiService {
|
||||
static const Duration timeout = Duration(seconds: 30);
|
||||
@ -241,6 +247,527 @@ class ApiService {
|
||||
}
|
||||
}
|
||||
|
||||
static Future<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 === //
|
||||
|
||||
/// Get Sidebar Menu API
|
||||
@ -1358,6 +1885,7 @@ class ApiService {
|
||||
required String phoneNumber,
|
||||
required String gender,
|
||||
required String jobRoleId,
|
||||
required String joiningDate,
|
||||
}) async {
|
||||
final body = {
|
||||
"firstName": firstName,
|
||||
@ -1365,6 +1893,7 @@ class ApiService {
|
||||
"phoneNumber": phoneNumber,
|
||||
"gender": gender,
|
||||
"jobRoleId": jobRoleId,
|
||||
"joiningDate": joiningDate
|
||||
};
|
||||
|
||||
final response = await _postRequest(
|
||||
|
@ -2,16 +2,41 @@ import 'dart:io';
|
||||
import 'package:logger/logger.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:marco/helpers/services/api_service.dart';
|
||||
|
||||
/// Global logger instance
|
||||
late final Logger appLogger;
|
||||
late final FileLogOutput fileLogOutput;
|
||||
Logger? _appLogger;
|
||||
late final FileLogOutput _fileLogOutput;
|
||||
|
||||
/// Store logs temporarily for API posting
|
||||
final List<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
|
||||
Future<void> initLogging() async {
|
||||
fileLogOutput = FileLogOutput();
|
||||
_fileLogOutput = FileLogOutput();
|
||||
|
||||
appLogger = Logger(
|
||||
_appLogger = Logger(
|
||||
printer: PrettyPrinter(
|
||||
methodCount: 0,
|
||||
printTime: true,
|
||||
@ -20,12 +45,18 @@ Future<void> initLogging() async {
|
||||
),
|
||||
output: MultiOutput([
|
||||
ConsoleOutput(),
|
||||
fileLogOutput,
|
||||
_fileLogOutput,
|
||||
]),
|
||||
level: Level.debug,
|
||||
);
|
||||
}
|
||||
|
||||
/// Enable API posting after login
|
||||
void enableRemoteLogging() {
|
||||
_canPostLogs = true;
|
||||
_postBufferedLogs(); // flush logs if any
|
||||
}
|
||||
|
||||
/// Safe logger wrapper
|
||||
void logSafe(
|
||||
String message, {
|
||||
@ -34,27 +65,60 @@ void logSafe(
|
||||
StackTrace? stackTrace,
|
||||
bool sensitive = false,
|
||||
}) {
|
||||
if (sensitive) return;
|
||||
if (sensitive || _appLogger == null) return;
|
||||
|
||||
switch (level) {
|
||||
case LogLevel.debug:
|
||||
appLogger.d(message, error: error, stackTrace: stackTrace);
|
||||
break;
|
||||
case LogLevel.warning:
|
||||
appLogger.w(message, error: error, stackTrace: stackTrace);
|
||||
break;
|
||||
case LogLevel.error:
|
||||
appLogger.e(message, error: error, stackTrace: stackTrace);
|
||||
break;
|
||||
case LogLevel.verbose:
|
||||
appLogger.v(message, error: error, stackTrace: stackTrace);
|
||||
break;
|
||||
default:
|
||||
appLogger.i(message, error: error, stackTrace: stackTrace);
|
||||
final loggerLevel = _levelMap[level] ?? Level.info;
|
||||
_appLogger!.log(loggerLevel, message, error: error, stackTrace: stackTrace);
|
||||
|
||||
// Buffer logs for API posting
|
||||
_logBuffer.add({
|
||||
"logLevel": level.name,
|
||||
"message": message,
|
||||
"timeStamp": DateTime.now().toUtc().toIso8601String(),
|
||||
"ipAddress": "this is test IP", // TODO: real IP
|
||||
"userAgent": "FlutterApp/1.0", // TODO: device_info_plus
|
||||
"details": error?.toString() ?? stackTrace?.toString(),
|
||||
});
|
||||
|
||||
if (_logBuffer.length >= _maxLogsBeforePost) {
|
||||
_postBufferedLogs();
|
||||
}
|
||||
}
|
||||
|
||||
/// Log output to file (safe path, no permission required)
|
||||
/// Post buffered logs to API
|
||||
Future<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 {
|
||||
File? _logFile;
|
||||
|
||||
@ -81,7 +145,6 @@ class FileLogOutput extends LogOutput {
|
||||
@override
|
||||
void output(OutputEvent event) async {
|
||||
await _init();
|
||||
|
||||
if (event.lines.isEmpty) return;
|
||||
|
||||
final logMessage = event.lines.join('\n') + '\n';
|
||||
@ -122,22 +185,5 @@ class FileLogOutput extends LogOutput {
|
||||
}
|
||||
}
|
||||
|
||||
/// Simple log printer for file output
|
||||
class SimpleFileLogPrinter extends LogPrinter {
|
||||
@override
|
||||
List<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
|
||||
/// Custom log levels
|
||||
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 {
|
||||
// ------------------- Project Management ------------------------------
|
||||
/// Permission to manage master data (like dropdowns, configurations)
|
||||
@ -91,4 +91,30 @@ class Permissions {
|
||||
// ------------------- Application Roles -------------------------------
|
||||
/// Application role ID for users with full expense management rights
|
||||
static const String expenseManagement = "a4e25142-449b-4334-a6e5-22f70e4732d7";
|
||||
|
||||
// ------------------- Document Entities -------------------------------
|
||||
/// Entity ID for project documents
|
||||
static const String projectEntity = "c8fe7115-aa27-43bc-99f4-7b05fabe436e";
|
||||
|
||||
/// Entity ID for employee documents
|
||||
static const String employeeEntity = "dbb9555a-7a0c-40f2-a9ed-f0463f1ceed7";
|
||||
|
||||
// ------------------- Document Permissions ----------------------------
|
||||
/// Permission to view documents
|
||||
static const String viewDocument = "71189504-f1c8-4ca5-8db6-810497be2854";
|
||||
|
||||
/// Permission to upload documents
|
||||
static const String uploadDocument = "3f6d1f67-6fa5-4b7c-b17b-018d4fe4aab8";
|
||||
|
||||
/// Permission to modify documents
|
||||
static const String modifyDocument = "c423fd81-6273-4b9d-bb5e-76a0fb343833";
|
||||
|
||||
/// Permission to delete documents
|
||||
static const String deleteDocument = "40863a13-5a66-469d-9b48-135bc5dbf486";
|
||||
|
||||
/// Permission to download documents
|
||||
static const String downloadDocument = "404373d0-860f-490e-a575-1c086ffbce1d";
|
||||
|
||||
/// Permission to verify documents
|
||||
static const String verifyDocument = "13a1f30f-38d1-41bf-8e7a-b75189aab8e0";
|
||||
}
|
||||
|
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;
|
||||
|
||||
// Task colors
|
||||
const completedColor = Color(0xFFE57373); // red
|
||||
const remainingColor = Color(0xFF64B5F6); // blue
|
||||
const completedColor = Color(0xFF64B5F6);
|
||||
const remainingColor =Color(0xFFE57373);
|
||||
|
||||
final List<_ChartData> pieData = [
|
||||
_ChartData('Completed', completed.toDouble(), completedColor),
|
||||
|
@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:marco/helpers/widgets/my_text.dart';
|
||||
|
||||
|
||||
class ConfirmDialog extends StatelessWidget {
|
||||
final String title;
|
||||
final String message;
|
||||
|
@ -113,6 +113,262 @@ class SkeletonLoaders {
|
||||
);
|
||||
}
|
||||
|
||||
// Document List Skeleton Loader
|
||||
static Widget documentSkeletonLoader() {
|
||||
return Column(
|
||||
children: List.generate(5, (index) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Date placeholder
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
|
||||
child: Container(
|
||||
height: 12,
|
||||
width: 80,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade300,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Document Card Skeleton
|
||||
Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Icon Placeholder
|
||||
Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade300,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Icon(Icons.description,
|
||||
color: Colors.transparent), // invisible icon
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
|
||||
// Text placeholders
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
height: 12,
|
||||
width: 80,
|
||||
color: Colors.grey.shade300,
|
||||
),
|
||||
MySpacing.height(6),
|
||||
Container(
|
||||
height: 14,
|
||||
width: double.infinity,
|
||||
color: Colors.grey.shade300,
|
||||
),
|
||||
MySpacing.height(6),
|
||||
Container(
|
||||
height: 12,
|
||||
width: 100,
|
||||
color: Colors.grey.shade300,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Action icon placeholder
|
||||
Container(
|
||||
width: 20,
|
||||
height: 20,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade300,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Document Details Card Skeleton Loader
|
||||
static Widget documentDetailsSkeletonLoader() {
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Details Card
|
||||
Container(
|
||||
constraints: const BoxConstraints(maxWidth: 460),
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.06),
|
||||
blurRadius: 16,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Header
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 56,
|
||||
height: 56,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade300,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
height: 16,
|
||||
width: 180,
|
||||
color: Colors.grey.shade300,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
height: 12,
|
||||
width: 120,
|
||||
color: Colors.grey.shade300,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Tags placeholder
|
||||
Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 6,
|
||||
children: List.generate(3, (index) {
|
||||
return Container(
|
||||
height: 20,
|
||||
width: 60,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade300,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Info rows placeholders
|
||||
Column(
|
||||
children: List.generate(10, (index) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
height: 12,
|
||||
width: 120,
|
||||
color: Colors.grey.shade300,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Container(
|
||||
height: 12,
|
||||
color: Colors.grey.shade300,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// Versions section skeleton
|
||||
Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: List.generate(3, (index) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade300,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
height: 12,
|
||||
width: 180,
|
||||
color: Colors.grey.shade300,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Container(
|
||||
height: 10,
|
||||
width: 120,
|
||||
color: Colors.grey.shade300,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Container(
|
||||
width: 24,
|
||||
height: 24,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade300,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Employee List - Card Style
|
||||
static Widget employeeListSkeletonLoader() {
|
||||
return Column(
|
||||
|
@ -13,7 +13,7 @@ Future<void> main() async {
|
||||
|
||||
await initLogging();
|
||||
logSafe("App starting...");
|
||||
|
||||
enableRemoteLogging();
|
||||
try {
|
||||
await initializeApp();
|
||||
logSafe("App initialized successfully.");
|
||||
@ -73,9 +73,11 @@ class _MainWrapperState extends State<MainWrapper> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bool isOffline = _connectivityStatus.contains(ConnectivityResult.none);
|
||||
final bool isOffline =
|
||||
_connectivityStatus.contains(ConnectivityResult.none);
|
||||
return isOffline
|
||||
? const MaterialApp(debugShowCheckedModeBanner: false, home: OfflineScreen())
|
||||
? const MaterialApp(
|
||||
debugShowCheckedModeBanner: false, home: OfflineScreen())
|
||||
: const MyApp();
|
||||
}
|
||||
}
|
||||
|
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_style.dart';
|
||||
import 'package:marco/helpers/utils/base_bottom_sheet.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:marco/helpers/widgets/my_snackbar.dart';
|
||||
|
||||
|
||||
class AddEmployeeBottomSheet extends StatefulWidget {
|
||||
@override
|
||||
@ -54,6 +57,18 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
|
||||
_controller.basicValidator.getValidation('last_name'),
|
||||
),
|
||||
MySpacing.height(16),
|
||||
_sectionLabel("Joining Details"),
|
||||
MySpacing.height(16),
|
||||
_buildDatePickerField(
|
||||
label: "Joining Date",
|
||||
value: _controller.joiningDate != null
|
||||
? DateFormat("dd MMM yyyy")
|
||||
.format(_controller.joiningDate!)
|
||||
: "",
|
||||
hint: "Select Joining Date",
|
||||
onTap: () => _pickJoiningDate(context),
|
||||
),
|
||||
MySpacing.height(16),
|
||||
_sectionLabel("Contact Details"),
|
||||
MySpacing.height(16),
|
||||
_buildPhoneInput(context),
|
||||
@ -83,12 +98,113 @@ class _AddEmployeeBottomSheetState extends State<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 {
|
||||
// Run form validation first
|
||||
final isValid =
|
||||
_controller.basicValidator.formKey.currentState?.validate() ?? false;
|
||||
|
||||
if (!isValid) {
|
||||
showAppSnackbar(
|
||||
title: "Missing Fields",
|
||||
message: "Please fill all required fields before submitting.",
|
||||
type: SnackbarType.warning,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Additional check for dropdowns & joining date
|
||||
if (_controller.joiningDate == null) {
|
||||
showAppSnackbar(
|
||||
title: "Missing Fields",
|
||||
message: "Please select Joining Date.",
|
||||
type: SnackbarType.warning,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (_controller.selectedGender == null) {
|
||||
showAppSnackbar(
|
||||
title: "Missing Fields",
|
||||
message: "Please select Gender.",
|
||||
type: SnackbarType.warning,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (_controller.selectedRoleId == null) {
|
||||
showAppSnackbar(
|
||||
title: "Missing Fields",
|
||||
message: "Please select Role.",
|
||||
type: SnackbarType.warning,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// All validations passed → Call API
|
||||
final result = await _controller.createEmployees();
|
||||
|
||||
if (result != null && result['success'] == true) {
|
||||
final employeeData = result['data']; // ✅ Safe now
|
||||
final employeeData = result['data'];
|
||||
final employeeController = Get.find<EmployeesScreenController>();
|
||||
final projectId = employeeController.selectedProjectId;
|
||||
|
||||
@ -100,18 +216,20 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
|
||||
|
||||
employeeController.update(['employee_screen_controller']);
|
||||
|
||||
// Reset form
|
||||
_controller.basicValidator.getController("first_name")?.clear();
|
||||
_controller.basicValidator.getController("last_name")?.clear();
|
||||
_controller.basicValidator.getController("phone_number")?.clear();
|
||||
_controller.selectedGender = null;
|
||||
_controller.selectedRoleId = null;
|
||||
_controller.joiningDate = null;
|
||||
_controller.update();
|
||||
|
||||
Navigator.pop(context, employeeData);
|
||||
}
|
||||
}
|
||||
|
||||
// Section label widget
|
||||
// --- Section label widget ---
|
||||
Widget _sectionLabel(String title) => Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@ -121,7 +239,7 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
|
||||
],
|
||||
);
|
||||
|
||||
// Input field with icon
|
||||
// --- Input field with icon ---
|
||||
Widget _inputWithIcon({
|
||||
required String label,
|
||||
required String hint,
|
||||
@ -132,11 +250,16 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
MyText.labelMedium(label),
|
||||
_requiredLabel(label),
|
||||
MySpacing.height(8),
|
||||
TextFormField(
|
||||
controller: controller,
|
||||
validator: validator,
|
||||
validator: (val) {
|
||||
if (val == null || val.trim().isEmpty) {
|
||||
return "$label is required";
|
||||
}
|
||||
return validator?.call(val);
|
||||
},
|
||||
decoration: _inputDecoration(hint).copyWith(
|
||||
prefixIcon: Icon(icon, size: 20),
|
||||
),
|
||||
@ -145,12 +268,12 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
|
||||
);
|
||||
}
|
||||
|
||||
// Phone input with country code selector
|
||||
// --- Phone input ---
|
||||
Widget _buildPhoneInput(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
MyText.labelMedium("Phone Number"),
|
||||
_requiredLabel("Phone Number"),
|
||||
MySpacing.height(8),
|
||||
Row(
|
||||
children: [
|
||||
@ -161,7 +284,7 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
color: Colors.grey.shade100,
|
||||
),
|
||||
child: Text("+91"),
|
||||
child: const Text("+91"),
|
||||
),
|
||||
MySpacing.width(12),
|
||||
Expanded(
|
||||
@ -170,13 +293,11 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
|
||||
_controller.basicValidator.getController('phone_number'),
|
||||
validator: (value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return "Phone number is required";
|
||||
return "Phone Number is required";
|
||||
}
|
||||
|
||||
if (!RegExp(r'^\d{10}$').hasMatch(value.trim())) {
|
||||
return "Enter a valid 10-digit number";
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
keyboardType: TextInputType.phone,
|
||||
@ -198,7 +319,7 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
|
||||
);
|
||||
}
|
||||
|
||||
// Gender/Role field (read-only dropdown)
|
||||
// --- Dropdown (Gender/Role) ---
|
||||
Widget _buildDropdownField({
|
||||
required String label,
|
||||
required String value,
|
||||
@ -208,7 +329,7 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
MyText.labelMedium(label),
|
||||
_requiredLabel(label),
|
||||
MySpacing.height(8),
|
||||
GestureDetector(
|
||||
onTap: onTap,
|
||||
@ -216,6 +337,12 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
|
||||
child: TextFormField(
|
||||
readOnly: true,
|
||||
controller: TextEditingController(text: value),
|
||||
validator: (val) {
|
||||
if (val == null || val.trim().isEmpty) {
|
||||
return "$label is required";
|
||||
}
|
||||
return null;
|
||||
},
|
||||
decoration: _inputDecoration(hint).copyWith(
|
||||
suffixIcon: const Icon(Icons.expand_more),
|
||||
),
|
||||
@ -226,7 +353,7 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
|
||||
);
|
||||
}
|
||||
|
||||
// Common input decoration
|
||||
// --- Common input decoration ---
|
||||
InputDecoration _inputDecoration(String hint) {
|
||||
return InputDecoration(
|
||||
hintText: hint,
|
||||
@ -249,7 +376,7 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
|
||||
);
|
||||
}
|
||||
|
||||
// Gender popup menu
|
||||
// --- Gender popup ---
|
||||
void _showGenderPopup(BuildContext context) async {
|
||||
final selected = await showMenu<Gender>(
|
||||
context: context,
|
||||
@ -268,7 +395,7 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
|
||||
}
|
||||
}
|
||||
|
||||
// Role popup menu
|
||||
// --- Role popup ---
|
||||
void _showRolePopup(BuildContext context) async {
|
||||
final selected = await showMenu<String>(
|
||||
context: context,
|
||||
|
@ -118,7 +118,61 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> {
|
||||
onCancel: Get.back,
|
||||
onSubmit: () {
|
||||
if (_formKey.currentState!.validate()) {
|
||||
// Additional dropdown validation
|
||||
if (controller.selectedProject.value.isEmpty) {
|
||||
showAppSnackbar(
|
||||
title: "Error",
|
||||
message: "Please select a project",
|
||||
type: SnackbarType.error,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (controller.selectedExpenseType.value == null) {
|
||||
showAppSnackbar(
|
||||
title: "Error",
|
||||
message: "Please select an expense type",
|
||||
type: SnackbarType.error,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (controller.selectedPaymentMode.value == null) {
|
||||
showAppSnackbar(
|
||||
title: "Error",
|
||||
message: "Please select a payment mode",
|
||||
type: SnackbarType.error,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (controller.selectedPaidBy.value == null) {
|
||||
showAppSnackbar(
|
||||
title: "Error",
|
||||
message: "Please select a person who paid",
|
||||
type: SnackbarType.error,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (controller.attachments.isEmpty &&
|
||||
controller.existingAttachments.isEmpty) {
|
||||
showAppSnackbar(
|
||||
title: "Error",
|
||||
message: "Please attach at least one document",
|
||||
type: SnackbarType.error,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Validation passed, submit
|
||||
controller.submitOrUpdateExpense();
|
||||
} else {
|
||||
showAppSnackbar(
|
||||
title: "Error",
|
||||
message: "Please fill all required fields correctly",
|
||||
type: SnackbarType.error,
|
||||
);
|
||||
}
|
||||
},
|
||||
child: SingleChildScrollView(
|
||||
@ -186,12 +240,6 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> {
|
||||
_CustomTextField(
|
||||
controller: controller.gstController,
|
||||
hint: "Enter GST No.",
|
||||
validator: (value) {
|
||||
if (value != null && value.isNotEmpty) {
|
||||
return Validators.gstValidator(value);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
|
||||
MySpacing.height(16),
|
||||
@ -284,7 +332,7 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> {
|
||||
if (value != null && value.isNotEmpty) {
|
||||
return Validators.transactionIdValidator(value);
|
||||
}
|
||||
return null;
|
||||
return null;
|
||||
},
|
||||
),
|
||||
|
||||
@ -743,6 +791,8 @@ class _AttachmentsSection extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
}),
|
||||
|
||||
// 📎 File Picker Button
|
||||
GestureDetector(
|
||||
onTap: onAdd,
|
||||
child: Container(
|
||||
@ -753,7 +803,24 @@ class _AttachmentsSection extends StatelessWidget {
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
color: Colors.grey.shade100,
|
||||
),
|
||||
child: const Icon(Icons.add, size: 30, color: Colors.grey),
|
||||
child: const Icon(Icons.attach_file,
|
||||
size: 30, color: Colors.grey),
|
||||
),
|
||||
),
|
||||
|
||||
// 📷 Camera Button
|
||||
GestureDetector(
|
||||
onTap: () => Get.find<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/directory/directory_main_screen.dart';
|
||||
import 'package:marco/view/expense/expense_screen.dart';
|
||||
import 'package:marco/view/document/user_document_screen.dart';
|
||||
|
||||
class AuthMiddleware extends GetMiddleware {
|
||||
@override
|
||||
@ -70,6 +71,11 @@ getPageRoute() {
|
||||
GetPage(
|
||||
name: '/dashboard/expense-main-page',
|
||||
page: () => ExpenseMainScreen(),
|
||||
middlewares: [AuthMiddleware()]),
|
||||
// Documents
|
||||
GetPage(
|
||||
name: '/dashboard/document-main-page',
|
||||
page: () => UserDocumentsPage(),
|
||||
middlewares: [AuthMiddleware()]),
|
||||
// Authentication
|
||||
GetPage(name: '/auth/login', page: () => LoginScreen()),
|
||||
|
@ -31,6 +31,7 @@ class DashboardScreen extends StatefulWidget {
|
||||
"/dashboard/daily-task-progress";
|
||||
static const String directoryMainPageRoute = "/dashboard/directory-main-page";
|
||||
static const String expenseMainPageRoute = "/dashboard/expense-main-page";
|
||||
static const String documentMainPageRoute = "/dashboard/document-main-page";
|
||||
|
||||
@override
|
||||
State<DashboardScreen> createState() => _DashboardScreenState();
|
||||
@ -39,8 +40,7 @@ class DashboardScreen extends StatefulWidget {
|
||||
class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
|
||||
final DashboardController dashboardController =
|
||||
Get.put(DashboardController(), permanent: true);
|
||||
final DynamicMenuController menuController =
|
||||
Get.put(DynamicMenuController(), permanent: true);
|
||||
final DynamicMenuController menuController = Get.put(DynamicMenuController());
|
||||
|
||||
bool hasMpin = true;
|
||||
|
||||
@ -79,7 +79,7 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
|
||||
*/
|
||||
_buildDashboardStats(context),
|
||||
MySpacing.height(24),
|
||||
SizedBox(
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: DashboardOverviewWidgets.teamsOverview(),
|
||||
),
|
||||
@ -242,7 +242,8 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
|
||||
if (menuController.isLoading.value) {
|
||||
return _buildLoadingSkeleton(context);
|
||||
}
|
||||
if (menuController.hasError.value) {
|
||||
if (menuController.hasError.value && menuController.menuItems.isEmpty) {
|
||||
// ❌ Only show error if there are no menus at all
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Center(
|
||||
@ -267,6 +268,8 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
|
||||
DashboardScreen.directoryMainPageRoute),
|
||||
_StatItem(LucideIcons.badge_dollar_sign, "Expense", contentTheme.info,
|
||||
DashboardScreen.expenseMainPageRoute),
|
||||
_StatItem(LucideIcons.file_text, "Documents", contentTheme.info,
|
||||
DashboardScreen.documentMainPageRoute),
|
||||
];
|
||||
|
||||
final projectController = Get.find<ProjectController>();
|
||||
@ -277,10 +280,16 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
|
||||
children: [
|
||||
if (!isProjectSelected) _buildNoProjectMessage(),
|
||||
Wrap(
|
||||
spacing: 6, // horizontal spacing
|
||||
runSpacing: 6, // vertical spacing
|
||||
spacing: 6,
|
||||
runSpacing: 6,
|
||||
children: stats
|
||||
.where((stat) => menuController.isMenuAllowed(stat.title))
|
||||
.where((stat) {
|
||||
// ✅ Always allow Documents
|
||||
if (stat.title == "Documents") return true;
|
||||
|
||||
// For all other menus, respect sidebar permissions
|
||||
return menuController.isMenuAllowed(stat.title);
|
||||
})
|
||||
.map((stat) => _buildStatCard(stat, isProjectSelected))
|
||||
.toList(),
|
||||
),
|
||||
@ -290,10 +299,14 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
|
||||
}
|
||||
|
||||
/// Stat Card (Compact with wrapping text)
|
||||
Widget _buildStatCard(_StatItem statItem, bool isEnabled) {
|
||||
/// Stat Card (Compact with wrapping text)
|
||||
Widget _buildStatCard(_StatItem statItem, bool isProjectSelected) {
|
||||
const double cardWidth = 80;
|
||||
const double cardHeight = 70;
|
||||
|
||||
// ✅ Attendance should always be enabled
|
||||
final bool isEnabled = statItem.title == "Attendance" || isProjectSelected;
|
||||
|
||||
return Opacity(
|
||||
opacity: isEnabled ? 1.0 : 0.4,
|
||||
child: IgnorePointer(
|
||||
@ -334,19 +347,6 @@ class _DashboardScreenState extends State<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
|
||||
void _handleStatCardTap(_StatItem statItem, bool isEnabled) {
|
||||
if (!isEnabled) {
|
||||
@ -363,6 +363,21 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
|
||||
Get.toNamed(statItem.route);
|
||||
}
|
||||
}
|
||||
|
||||
/// Compact Icon
|
||||
Widget _buildStatCardIconCompact(_StatItem statItem) {
|
||||
return MyContainer.rounded(
|
||||
paddingAll: 6,
|
||||
color: statItem.color.withOpacity(0.1),
|
||||
child: Icon(
|
||||
statItem.icon,
|
||||
size: 14,
|
||||
color: statItem.color,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Handle Tap
|
||||
}
|
||||
|
||||
class _StatItem {
|
||||
|
@ -16,6 +16,7 @@ import 'package:marco/model/directory/create_bucket_bottom_sheet.dart';
|
||||
import 'package:marco/view/directory/contact_detail_screen.dart';
|
||||
import 'package:marco/view/directory/manage_bucket_screen.dart';
|
||||
import 'package:marco/controller/permission_controller.dart';
|
||||
import 'package:marco/helpers/utils/permission_constants.dart';
|
||||
|
||||
class DirectoryView extends StatefulWidget {
|
||||
@override
|
||||
@ -288,35 +289,44 @@ class _DirectoryViewState extends State<DirectoryView> {
|
||||
),
|
||||
);
|
||||
|
||||
// Create Bucket option
|
||||
menuItems.add(
|
||||
PopupMenuItem<int>(
|
||||
value: 2,
|
||||
child: Row(
|
||||
children: const [
|
||||
Icon(Icons.add_box_outlined,
|
||||
size: 20, color: Colors.black87),
|
||||
SizedBox(width: 10),
|
||||
Expanded(child: Text("Create Bucket")),
|
||||
Icon(Icons.chevron_right,
|
||||
size: 20, color: Colors.red),
|
||||
],
|
||||
// ✅ Conditionally show Create Bucket option
|
||||
if (permissionController
|
||||
.hasPermission(Permissions.directoryAdmin) ||
|
||||
permissionController
|
||||
.hasPermission(Permissions.directoryManager) ||
|
||||
permissionController
|
||||
.hasPermission(Permissions.directoryUser)) {
|
||||
menuItems.add(
|
||||
PopupMenuItem<int>(
|
||||
value: 2,
|
||||
child: Row(
|
||||
children: const [
|
||||
Icon(Icons.add_box_outlined,
|
||||
size: 20, color: Colors.black87),
|
||||
SizedBox(width: 10),
|
||||
Expanded(child: Text("Create Bucket")),
|
||||
Icon(Icons.chevron_right,
|
||||
size: 20, color: Colors.red),
|
||||
],
|
||||
),
|
||||
onTap: () {
|
||||
Future.delayed(Duration.zero, () async {
|
||||
final created =
|
||||
await showModalBottomSheet<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
|
||||
menuItems.add(
|
||||
@ -355,7 +365,7 @@ class _DirectoryViewState extends State<DirectoryView> {
|
||||
),
|
||||
);
|
||||
|
||||
// Show Inactive switch
|
||||
// Show Inactive toggle
|
||||
menuItems.add(
|
||||
PopupMenuItem<int>(
|
||||
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:intl/intl.dart';
|
||||
import 'package:marco/controller/employee/employees_screen_controller.dart';
|
||||
import 'package:marco/controller/project_controller.dart';
|
||||
import 'package:marco/helpers/widgets/custom_app_bar.dart';
|
||||
import 'package:marco/helpers/widgets/avatar.dart';
|
||||
import 'package:marco/helpers/widgets/my_spacing.dart';
|
||||
import 'package:marco/helpers/widgets/my_text.dart';
|
||||
@ -15,6 +15,7 @@ import 'package:marco/helpers/widgets/my_refresh_indicator.dart';
|
||||
class EmployeeDetailPage extends StatefulWidget {
|
||||
final String employeeId;
|
||||
final bool fromProfile;
|
||||
|
||||
const EmployeeDetailPage({
|
||||
super.key,
|
||||
required this.employeeId,
|
||||
@ -30,6 +31,7 @@ class _EmployeeDetailPageState extends State<EmployeeDetailPage> {
|
||||
Get.put(EmployeesScreenController());
|
||||
final PermissionController _permissionController =
|
||||
Get.find<PermissionController>();
|
||||
|
||||
@override
|
||||
void 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,
|
||||
{bool isMultiLine = false}) {
|
||||
final lowerLabel = label.toLowerCase();
|
||||
@ -91,9 +92,8 @@ class _EmployeeDetailPageState extends State<EmployeeDetailPage> {
|
||||
fontWeight: FontWeight.normal,
|
||||
color: (isEmail || isPhone) ? Colors.indigo : Colors.black54,
|
||||
fontSize: 14,
|
||||
decoration: (isEmail || isPhone)
|
||||
? TextDecoration.underline
|
||||
: TextDecoration.none,
|
||||
decoration:
|
||||
(isEmail || isPhone) ? TextDecoration.underline : TextDecoration.none,
|
||||
),
|
||||
),
|
||||
);
|
||||
@ -147,7 +147,6 @@ class _EmployeeDetailPageState extends State<EmployeeDetailPage> {
|
||||
);
|
||||
}
|
||||
|
||||
/// Info card
|
||||
Widget _buildInfoCard(employee) {
|
||||
return Card(
|
||||
elevation: 3,
|
||||
@ -188,73 +187,22 @@ class _EmployeeDetailPageState extends State<EmployeeDetailPage> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bool showAppBar = !widget.fromProfile;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFFF1F1F1),
|
||||
appBar: PreferredSize(
|
||||
preferredSize: const Size.fromHeight(72),
|
||||
child: AppBar(
|
||||
backgroundColor: const Color(0xFFF5F5F5),
|
||||
elevation: 0.5,
|
||||
automaticallyImplyLeading: false,
|
||||
titleSpacing: 0,
|
||||
title: Padding(
|
||||
padding: MySpacing.xy(16, 0),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.arrow_back_ios_new,
|
||||
color: Colors.black, size: 20),
|
||||
onPressed: () {
|
||||
if (widget.fromProfile) {
|
||||
Get.back();
|
||||
} else {
|
||||
Get.offNamed('/dashboard/employees');
|
||||
}
|
||||
},
|
||||
),
|
||||
MySpacing.width(8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
MyText.titleLarge(
|
||||
'Employee Details',
|
||||
fontWeight: 700,
|
||||
color: Colors.black,
|
||||
),
|
||||
MySpacing.height(2),
|
||||
GetBuilder<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],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
appBar: showAppBar
|
||||
? CustomAppBar(
|
||||
title: 'Employee Details',
|
||||
onBackPressed: () {
|
||||
if (widget.fromProfile) {
|
||||
Get.back();
|
||||
} else {
|
||||
Get.offNamed('/dashboard/employees');
|
||||
}
|
||||
},
|
||||
)
|
||||
: null,
|
||||
body: Obx(() {
|
||||
if (controller.isLoadingEmployeeDetails.value) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
|
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/controller/project_controller.dart';
|
||||
import 'package:marco/helpers/widgets/my_custom_skeleton.dart';
|
||||
import 'package:marco/view/employees/employee_detail_screen.dart';
|
||||
import 'package:marco/model/employees/employee_model.dart';
|
||||
import 'package:marco/helpers/utils/launcher_utils.dart';
|
||||
import 'package:marco/view/employees/assign_employee_bottom_sheet.dart';
|
||||
import 'package:marco/controller/permission_controller.dart';
|
||||
import 'package:marco/helpers/utils/permission_constants.dart';
|
||||
import 'package:marco/helpers/widgets/my_refresh_indicator.dart';
|
||||
import 'package:marco/view/employees/employee_profile_screen.dart';
|
||||
|
||||
class EmployeesScreen extends StatefulWidget {
|
||||
const EmployeesScreen({super.key});
|
||||
@ -413,7 +413,7 @@ class _EmployeesScreenState extends State<EmployeesScreen> with UIMixin {
|
||||
final lastName = names.length > 1 ? names.last : '';
|
||||
|
||||
return InkWell(
|
||||
onTap: () => Get.to(() => EmployeeDetailPage(employeeId: e.id)),
|
||||
onTap: () => Get.to(() => EmployeeProfilePage(employeeId: e.id)),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
|
@ -9,7 +9,7 @@ import 'package:marco/helpers/widgets/my_text.dart';
|
||||
import 'package:marco/helpers/widgets/avatar.dart';
|
||||
import 'package:marco/model/employees/employee_info.dart';
|
||||
import 'package:marco/controller/auth/mpin_controller.dart';
|
||||
import 'package:marco/view/employees/employee_detail_screen.dart';
|
||||
import 'package:marco/view/employees/employee_profile_screen.dart';
|
||||
|
||||
class UserProfileBar extends StatefulWidget {
|
||||
final bool isCondensed;
|
||||
@ -238,9 +238,8 @@ class _UserProfileBarState extends State<UserProfileBar>
|
||||
}
|
||||
|
||||
void _onProfileTap() {
|
||||
Get.to(() => EmployeeDetailPage(
|
||||
Get.to(() => EmployeeProfilePage(
|
||||
employeeId: employeeInfo.id,
|
||||
fromProfile: true,
|
||||
));
|
||||
}
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user