Merge pull request 'Feature_Document' (#68) from Feature_Document into main

Reviewed-on: #68
This commit is contained in:
vaibhav.surve 2025-09-15 11:41:07 +00:00
commit d2712b8288
38 changed files with 5749 additions and 273 deletions

View File

@ -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
/*

View 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();
}
}

View 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;
}
}
}

View 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;
}
}

View File

@ -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();
}
}

View File

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

View File

@ -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() {

View File

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

View File

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

View File

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

View File

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

View 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);
}

View File

@ -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),

View File

@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:marco/helpers/widgets/my_text.dart';
class ConfirmDialog extends StatelessWidget {
final String title;
final String message;

View File

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

View File

@ -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();
}
}

View 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,
};
}
}

View 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"),
],
),
),
),
],
);
}

View 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,
};
}
}

View 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"),
],
),
),
),
],
);
}

View 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'] ?? "",
);
}
}

View 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,
};
}
}

View 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,
};
}
}

View 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(),
};
}
}

View 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(),
};
}
}

View 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;
}
}

View File

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

View File

@ -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),
),
),
],

View File

@ -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()),

View File

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

View File

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

View 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,
);
}
}
}

View 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,
);
}
}

View File

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

View 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,
),
],
),
),
],
),
);
}
}

View File

@ -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: [

View File

@ -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,
));
}