feat: Add document management features including document listing, details, and filtering

- Implemented DocumentsResponse and related models for handling document data.
- Created UserDocumentsPage for displaying user-specific documents with filtering options.
- Developed DocumentDetailsPage to show detailed information about a selected document.
- Added functionality for uploading documents with DocumentUploadBottomSheet.
- Integrated document filtering through UserDocumentFilterBottomSheet.
- Enhanced dashboard to include navigation to the document management section.
- Updated user profile right bar to provide quick access to user documents.
This commit is contained in:
Vaibhav Surve 2025-09-04 16:56:49 +05:30
parent 40a4a77af5
commit 334023bf1b
23 changed files with 3631 additions and 61 deletions

View File

@ -0,0 +1,58 @@
import 'package:get/get.dart';
import 'package:marco/helpers/services/api_service.dart';
import 'package:marco/model/document/document_details_model.dart';
import 'package:marco/model/document/document_version_model.dart';
class DocumentDetailsController extends GetxController {
/// Observables
var isLoading = false.obs;
var documentDetails = Rxn<DocumentDetailsResponse>();
var versions = <DocumentVersionItem>[].obs;
var isVersionsLoading = false.obs;
/// Fetch document details by id
Future<void> fetchDocumentDetails(String documentId) async {
try {
isLoading.value = true;
final response = await ApiService.getDocumentDetailsApi(documentId);
if (response != null) {
documentDetails.value = response;
} else {
documentDetails.value = null;
}
} finally {
isLoading.value = false;
}
}
/// Fetch document versions by parentAttachmentId
Future<void> fetchDocumentVersions(String parentAttachmentId) async {
try {
isVersionsLoading.value = true;
final response = await ApiService.getDocumentVersionsApi(
parentAttachmentId: parentAttachmentId,
);
if (response != null) {
versions.assignAll(response.data.data);
} else {
versions.clear();
}
} finally {
isVersionsLoading.value = false;
}
}
/// Fetch Pre-Signed URL for a given version
Future<String?> fetchPresignedUrl(String versionId) async {
return await ApiService.getPresignedUrlApi(versionId);
}
/// Clear data when leaving the screen
void clearDetails() {
documentDetails.value = null;
versions.clear();
}
}

View File

@ -0,0 +1,191 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:marco/helpers/services/app_logger.dart';
import 'package:marco/helpers/services/api_service.dart';
import 'package:marco/model/document/master_document_type_model.dart';
import 'package:marco/model/document/master_document_tags.dart';
import 'package:marco/helpers/widgets/my_snackbar.dart';
class DocumentUploadController extends GetxController {
// Observables
var isLoading = false.obs;
var isUploading = false.obs;
var categories = <DocumentType>[].obs;
var tags = <TagItem>[].obs;
DocumentType? selectedCategory;
/// --- FILE HANDLING ---
String? selectedFileName;
String? selectedFileBase64;
String? selectedFileContentType;
int? selectedFileSize;
/// --- TAG HANDLING ---
final tagCtrl = TextEditingController();
final enteredTags = <String>[].obs;
final filteredSuggestions = <String>[].obs;
var documentTypes = <DocumentType>[].obs;
DocumentType? selectedType;
@override
void onInit() {
super.onInit();
fetchCategories();
fetchTags();
}
/// Fetch available document categories
Future<void> fetchCategories() async {
try {
isLoading.value = true;
final response = await ApiService.getMasterDocumentTypesApi();
if (response != null && response.data.isNotEmpty) {
categories.assignAll(response.data);
logSafe("Fetched categories: ${categories.length}");
} else {
logSafe("No categories fetched", level: LogLevel.warning);
}
} finally {
isLoading.value = false;
}
}
Future<void> fetchDocumentTypes(String categoryId) async {
try {
isLoading.value = true;
final response =
await ApiService.getDocumentTypesByCategoryApi(categoryId);
if (response != null && response.data.isNotEmpty) {
documentTypes.assignAll(response.data);
selectedType = null; // reset previous type
} else {
documentTypes.clear();
selectedType = null;
}
} finally {
isLoading.value = false;
}
}
/// Fetch available document tags
Future<void> fetchTags() async {
try {
isLoading.value = true;
final response = await ApiService.getMasterDocumentTagsApi();
if (response != null) {
tags.assignAll(response.data);
logSafe("Fetched tags: ${tags.length}");
} else {
logSafe("No tags fetched", level: LogLevel.warning);
}
} finally {
isLoading.value = false;
}
}
/// --- TAG LOGIC ---
void filterSuggestions(String query) {
if (query.isEmpty) {
filteredSuggestions.clear();
return;
}
filteredSuggestions.assignAll(
tags.map((t) => t.name).where(
(tag) => tag.toLowerCase().contains(query.toLowerCase()),
),
);
}
void addEnteredTag(String tag) {
if (tag.trim().isEmpty) return;
if (!enteredTags.contains(tag.trim())) {
enteredTags.add(tag.trim());
}
}
void removeEnteredTag(String tag) {
enteredTags.remove(tag);
}
void clearSuggestions() {
filteredSuggestions.clear();
}
/// Upload document
Future<bool> uploadDocument({
required String documentId,
required String name,
required String entityId,
required String documentTypeId,
required String fileName,
required String base64Data,
required String contentType,
required int fileSize,
String? description,
}) async {
try {
isUploading.value = true;
final payloadTags =
enteredTags.map((t) => {"name": t, "isActive": true}).toList();
final payload = {
"documentId": documentId,
"name": name,
"description": description,
"entityId": entityId,
"documentTypeId": documentTypeId,
"fileName": fileName,
"base64Data":
base64Data.isNotEmpty ? "<base64-string-truncated>" : null,
"contentType": contentType,
"fileSize": fileSize,
"tags": payloadTags,
};
// Log the payload (hide long base64 string for readability)
logSafe("Upload payload: $payload");
final success = await ApiService.uploadDocumentApi(
documentId: documentId,
name: name,
description: description,
entityId: entityId,
documentTypeId: documentTypeId,
fileName: fileName,
base64Data: base64Data,
contentType: contentType,
fileSize: fileSize,
tags: payloadTags,
);
if (success) {
showAppSnackbar(
title: "Success",
message: "Document uploaded successfully",
type: SnackbarType.success,
);
} else {
showAppSnackbar(
title: "Error",
message: "Could not upload document",
type: SnackbarType.error,
);
}
return success;
} catch (e, stack) {
logSafe("Upload error: $e", level: LogLevel.error);
logSafe("Stacktrace: $stack", level: LogLevel.debug);
showAppSnackbar(
title: "Error",
message: "An unexpected error occurred",
type: SnackbarType.error,
);
return false;
} finally {
isUploading.value = false;
}
}
}

View File

@ -0,0 +1,98 @@
import 'package:get/get.dart';
import 'package:marco/helpers/services/api_service.dart';
import 'package:marco/model/document/document_filter_model.dart';
import 'package:marco/model/document/documents_list_model.dart';
class DocumentController extends GetxController {
// ------------------ Observables ---------------------
var isLoading = false.obs;
var documents = <DocumentItem>[].obs;
var filters = Rxn<DocumentFiltersData>();
var selectedFilter = "".obs;
var selectedUploadedBy = "".obs;
var selectedCategory = "".obs;
var selectedType = "".obs;
var selectedTag = "".obs;
// Pagination state
var pageNumber = 1.obs;
final int pageSize = 20;
var hasMore = true.obs;
// Error message
var errorMessage = "".obs;
// ------------------ API Calls -----------------------
/// Fetch Document Filters for an Entity
Future<void> fetchFilters(String entityTypeId) async {
try {
isLoading.value = true;
final response = await ApiService.getDocumentFilters(entityTypeId);
if (response != null && response.success) {
filters.value = response.data;
} else {
errorMessage.value = response?.message ?? "Failed to fetch filters";
}
} catch (e) {
errorMessage.value = "Error fetching filters: $e";
} finally {
isLoading.value = false;
}
}
/// Fetch Documents for an entity
Future<void> fetchDocuments({
required String entityTypeId,
required String entityId,
String filter = "",
String searchString = "",
bool reset = false,
}) async {
try {
if (reset) {
pageNumber.value = 1;
documents.clear();
hasMore.value = true;
}
if (!hasMore.value) return;
isLoading.value = true;
final response = await ApiService.getDocumentListApi(
entityTypeId: entityTypeId,
entityId: entityId,
filter: filter,
searchString: searchString,
pageNumber: pageNumber.value,
pageSize: pageSize,
);
if (response != null && response.success) {
if (response.data.data.isNotEmpty) {
documents.addAll(response.data.data);
pageNumber.value++;
} else {
hasMore.value = false;
}
} else {
errorMessage.value = response?.message ?? "Failed to fetch documents";
}
} catch (e) {
errorMessage.value = "Error fetching documents: $e";
} finally {
isLoading.value = false;
}
}
// ------------------ Helpers -----------------------
/// Clear selected filters
void clearFilters() {
selectedUploadedBy.value = "";
selectedCategory.value = "";
selectedType.value = "";
selectedTag.value = "";
}
}

View File

@ -1,15 +1,15 @@
class ApiEndpoints {
// static const String baseUrl = "https://stageapi.marcoaiot.com/api";
static const String baseUrl = "https://api.marcoaiot.com/api";
// Dashboard Module API Endpoints
static const String getDashboardAttendanceOverview = "/dashboard/attendance-overview";
static const String baseUrl = "https://stageapi.marcoaiot.com/api";
// static const String baseUrl = "https://api.marcoaiot.com/api";
//
// Dashboard Module API Endpoints
static const String getDashboardAttendanceOverview =
"/dashboard/attendance-overview";
static const String getDashboardProjectProgress = "/dashboard/progression";
static const String getDashboardTasks = "/dashboard/tasks";
static const String getDashboardTeams = "/dashboard/teams";
static const String getDashboardProjects = "/dashboard/projects";
// Attendance Module API Endpoints
static const String getProjects = "/project/list";
static const String getGlobalProjects = "/project/list/basic";
@ -45,7 +45,8 @@ class ApiEndpoints {
static const String getDirectoryContacts = "/directory";
static const String getDirectoryBucketList = "/directory/buckets";
static const String getDirectoryContactDetail = "/directory/notes";
static const String getDirectoryContactCategory = "/master/contact-categories";
static const String getDirectoryContactCategory =
"/master/contact-categories";
static const String getDirectoryContactTags = "/master/contact-tags";
static const String getDirectoryOrganization = "/directory/organization";
static const String createContact = "/directory";
@ -70,4 +71,17 @@ class ApiEndpoints {
////// Dynamic Menu Module API Endpoints
static const String getDynamicMenu = "/appmenu/get/menu-mobile";
///// Document Module API Endpoints
static const String getMasterDocumentCategories =
"/master/document-category/list";
static const String getMasterDocumentTags = "/document/get/tags";
static const String getDocumentList = "/document/list";
static const String getDocumentDetails = "/document/get/details";
static const String uploadDocument = "/document/upload";
static const String deleteDocument = "/document/delete";
static const String getDocumentFilter = "/document/get/filter";
static const String getDocumentTypesByCategory = "/master/document-type/list";
static const String getDocumentVersion = "/document/get/version";
static const String getDocumentVersions = "/document/list/versions";
}

View File

@ -12,6 +12,13 @@ import 'package:marco/model/dashboard/project_progress_model.dart';
import 'package:marco/model/dashboard/dashboard_tasks_model.dart';
import 'package:marco/model/dashboard/dashboard_teams_model.dart';
import 'package:marco/helpers/services/app_logger.dart';
import 'package:marco/model/document/document_filter_model.dart';
import 'package:marco/model/document/documents_list_model.dart';
import 'package:marco/model/document/master_document_tags.dart';
import 'package:marco/model/document/master_document_type_model.dart';
import 'package:marco/model/document/document_details_model.dart';
import 'package:marco/model/document/document_version_model.dart';
class ApiService {
static const Duration timeout = Duration(seconds: 30);
@ -240,6 +247,340 @@ class ApiService {
return null;
}
}
/// Get Pre-Signed URL for Old Version
static Future<String?> getPresignedUrlApi(String versionId) async {
final endpoint = "${ApiEndpoints.getDocumentVersion}/$versionId";
logSafe("Fetching Pre-Signed URL for versionId: $versionId");
try {
final response = await _getRequest(endpoint);
if (response == null) {
logSafe("Pre-Signed URL request failed: null response",
level: LogLevel.error);
return null;
}
final jsonResponse =
_parseResponseForAllData(response, label: "Pre-Signed URL");
if (jsonResponse != null) {
return jsonResponse['data'] as String?;
}
} catch (e, stack) {
logSafe("Exception during getPresignedUrlApi: $e",
level: LogLevel.error);
logSafe("StackTrace: $stack", level: LogLevel.debug);
}
return null;
}
/// Get List of Versions by ParentAttachmentId
static Future<DocumentVersionsResponse?> getDocumentVersionsApi({
required String parentAttachmentId,
int pageNumber = 1,
int pageSize = 20,
}) async {
final endpoint =
"${ApiEndpoints.getDocumentVersions}/$parentAttachmentId";
final queryParams = {
"pageNumber": pageNumber.toString(),
"pageSize": pageSize.toString(),
};
logSafe(
"Fetching document versions for parentAttachmentId: $parentAttachmentId");
try {
final response =
await _getRequest(endpoint, queryParams: queryParams);
if (response == null) {
logSafe("Document versions request failed: null response",
level: LogLevel.error);
return null;
}
final jsonResponse =
_parseResponseForAllData(response, label: "Document Versions");
if (jsonResponse != null) {
return DocumentVersionsResponse.fromJson(jsonResponse);
}
} catch (e, stack) {
logSafe("Exception during getDocumentVersionsApi: $e",
level: LogLevel.error);
logSafe("StackTrace: $stack", level: LogLevel.debug);
}
return null;
}
/// Get Document Details by ID
static Future<DocumentDetailsResponse?> getDocumentDetailsApi(
String documentId) async {
final endpoint = "${ApiEndpoints.getDocumentDetails}/$documentId";
logSafe("Fetching document details for id: $documentId");
try {
final response = await _getRequest(endpoint);
if (response == null) {
logSafe("Document details request failed: null response",
level: LogLevel.error);
return null;
}
final jsonResponse =
_parseResponseForAllData(response, label: "Document Details");
if (jsonResponse != null) {
return DocumentDetailsResponse.fromJson(jsonResponse);
}
} catch (e, stack) {
logSafe("Exception during getDocumentDetailsApi: $e",
level: LogLevel.error);
logSafe("StackTrace: $stack", level: LogLevel.debug);
}
return null;
}
/// Get Document Types by CategoryId
static Future<DocumentTypesResponse?> getDocumentTypesByCategoryApi(
String documentCategoryId) async {
const endpoint = ApiEndpoints.getDocumentTypesByCategory;
logSafe("Fetching document types for category: $documentCategoryId");
try {
final response = await _getRequest(
endpoint,
queryParams: {"documentCategoryId": documentCategoryId},
);
if (response == null) {
logSafe("Document types by category request failed: null response",
level: LogLevel.error);
return null;
}
final jsonResponse = _parseResponseForAllData(response,
label: "Document Types by Category");
if (jsonResponse != null) {
return DocumentTypesResponse.fromJson(jsonResponse);
}
} catch (e, stack) {
logSafe("Exception during getDocumentTypesByCategoryApi: $e",
level: LogLevel.error);
logSafe("StackTrace: $stack", level: LogLevel.debug);
}
return null;
}
/// Get Master Document Types (Category Types)
static Future<DocumentTypesResponse?> getMasterDocumentTypesApi() async {
const endpoint = ApiEndpoints.getMasterDocumentCategories;
logSafe("Fetching master document types...");
try {
final response = await _getRequest(endpoint);
if (response == null) {
logSafe("Document types request failed: null response",
level: LogLevel.error);
return null;
}
final jsonResponse =
_parseResponseForAllData(response, label: "Master Document Types");
if (jsonResponse != null) {
return DocumentTypesResponse.fromJson(jsonResponse);
}
} catch (e, stack) {
logSafe("Exception during getMasterDocumentTypesApi: $e",
level: LogLevel.error);
logSafe("StackTrace: $stack", level: LogLevel.debug);
}
return null;
}
/// Upload Document API
static Future<bool> uploadDocumentApi({
required String name,
String? documentId,
String? description,
required String entityId,
required String documentTypeId,
required String fileName,
required String base64Data,
required String contentType,
required int fileSize,
String? fileDescription,
bool isActive = true,
List<Map<String, dynamic>> tags = const [],
}) async {
const endpoint = ApiEndpoints.uploadDocument;
logSafe("Uploading document: $name for entity: $entityId");
final Map<String, dynamic> payload = {
"name": name,
"documentId": documentId ?? "",
"description": description ?? "",
"entityId": entityId,
"documentTypeId": documentTypeId,
"attachment": {
"fileName": fileName,
"base64Data": base64Data,
"contentType": contentType,
"fileSize": fileSize,
"description": fileDescription ?? "",
"isActive": isActive,
},
"tags": tags.isNotEmpty
? tags
: [
{"name": "default", "isActive": true}
],
};
try {
final response =
await _postRequest(endpoint, payload, customTimeout: extendedTimeout);
if (response == null) {
logSafe("Upload document failed: null response", level: LogLevel.error);
return false;
}
logSafe("Upload document response status: ${response.statusCode}");
logSafe("Upload document response body: ${response.body}");
final json = jsonDecode(response.body);
if (json['success'] == true) {
logSafe("Document uploaded successfully: ${json['data']}");
return true;
} else {
logSafe(
"Failed to upload document: ${json['message'] ?? 'Unknown error'}",
level: LogLevel.warning,
);
}
} catch (e, stack) {
logSafe("Exception during uploadDocumentApi: $e", level: LogLevel.error);
logSafe("StackTrace: $stack", level: LogLevel.debug);
}
return false;
}
/// Get Master Document Tags
static Future<TagResponse?> getMasterDocumentTagsApi() async {
const endpoint = ApiEndpoints.getMasterDocumentTags;
logSafe("Fetching master document tags...");
try {
final response = await _getRequest(endpoint);
if (response == null) {
logSafe("Tags request failed: null response", level: LogLevel.error);
return null;
}
final jsonResponse =
_parseResponseForAllData(response, label: "Master Document Tags");
if (jsonResponse != null) {
return TagResponse.fromJson(jsonResponse);
}
} catch (e, stack) {
logSafe("Exception during getMasterDocumentTagsApi: $e",
level: LogLevel.error);
logSafe("StackTrace: $stack", level: LogLevel.debug);
}
return null;
}
/// Get Document List by EntityTypeId and EntityId
static Future<DocumentsResponse?> getDocumentListApi({
required String entityTypeId,
required String entityId,
String filter = "",
String searchString = "",
bool isActive = true,
int pageNumber = 1,
int pageSize = 20,
}) async {
final endpoint =
"${ApiEndpoints.getDocumentList}/$entityTypeId/entity/$entityId";
final queryParams = {
"filter": filter,
"searchString": searchString,
"isActive": isActive.toString(),
"pageNumber": pageNumber.toString(),
"pageSize": pageSize.toString(),
};
logSafe(
"Fetching document list for entityTypeId: $entityTypeId, entityId: $entityId");
try {
final response = await _getRequest(endpoint, queryParams: queryParams);
if (response == null) {
logSafe("Document list request failed: null response",
level: LogLevel.error);
return null;
}
final jsonResponse =
_parseResponseForAllData(response, label: "Document List");
if (jsonResponse != null) {
return DocumentsResponse.fromJson(jsonResponse);
}
} catch (e, stack) {
logSafe("Exception during getDocumentListApi: $e", level: LogLevel.error);
logSafe("StackTrace: $stack", level: LogLevel.debug);
}
return null;
}
/// Get Document Filters by EntityTypeId
static Future<DocumentFiltersResponse?> getDocumentFilters(
String entityTypeId) async {
final endpoint = "${ApiEndpoints.getDocumentFilter}/$entityTypeId";
logSafe("Fetching document filters for entityTypeId: $entityTypeId");
try {
final response = await _getRequest(endpoint, queryParams: null);
if (response == null) {
logSafe("Document filter request failed: null response",
level: LogLevel.error);
return null;
}
final jsonResponse =
_parseResponseForAllData(response, label: "Document Filters");
if (jsonResponse != null) {
return DocumentFiltersResponse.fromJson(jsonResponse);
}
} catch (e, stack) {
logSafe("Exception during getDocumentFilters: $e", level: LogLevel.error);
logSafe("StackTrace: $stack", level: LogLevel.debug);
}
return null;
}
// === Menu APIs === //

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,11 @@ class Permissions {
// ------------------- Application Roles -------------------------------
/// Application role ID for users with full expense management rights
static const String expenseManagement = "a4e25142-449b-4334-a6e5-22f70e4732d7";
// ------------------- Document Entities -------------------------------
/// Entity ID for project documents
static const String projectEntity = "c8fe7115-aa27-43bc-99f4-7b05fabe436e";
/// Entity ID for employee documents
static const String employeeEntity = "dbb9555a-7a0c-40f2-a9ed-f0463f1ceed7";
}

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

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

@ -0,0 +1,213 @@
class DocumentDetailsResponse {
final bool success;
final String message;
final DocumentDetails? data;
final dynamic errors;
final int statusCode;
final DateTime timestamp;
DocumentDetailsResponse({
required this.success,
required this.message,
required this.data,
required this.errors,
required this.statusCode,
required this.timestamp,
});
factory DocumentDetailsResponse.fromJson(Map<String, dynamic> json) {
return DocumentDetailsResponse(
success: json['success'] ?? false,
message: json['message'] ?? '',
data: json['data'] != null ? DocumentDetails.fromJson(json['data']) : null,
errors: json['errors'],
statusCode: json['statusCode'] ?? 0,
timestamp: DateTime.tryParse(json['timestamp'] ?? '') ?? DateTime.now(),
);
}
}
class DocumentDetails {
final String id;
final String name;
final String documentId;
final String? description;
final int version;
final bool isCurrentVersion;
final String parentAttachmentId;
final DateTime uploadedAt;
final UploadedBy uploadedBy;
final DateTime? updatedAt;
final UploadedBy? updatedBy;
final DateTime? verifiedAt;
final bool? isVerified;
final UploadedBy? verifiedBy;
final String entityId;
final DocumentType documentType;
final List<DocumentTag> tags;
final bool isActive;
DocumentDetails({
required this.id,
required this.name,
required this.documentId,
this.description,
required this.version,
required this.isCurrentVersion,
required this.parentAttachmentId,
required this.uploadedAt,
required this.uploadedBy,
this.updatedAt,
this.updatedBy,
this.verifiedAt,
this.isVerified,
this.verifiedBy,
required this.entityId,
required this.documentType,
required this.tags,
required this.isActive,
});
factory DocumentDetails.fromJson(Map<String, dynamic> json) {
return DocumentDetails(
id: json['id'] ?? '',
name: json['name'] ?? '',
documentId: json['documentId'] ?? '',
description: json['description'],
version: json['version'] ?? 0,
isCurrentVersion: json['isCurrentVersion'] ?? false,
parentAttachmentId: json['parentAttachmentId'] ?? '',
uploadedAt: DateTime.tryParse(json['uploadedAt'] ?? '') ?? DateTime.now(),
uploadedBy: UploadedBy.fromJson(json['uploadedBy'] ?? {}),
updatedAt: json['updatedAt'] != null
? DateTime.tryParse(json['updatedAt'])
: null,
updatedBy: json['updatedBy'] != null
? UploadedBy.fromJson(json['updatedBy'])
: null,
verifiedAt: json['verifiedAt'] != null
? DateTime.tryParse(json['verifiedAt'])
: null,
isVerified: json['isVerified'],
verifiedBy: json['verifiedBy'] != null
? UploadedBy.fromJson(json['verifiedBy'])
: null,
entityId: json['entityId'] ?? '',
documentType: DocumentType.fromJson(json['documentType'] ?? {}),
tags: (json['tags'] as List<dynamic>?)
?.map((tag) => DocumentTag.fromJson(tag))
.toList() ??
[],
isActive: json['isActive'] ?? false,
);
}
}
class UploadedBy {
final String id;
final String firstName;
final String? lastName;
final String? photo;
final String jobRoleId;
final String jobRoleName;
UploadedBy({
required this.id,
required this.firstName,
this.lastName,
this.photo,
required this.jobRoleId,
required this.jobRoleName,
});
factory UploadedBy.fromJson(Map<String, dynamic> json) {
return UploadedBy(
id: json['id'] ?? '',
firstName: json['firstName'] ?? '',
lastName: json['lastName'],
photo: json['photo'],
jobRoleId: json['jobRoleId'] ?? '',
jobRoleName: json['jobRoleName'] ?? '',
);
}
}
class DocumentType {
final String id;
final String name;
final String? regexExpression; // nullable
final String allowedContentType;
final int maxSizeAllowedInMB;
final bool isValidationRequired;
final bool isMandatory;
final bool isSystem;
final bool isActive;
final DocumentCategory? documentCategory; // nullable
DocumentType({
required this.id,
required this.name,
this.regexExpression,
required this.allowedContentType,
required this.maxSizeAllowedInMB,
required this.isValidationRequired,
required this.isMandatory,
required this.isSystem,
required this.isActive,
this.documentCategory,
});
factory DocumentType.fromJson(Map<String, dynamic> json) {
return DocumentType(
id: json['id'] ?? '',
name: json['name'] ?? '',
regexExpression: json['regexExpression'],
allowedContentType: json['allowedContentType'] ?? '',
maxSizeAllowedInMB: json['maxSizeAllowedInMB'] ?? 0,
isValidationRequired: json['isValidationRequired'] ?? false,
isMandatory: json['isMandatory'] ?? false,
isSystem: json['isSystem'] ?? false,
isActive: json['isActive'] ?? false,
documentCategory: json['documentCategory'] != null
? DocumentCategory.fromJson(json['documentCategory'])
: null,
);
}
}
class DocumentCategory {
final String id;
final String name;
final String? description;
final String entityTypeId;
DocumentCategory({
required this.id,
required this.name,
this.description,
required this.entityTypeId,
});
factory DocumentCategory.fromJson(Map<String, dynamic> json) {
return DocumentCategory(
id: json['id'] ?? '',
name: json['name'] ?? '',
description: json['description'],
entityTypeId: json['entityTypeId'] ?? '',
);
}
}
class DocumentTag {
final String name;
final bool isActive;
DocumentTag({required this.name, required this.isActive});
factory DocumentTag.fromJson(Map<String, dynamic> json) {
return DocumentTag(
name: json['name'] ?? '',
isActive: json['isActive'] ?? false,
);
}
}

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,773 @@
import 'dart:convert';
import 'dart:io';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:marco/controller/document/document_upload_controller.dart';
import 'package:marco/helpers/utils/base_bottom_sheet.dart';
import 'package:marco/helpers/widgets/my_spacing.dart';
import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/helpers/widgets/my_text_style.dart';
import 'package:marco/helpers/widgets/image_viewer_dialog.dart';
import 'package:marco/helpers/widgets/my_snackbar.dart';
class DocumentUploadBottomSheet extends StatefulWidget {
final Function(Map<String, dynamic>) onSubmit;
final Map<String, dynamic>? initialData;
const DocumentUploadBottomSheet({
Key? key,
required this.onSubmit,
this.initialData,
}) : super(key: key);
@override
State<DocumentUploadBottomSheet> createState() =>
_DocumentUploadBottomSheetState();
}
class _DocumentUploadBottomSheetState extends State<DocumentUploadBottomSheet> {
final _formKey = GlobalKey<FormState>();
final controller = Get.put(DocumentUploadController());
final TextEditingController _docIdController = TextEditingController();
final TextEditingController _docNameController = TextEditingController();
final TextEditingController _descriptionController = TextEditingController();
File? selectedFile;
String? existingFileName;
@override
void initState() {
super.initState();
// Pre-fill if editing
if (widget.initialData != null) {
final data = widget.initialData!;
_docIdController.text = data["documentId"] ?? "";
_docNameController.text = data["name"] ?? "";
_descriptionController.text = data["description"] ?? "";
existingFileName = data["fileName"];
// Preselect category & type
WidgetsBinding.instance.addPostFrameCallback((_) async {
if (data["categoryName"] != null) {
final category = controller.categories.firstWhereOrNull(
(c) => c.name == data["categoryName"],
);
if (category != null) {
setState(() => controller.selectedCategory = category);
await controller.fetchDocumentTypes(category.id);
if (data["documentTypeName"] != null) {
final type = controller.documentTypes.firstWhereOrNull(
(t) => t.name == data["documentTypeName"],
);
if (type != null) {
setState(() => controller.selectedType = type);
}
}
}
}
});
// Prefill tags
if (data["tags"] != null) {
controller.enteredTags.value =
List<String>.from(data["tags"].map((t) => t["name"]));
}
// Prefill file info
controller.selectedFileName = data["fileName"];
controller.selectedFileContentType = data["contentType"];
controller.selectedFileSize = data["fileSize"];
}
}
@override
void dispose() {
_docIdController.dispose();
_docNameController.dispose();
_descriptionController.dispose();
super.dispose();
}
void _handleSubmit() {
if (!(_formKey.currentState?.validate() ?? false)) return;
if (selectedFile == null && existingFileName == null) {
showAppSnackbar(
title: "Error",
message: "Please attach a document",
type: SnackbarType.error,
);
return;
}
// Validate file size
final maxSizeMB = controller.selectedType?.maxSizeAllowedInMB;
if (maxSizeMB != null && controller.selectedFileSize != null) {
final fileSizeMB = controller.selectedFileSize! / (1024 * 1024);
if (fileSizeMB > maxSizeMB) {
showAppSnackbar(
title: "Error",
message: "File size exceeds $maxSizeMB MB limit",
type: SnackbarType.error,
);
return;
}
}
// Validate file type
final allowedType = controller.selectedType?.allowedContentType;
if (allowedType != null && controller.selectedFileContentType != null) {
if (!allowedType
.toLowerCase()
.contains(controller.selectedFileContentType!.toLowerCase())) {
showAppSnackbar(
title: "Error",
message: "Only $allowedType files are allowed for this type",
type: SnackbarType.error,
);
return;
}
}
final payload = {
"documentId": _docIdController.text.trim(),
"name": _docNameController.text.trim(),
"description": _descriptionController.text.trim(),
"documentTypeId": controller.selectedType?.id,
"attachment": {
"fileName": controller.selectedFileName,
"base64Data": controller.selectedFileBase64,
"contentType": controller.selectedFileContentType,
"fileSize": controller.selectedFileSize,
"isActive": true,
},
"tags": controller.enteredTags
.map((t) => {"name": t, "isActive": true})
.toList(),
};
widget.onSubmit(payload);
showAppSnackbar(
title: "Success",
message: "Document submitted successfully",
type: SnackbarType.success,
);
}
Future<void> _pickFile() async {
final result = await FilePicker.platform.pickFiles(
type: FileType.custom,
allowedExtensions: ['pdf', 'jpg', 'png', 'jpeg'],
);
if (result != null && result.files.single.path != null) {
final file = File(result.files.single.path!);
final fileName = result.files.single.name;
final fileBytes = await file.readAsBytes();
final base64Data = base64Encode(fileBytes);
setState(() {
selectedFile = file;
existingFileName = null;
controller.selectedFileName = fileName;
controller.selectedFileBase64 = base64Data;
controller.selectedFileContentType =
result.files.single.extension?.toLowerCase() == "pdf"
? "application/pdf"
: "image/${result.files.single.extension?.toLowerCase()}";
controller.selectedFileSize = (fileBytes.length / 1024).round();
});
}
}
@override
Widget build(BuildContext context) {
return BaseBottomSheet(
title: widget.initialData == null ? "Upload Document" : "Edit Document",
onCancel: () => Navigator.pop(context),
onSubmit: _handleSubmit,
child: Form(
key: _formKey,
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MySpacing.height(16),
/// Document ID
LabeledInput(
label: "Document ID",
hint: "Enter Document ID",
controller: _docIdController,
validator: (value) =>
value == null || value.trim().isEmpty ? "Required" : null,
isRequired: true,
),
MySpacing.height(16),
/// Document Name
LabeledInput(
label: "Document Name",
hint: "e.g., PAN Card",
controller: _docNameController,
validator: (value) =>
value == null || value.trim().isEmpty ? "Required" : null,
isRequired: true,
),
MySpacing.height(16),
/// Document Category
Obx(() {
if (controller.isLoading.value &&
controller.categories.isEmpty) {
return const Center(child: CircularProgressIndicator());
}
return LabeledDropdown(
label: "Document Category",
hint: "Select Category",
value: controller.selectedCategory?.name,
items: controller.categories.map((c) => c.name).toList(),
onChanged: (selected) async {
final category = controller.categories
.firstWhere((c) => c.name == selected);
setState(() => controller.selectedCategory = category);
await controller.fetchDocumentTypes(category.id);
},
isRequired: true,
);
}),
MySpacing.height(16),
/// Document Type
Obx(() {
if (controller.documentTypes.isEmpty) {
return const SizedBox.shrink();
}
return LabeledDropdown(
label: "Document Type",
hint: "Select Type",
value: controller.selectedType?.name,
items: controller.documentTypes.map((t) => t.name).toList(),
onChanged: (selected) {
final type = controller.documentTypes
.firstWhere((t) => t.name == selected);
setState(() => controller.selectedType = type);
},
isRequired: true,
);
}),
MySpacing.height(24),
/// Single Attachment Section
AttachmentSectionSingle(
attachment: selectedFile,
existingFileName: existingFileName,
onPick: _pickFile,
onRemove: () => setState(() {
selectedFile = null;
existingFileName = null;
controller.selectedFileName = null;
controller.selectedFileBase64 = null;
controller.selectedFileContentType = null;
controller.selectedFileSize = null;
}),
),
if (controller.selectedType?.maxSizeAllowedInMB != null)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
"Max file size: ${controller.selectedType!.maxSizeAllowedInMB} MB",
style: const TextStyle(color: Colors.grey, fontSize: 12),
),
),
MySpacing.height(16),
/// Tags
MyText.labelMedium("Tags"),
MySpacing.height(8),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
height: 56,
child: TextFormField(
controller: controller.tagCtrl,
onChanged: controller.filterSuggestions,
onFieldSubmitted: (v) {
controller.addEnteredTag(v);
controller.tagCtrl.clear();
controller.clearSuggestions();
},
decoration: InputDecoration(
hintText: "Start typing to add tags",
hintStyle: MyTextStyle.bodySmall(xMuted: true),
filled: true,
fillColor: Colors.grey.shade100,
border: _inputBorder(),
enabledBorder: _inputBorder(),
focusedBorder: _inputFocusedBorder(),
contentPadding: MySpacing.all(16),
),
),
),
Obx(() => controller.filteredSuggestions.isEmpty
? const SizedBox.shrink()
: Container(
margin: const EdgeInsets.only(top: 4),
decoration: BoxDecoration(
color: Colors.white,
border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(8),
boxShadow: const [
BoxShadow(color: Colors.black12, blurRadius: 4),
],
),
child: ListView.builder(
shrinkWrap: true,
itemCount: controller.filteredSuggestions.length,
itemBuilder: (_, i) {
final suggestion =
controller.filteredSuggestions[i];
return ListTile(
dense: true,
title: Text(suggestion),
onTap: () {
controller.addEnteredTag(suggestion);
controller.tagCtrl.clear();
controller.clearSuggestions();
},
);
},
),
)),
MySpacing.height(8),
Obx(() => Wrap(
spacing: 8,
children: controller.enteredTags
.map((tag) => Chip(
label: Text(tag),
onDeleted: () =>
controller.removeEnteredTag(tag),
))
.toList(),
)),
],
),
MySpacing.height(16),
/// Description
LabeledInput(
label: "Description",
hint: "Enter short description",
controller: _descriptionController,
validator: (value) =>
value == null || value.trim().isEmpty ? "Required" : null,
isRequired: true,
),
],
),
),
),
);
}
OutlineInputBorder _inputBorder() => OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.grey.shade300),
);
OutlineInputBorder _inputFocusedBorder() => const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
borderSide: BorderSide(color: Colors.blueAccent, width: 1.5),
);
}
/// ---------------- Single Attachment Widget ----------------
class AttachmentSectionSingle extends StatelessWidget {
final File? attachment;
final String? existingFileName; // new
final VoidCallback onPick;
final VoidCallback? onRemove;
const AttachmentSectionSingle({
Key? key,
this.attachment,
this.existingFileName,
required this.onPick,
this.onRemove,
}) : super(key: key);
@override
Widget build(BuildContext context) {
if (attachment == null && existingFileName != null) {
return Row(
children: [
const Icon(Icons.insert_drive_file, color: Colors.blueAccent),
const SizedBox(width: 8),
Expanded(
child: Text(existingFileName!,
style: const TextStyle(fontSize: 14, color: Colors.black87)),
),
if (onRemove != null)
IconButton(
icon: const Icon(Icons.close, color: Colors.red),
onPressed: onRemove,
),
],
);
}
final allowedImageExtensions = ['jpg', 'jpeg', 'png'];
Widget buildTile(File file) {
final isImage = allowedImageExtensions
.contains(file.path.split('.').last.toLowerCase());
final fileName = file.path.split('/').last;
IconData fileIcon = Icons.insert_drive_file;
Color iconColor = Colors.blueGrey;
if (!isImage) {
final ext = fileName.split('.').last.toLowerCase();
switch (ext) {
case 'pdf':
fileIcon = Icons.picture_as_pdf;
iconColor = Colors.redAccent;
break;
case 'doc':
case 'docx':
fileIcon = Icons.description;
iconColor = Colors.blueAccent;
break;
case 'xls':
case 'xlsx':
fileIcon = Icons.table_chart;
iconColor = Colors.green;
break;
case 'txt':
fileIcon = Icons.article;
iconColor = Colors.grey;
break;
}
}
return Stack(
clipBehavior: Clip.none,
children: [
GestureDetector(
onTap: () {
if (isImage) {
showDialog(
context: context,
builder: (_) => ImageViewerDialog(
imageSources: [file],
initialIndex: 0,
),
);
}
},
child: Container(
width: 100,
height: 100,
decoration: BoxDecoration(
border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(8),
color: Colors.grey.shade100,
),
child: isImage
? ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.file(file, fit: BoxFit.cover),
)
: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(fileIcon, color: iconColor, size: 30),
const SizedBox(height: 4),
Text(
fileName.split('.').last.toUpperCase(),
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: iconColor),
),
],
),
),
),
if (onRemove != null)
Positioned(
top: -6,
right: -6,
child: IconButton(
icon: const Icon(Icons.close, color: Colors.red, size: 18),
onPressed: onRemove,
),
),
],
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: const [
Text("Attachment", style: TextStyle(fontWeight: FontWeight.w600)),
Text(" *",
style:
TextStyle(color: Colors.red, fontWeight: FontWeight.bold))
],
),
const SizedBox(height: 8),
Row(
children: [
if (attachment != null)
buildTile(attachment!)
else
GestureDetector(
onTap: onPick,
child: Container(
width: 100,
height: 100,
decoration: BoxDecoration(
border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(8),
color: Colors.grey.shade100,
),
child: const Icon(Icons.add, size: 40, color: Colors.grey),
),
),
],
),
],
);
}
}
// ---- Reusable Widgets ----
class LabeledInput extends StatelessWidget {
final String label;
final String hint;
final TextEditingController controller;
final String? Function(String?) validator;
final bool isRequired;
const LabeledInput({
Key? key,
required this.label,
required this.hint,
required this.controller,
required this.validator,
this.isRequired = false,
}) : super(key: key);
@override
Widget build(BuildContext context) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
MyText.labelMedium(label),
if (isRequired)
const Text(
" *",
style:
TextStyle(color: Colors.red, fontWeight: FontWeight.bold),
),
],
),
MySpacing.height(8),
TextFormField(
controller: controller,
validator: validator,
decoration: _inputDecoration(context, hint),
),
],
);
InputDecoration _inputDecoration(BuildContext context, String hint) =>
InputDecoration(
hintText: hint,
hintStyle: MyTextStyle.bodySmall(xMuted: true),
filled: true,
fillColor: Colors.grey.shade100,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.grey.shade300),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.grey.shade300),
),
focusedBorder: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
borderSide: BorderSide(color: Colors.blueAccent, width: 1.5),
),
contentPadding: MySpacing.all(16),
);
}
class LabeledDropdown extends StatefulWidget {
final String label;
final String hint;
final String? value;
final List<String> items;
final ValueChanged<String> onChanged;
final bool isRequired;
const LabeledDropdown({
Key? key,
required this.label,
required this.hint,
required this.value,
required this.items,
required this.onChanged,
this.isRequired = false,
}) : super(key: key);
@override
State<LabeledDropdown> createState() => _LabeledDropdownState();
}
class _LabeledDropdownState extends State<LabeledDropdown> {
final GlobalKey _dropdownKey = GlobalKey();
@override
Widget build(BuildContext context) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
MyText.labelMedium(widget.label),
if (widget.isRequired)
const Text(
" *",
style:
TextStyle(color: Colors.red, fontWeight: FontWeight.bold),
),
],
),
MySpacing.height(8),
GestureDetector(
key: _dropdownKey,
onTap: () async {
final RenderBox renderBox =
_dropdownKey.currentContext!.findRenderObject() as RenderBox;
final Offset offset = renderBox.localToGlobal(Offset.zero);
final Size size = renderBox.size;
final RelativeRect position = RelativeRect.fromLTRB(
offset.dx,
offset.dy + size.height,
offset.dx + size.width,
offset.dy,
);
final selected = await showMenu<String>(
context: context,
position: position,
items: widget.items
.map((item) => PopupMenuItem<String>(
value: item,
child: Text(item),
))
.toList(),
);
if (selected != null) widget.onChanged(selected);
},
child: AbsorbPointer(
child: TextFormField(
readOnly: true,
controller: TextEditingController(text: widget.value ?? ""),
validator: (value) =>
widget.isRequired && (value == null || value.isEmpty)
? "Required"
: null,
decoration: _inputDecoration(context, widget.hint).copyWith(
suffixIcon: const Icon(Icons.expand_more),
),
),
),
),
],
);
InputDecoration _inputDecoration(BuildContext context, String hint) =>
InputDecoration(
hintText: hint,
hintStyle: MyTextStyle.bodySmall(xMuted: true),
filled: true,
fillColor: Colors.grey.shade100,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.grey.shade300),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.grey.shade300),
),
focusedBorder: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
borderSide: BorderSide(color: Colors.blueAccent, width: 1.5),
),
contentPadding: MySpacing.all(16),
);
}
class FilePickerTile extends StatelessWidget {
final String? pickedFile;
final VoidCallback onTap;
final bool isRequired;
const FilePickerTile({
Key? key,
required this.pickedFile,
required this.onTap,
this.isRequired = false,
}) : super(key: key);
@override
Widget build(BuildContext context) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
MyText.labelMedium("Attachments"),
if (isRequired)
const Text(
" *",
style:
TextStyle(color: Colors.red, fontWeight: FontWeight.bold),
),
],
),
MySpacing.height(8),
GestureDetector(
onTap: onTap,
child: Container(
padding: MySpacing.all(16),
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.grey.shade300),
),
child: Row(
children: [
const Icon(Icons.upload_file, color: Colors.blueAccent),
const SizedBox(width: 12),
Text(pickedFile ?? "Choose File"),
],
),
),
),
],
);
}

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,273 @@
class DocumentsResponse {
final bool success;
final String message;
final DocumentDataWrapper data;
final dynamic errors;
final int statusCode;
final DateTime timestamp;
DocumentsResponse({
required this.success,
required this.message,
required this.data,
this.errors,
required this.statusCode,
required this.timestamp,
});
factory DocumentsResponse.fromJson(Map<String, dynamic> json) {
return DocumentsResponse(
success: json['success'] ?? false,
message: json['message'] ?? '',
data: DocumentDataWrapper.fromJson(json['data']),
errors: json['errors'],
statusCode: json['statusCode'] ?? 0,
timestamp: json['timestamp'] != null
? DateTime.parse(json['timestamp'])
: DateTime.now(),
);
}
Map<String, dynamic> toJson() {
return {
'success': success,
'message': message,
'data': data.toJson(),
'errors': errors,
'statusCode': statusCode,
'timestamp': timestamp.toIso8601String(),
};
}
}
class DocumentDataWrapper {
final dynamic currentFilter;
final int currentPage;
final int totalPages;
final int totalEntites;
final List<DocumentItem> data;
DocumentDataWrapper({
this.currentFilter,
required this.currentPage,
required this.totalPages,
required this.totalEntites,
required this.data,
});
factory DocumentDataWrapper.fromJson(Map<String, dynamic> json) {
return DocumentDataWrapper(
currentFilter: json['currentFilter'],
currentPage: json['currentPage'] ?? 0,
totalPages: json['totalPages'] ?? 0,
totalEntites: json['totalEntites'] ?? 0,
data: (json['data'] as List<dynamic>? ?? [])
.map((e) => DocumentItem.fromJson(e))
.toList(),
);
}
Map<String, dynamic> toJson() {
return {
'currentFilter': currentFilter,
'currentPage': currentPage,
'totalPages': totalPages,
'totalEntites': totalEntites,
'data': data.map((e) => e.toJson()).toList(),
};
}
}
class DocumentItem {
final String id;
final String name;
final String documentId;
final String description;
final DateTime uploadedAt;
final String? parentAttachmentId;
final bool isCurrentVersion;
final int version;
final bool isActive;
final bool? isVerified;
final UploadedBy uploadedBy;
final DocumentType documentType;
DocumentItem({
required this.id,
required this.name,
required this.documentId,
required this.description,
required this.uploadedAt,
this.parentAttachmentId,
required this.isCurrentVersion,
required this.version,
required this.isActive,
this.isVerified,
required this.uploadedBy,
required this.documentType,
});
factory DocumentItem.fromJson(Map<String, dynamic> json) {
return DocumentItem(
id: json['id'] ?? '',
name: json['name'] ?? '',
documentId: json['documentId'] ?? '',
description: json['description'] ?? '',
uploadedAt: DateTime.parse(json['uploadedAt']),
parentAttachmentId: json['parentAttachmentId'],
isCurrentVersion: json['isCurrentVersion'] ?? false,
version: json['version'] ?? 0,
isActive: json['isActive'] ?? false,
isVerified: json['isVerified'],
uploadedBy: UploadedBy.fromJson(json['uploadedBy']),
documentType: DocumentType.fromJson(json['documentType']),
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
'documentId': documentId,
'description': description,
'uploadedAt': uploadedAt.toIso8601String(),
'parentAttachmentId': parentAttachmentId,
'isCurrentVersion': isCurrentVersion,
'version': version,
'isActive': isActive,
'isVerified': isVerified,
'uploadedBy': uploadedBy.toJson(),
'documentType': documentType.toJson(),
};
}
}
class UploadedBy {
final String id;
final String firstName;
final String? lastName;
final String? photo;
final String jobRoleId;
final String jobRoleName;
UploadedBy({
required this.id,
required this.firstName,
this.lastName,
this.photo,
required this.jobRoleId,
required this.jobRoleName,
});
factory UploadedBy.fromJson(Map<String, dynamic> json) {
return UploadedBy(
id: json['id'] ?? '',
firstName: json['firstName'] ?? '',
lastName: json['lastName'],
photo: json['photo'],
jobRoleId: json['jobRoleId'] ?? '',
jobRoleName: json['jobRoleName'] ?? '',
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'firstName': firstName,
'lastName': lastName,
'photo': photo,
'jobRoleId': jobRoleId,
'jobRoleName': jobRoleName,
};
}
}
class DocumentType {
final String id;
final String name;
final String? regexExpression;
final String? allowedContentType;
final int? maxSizeAllowedInMB;
final bool isValidationRequired;
final bool isMandatory;
final bool isSystem;
final bool isActive;
final DocumentCategory? documentCategory;
DocumentType({
required this.id,
required this.name,
this.regexExpression,
this.allowedContentType,
this.maxSizeAllowedInMB,
required this.isValidationRequired,
required this.isMandatory,
required this.isSystem,
required this.isActive,
this.documentCategory,
});
factory DocumentType.fromJson(Map<String, dynamic> json) {
return DocumentType(
id: json['id'] ?? '',
name: json['name'] ?? '',
regexExpression: json['regexExpression'], // nullable
allowedContentType: json['allowedContentType'],
maxSizeAllowedInMB: json['maxSizeAllowedInMB'],
isValidationRequired: json['isValidationRequired'] ?? false,
isMandatory: json['isMandatory'] ?? false,
isSystem: json['isSystem'] ?? false,
isActive: json['isActive'] ?? false,
documentCategory: json['documentCategory'] != null
? DocumentCategory.fromJson(json['documentCategory'])
: null,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
'regexExpression': regexExpression,
'allowedContentType': allowedContentType,
'maxSizeAllowedInMB': maxSizeAllowedInMB,
'isValidationRequired': isValidationRequired,
'isMandatory': isMandatory,
'isSystem': isSystem,
'isActive': isActive,
'documentCategory': documentCategory?.toJson(),
};
}
}
class DocumentCategory {
final String id;
final String name;
final String? description;
final String? entityTypeId;
DocumentCategory({
required this.id,
required this.name,
this.description,
this.entityTypeId,
});
factory DocumentCategory.fromJson(Map<String, dynamic> json) {
return DocumentCategory(
id: json['id'] ?? '',
name: json['name'] ?? '',
description: json['description'],
entityTypeId: json['entityTypeId'],
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
'description': description,
'entityTypeId': entityTypeId,
};
}
}

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

@ -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();
@ -79,7 +80,7 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
*/
_buildDashboardStats(context),
MySpacing.height(24),
SizedBox(
SizedBox(
width: double.infinity,
child: DashboardOverviewWidgets.teamsOverview(),
),
@ -267,6 +268,8 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
DashboardScreen.directoryMainPageRoute),
_StatItem(LucideIcons.badge_dollar_sign, "Expense", contentTheme.info,
DashboardScreen.expenseMainPageRoute),
_StatItem(LucideIcons.file_text, "Documents", contentTheme.info,
DashboardScreen.documentMainPageRoute),
];
final projectController = Get.find<ProjectController>();
@ -277,10 +280,16 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
children: [
if (!isProjectSelected) _buildNoProjectMessage(),
Wrap(
spacing: 6, // horizontal spacing
runSpacing: 6, // vertical spacing
spacing: 6,
runSpacing: 6,
children: stats
.where((stat) => menuController.isMenuAllowed(stat.title))
.where((stat) {
// Always allow Documents
if (stat.title == "Documents") return true;
// For all other menus, respect sidebar permissions
return menuController.isMenuAllowed(stat.title);
})
.map((stat) => _buildStatCard(stat, isProjectSelected))
.toList(),
),
@ -290,49 +299,70 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
}
/// Stat Card (Compact with wrapping text)
Widget _buildStatCard(_StatItem statItem, bool isEnabled) {
const double cardWidth = 80;
const double cardHeight = 70;
/// Stat Card (Compact with wrapping text)
Widget _buildStatCard(_StatItem statItem, bool isProjectSelected) {
const double cardWidth = 80;
const double cardHeight = 70;
return Opacity(
opacity: isEnabled ? 1.0 : 0.4,
child: IgnorePointer(
ignoring: !isEnabled,
child: InkWell(
onTap: () => _handleStatCardTap(statItem, isEnabled),
borderRadius: BorderRadius.circular(8),
child: MyCard.bordered(
width: cardWidth,
height: cardHeight,
paddingAll: 4,
borderRadiusAll: 8,
border: Border.all(color: Colors.grey.withOpacity(0.15)),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_buildStatCardIconCompact(statItem),
MySpacing.height(4),
Expanded(
child: Center(
child: Text(
statItem.title,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 10,
overflow: TextOverflow.visible,
),
maxLines: 2,
softWrap: true,
// Attendance should always be enabled
final bool isEnabled = statItem.title == "Attendance" || isProjectSelected;
return Opacity(
opacity: isEnabled ? 1.0 : 0.4,
child: IgnorePointer(
ignoring: !isEnabled,
child: InkWell(
onTap: () => _handleStatCardTap(statItem, isEnabled),
borderRadius: BorderRadius.circular(8),
child: MyCard.bordered(
width: cardWidth,
height: cardHeight,
paddingAll: 4,
borderRadiusAll: 8,
border: Border.all(color: Colors.grey.withOpacity(0.15)),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_buildStatCardIconCompact(statItem),
MySpacing.height(4),
Expanded(
child: Center(
child: Text(
statItem.title,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 10,
overflow: TextOverflow.visible,
),
maxLines: 2,
softWrap: true,
),
),
],
),
),
],
),
),
),
),
);
}
/// Handle Tap
void _handleStatCardTap(_StatItem statItem, bool isEnabled) {
if (!isEnabled) {
Get.defaultDialog(
title: "No Project Selected",
middleText: "You need to select a project before accessing this section.",
confirm: ElevatedButton(
onPressed: () => Get.back(),
child: const Text("OK"),
),
);
} else {
Get.toNamed(statItem.route);
}
}
/// Compact Icon
Widget _buildStatCardIconCompact(_StatItem statItem) {
@ -348,21 +378,7 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
}
/// Handle Tap
void _handleStatCardTap(_StatItem statItem, bool isEnabled) {
if (!isEnabled) {
Get.defaultDialog(
title: "No Project Selected",
middleText:
"You need to select a project before accessing this section.",
confirm: ElevatedButton(
onPressed: () => Get.back(),
child: const Text("OK"),
),
);
} else {
Get.toNamed(statItem.route);
}
}
}
class _StatItem {

View File

@ -0,0 +1,287 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:intl/intl.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/helpers/widgets/my_spacing.dart';
import 'package:marco/helpers/widgets/my_custom_skeleton.dart';
import 'package:marco/helpers/widgets/my_refresh_indicator.dart';
import 'package:marco/model/document/document_details_model.dart';
import 'package:marco/controller/document/document_details_controller.dart';
import 'package:marco/helpers/widgets/custom_app_bar.dart';
import 'package:marco/helpers/widgets/my_snackbar.dart';
class DocumentDetailsPage extends StatefulWidget {
final String documentId;
const DocumentDetailsPage({super.key, required this.documentId});
@override
State<DocumentDetailsPage> createState() => _DocumentDetailsPageState();
}
class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
final DocumentDetailsController controller =
Get.put(DocumentDetailsController());
@override
void initState() {
super.initState();
_fetchDetails();
}
Future<void> _fetchDetails() async {
await controller.fetchDocumentDetails(widget.documentId);
final parentId = controller.documentDetails.value?.data?.parentAttachmentId;
if (parentId != null && parentId.isNotEmpty) {
await controller.fetchDocumentVersions(parentId);
}
}
Future<void> _onRefresh() async {
await _fetchDetails();
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFFF1F1F1),
appBar: CustomAppBar(
title: 'Document Details',
onBackPressed: () {
Get.back();
},
),
body: Obx(() {
if (controller.isLoading.value) {
return SkeletonLoaders.documentDetailsSkeletonLoader();
}
final docResponse = controller.documentDetails.value;
if (docResponse == null || docResponse.data == null) {
return Center(
child: MyText.bodyMedium(
"Failed to load document details.",
color: Colors.grey,
),
);
}
final doc = docResponse.data!;
return MyRefreshIndicator(
onRefresh: _onRefresh,
child: SingleChildScrollView(
physics:
const AlwaysScrollableScrollPhysics(), // ensures pull works
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildDetailsCard(doc),
const SizedBox(height: 20),
MyText.titleMedium("Versions",
fontWeight: 700, color: Colors.black),
const SizedBox(height: 10),
_buildVersionsSection(),
],
),
),
);
}),
);
}
/// ---------------- DOCUMENT DETAILS CARD ----------------
Widget _buildDetailsCard(DocumentDetails doc) {
final uploadDate =
DateFormat("dd MMM yyyy, hh:mm a").format(doc.uploadedAt.toLocal());
final updateDate = doc.updatedAt != null
? DateFormat("dd MMM yyyy, hh:mm a").format(doc.updatedAt!.toLocal())
: "-";
return Container(
constraints: const BoxConstraints(maxWidth: 460),
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(10),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.06),
blurRadius: 16,
offset: const Offset(0, 4),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header
Row(
children: [
CircleAvatar(
backgroundColor: Colors.blue.shade50,
radius: 28,
child:
const Icon(Icons.description, color: Colors.blue, size: 28),
),
MySpacing.width(16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.titleLarge(doc.name,
fontWeight: 700, color: Colors.black),
MyText.bodySmall(
doc.documentType.name,
color: Colors.blueGrey,
fontWeight: 600,
),
],
),
),
],
),
MySpacing.height(12),
// Tags
if (doc.tags.isNotEmpty)
Wrap(
children: doc.tags.map((t) => _buildTagChip(t.name)).toList(),
),
MySpacing.height(16),
// Info rows
_buildDetailRow("Document ID", doc.documentId),
_buildDetailRow("Description", doc.description ?? "-"),
_buildDetailRow(
"Category", doc.documentType.documentCategory?.name ?? "-"),
_buildDetailRow("Version", "v${doc.version}"),
_buildDetailRow(
"Current Version", doc.isCurrentVersion ? "Yes" : "No"),
_buildDetailRow("Verified",
doc.isVerified == null ? '-' : (doc.isVerified! ? "Yes" : "No")),
_buildDetailRow("Uploaded By",
"${doc.uploadedBy.firstName} ${doc.uploadedBy.lastName}"),
_buildDetailRow("Uploaded On", uploadDate),
if (doc.updatedAt != null)
_buildDetailRow("Last Updated On", updateDate),
],
),
);
}
/// ---------------- VERSIONS SECTION ----------------
Widget _buildVersionsSection() {
return Obx(() {
if (controller.isVersionsLoading.value) {
return const Center(child: CircularProgressIndicator());
}
if (controller.versions.isEmpty) {
return MyText.bodySmall("No versions found", color: Colors.grey);
}
final sorted = [...controller.versions];
sorted.sort((a, b) => b.uploadedAt.compareTo(a.uploadedAt));
return ListView.separated(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: sorted.length,
separatorBuilder: (_, __) => Divider(height: 1),
itemBuilder: (context, index) {
final version = sorted[index];
final uploadDate =
DateFormat("dd MMM yyyy, hh:mm a").format(version.uploadedAt);
return ListTile(
leading: const Icon(Icons.description, color: Colors.blue),
title: MyText.bodyMedium(
"${version.name} (v${version.version})",
fontWeight: 600,
color: Colors.black,
),
subtitle: MyText.bodySmall(
"Uploaded by ${version.uploadedBy.firstName} ${version.uploadedBy.lastName}$uploadDate",
color: Colors.grey.shade600,
),
trailing: IconButton(
icon: const Icon(Icons.open_in_new, color: Colors.blue),
onPressed: () async {
final url = await controller.fetchPresignedUrl(version.id);
if (url != null) {
_openDocument(url);
} else {
showAppSnackbar(
title: "Error",
message: "Failed to fetch document link",
type: SnackbarType.error,
);
}
},
),
);
},
);
});
}
/// ---------------- HELPERS ----------------
Widget _buildTagChip(String label) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
margin: const EdgeInsets.only(right: 6, bottom: 6),
decoration: BoxDecoration(
color: Colors.blue.shade100,
borderRadius: BorderRadius.circular(12),
),
child: MyText.bodySmall(
label,
color: Colors.blue.shade900,
fontWeight: 600,
),
);
}
Widget _buildDetailRow(String title, String value) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
children: [
SizedBox(
width: 120,
child: MyText.bodySmall(
"$title:",
fontWeight: 600,
color: Colors.grey.shade800,
overflow: TextOverflow.ellipsis,
),
),
Expanded(
child: MyText.bodySmall(
value,
color: Colors.grey.shade600,
overflow: TextOverflow.ellipsis,
),
),
],
),
);
}
Future<void> _openDocument(String url) async {
final uri = Uri.parse(url);
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
} else {
showAppSnackbar(
title: "Error",
message: "Could not open document",
type: SnackbarType.error,
);
}
}
}

View File

@ -0,0 +1,317 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:marco/controller/document/user_document_controller.dart';
import 'package:marco/controller/project_controller.dart';
import 'package:marco/helpers/widgets/my_spacing.dart';
import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/helpers/widgets/my_refresh_indicator.dart';
import 'package:marco/helpers/utils/permission_constants.dart';
import 'package:marco/model/document/user_document_filter_bottom_sheet.dart';
import 'package:marco/model/document/documents_list_model.dart';
import 'package:intl/intl.dart';
import 'package:marco/helpers/widgets/my_custom_skeleton.dart';
import 'package:marco/model/document/document_upload_bottom_sheet.dart';
import 'package:marco/controller/document/document_upload_controller.dart';
import 'package:marco/view/document/document_details_page.dart';
import 'package:marco/helpers/widgets/custom_app_bar.dart';
class UserDocumentsPage extends StatefulWidget {
final String? entityId;
final bool isEmployee;
const UserDocumentsPage({
super.key,
this.entityId,
this.isEmployee = false,
});
@override
State<UserDocumentsPage> createState() => _UserDocumentsPageState();
}
class _UserDocumentsPageState extends State<UserDocumentsPage> {
final DocumentController docController = Get.put(DocumentController());
String get entityTypeId => widget.isEmployee
? Permissions.employeeEntity
: Permissions.projectEntity;
String get resolvedEntityId => widget.isEmployee
? widget.entityId ?? ""
: Get.find<ProjectController>().selectedProject?.id ?? "";
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
docController.fetchFilters(entityTypeId);
docController.fetchDocuments(
entityTypeId: entityTypeId,
entityId: resolvedEntityId,
reset: true,
);
});
}
@override
void dispose() {
docController.documents.clear();
super.dispose();
}
Widget _buildDocumentTile(DocumentItem doc) {
final uploadDate =
DateFormat("dd MMM yyyy").format(doc.uploadedAt.toLocal());
final uploader = doc.uploadedBy.firstName.isNotEmpty
? "Added by ${doc.uploadedBy.firstName} ${doc.uploadedBy.lastName}"
.trim()
: "Added by you";
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
child: MyText.bodySmall(
uploadDate,
fontSize: 13,
fontWeight: 500,
color: Colors.grey,
),
),
InkWell(
onTap: () {
// 👉 Navigate to details page
Get.to(() => DocumentDetailsPage(documentId: doc.id));
},
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: Colors.blue.shade50,
borderRadius: BorderRadius.circular(8),
),
child: const Icon(Icons.description, color: Colors.blue),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.bodySmall(
doc.documentType.name,
fontSize: 13,
fontWeight: 600,
color: Colors.grey,
),
MySpacing.height(2),
MyText.bodyMedium(
doc.name,
fontSize: 15,
fontWeight: 600,
color: Colors.black,
),
MySpacing.height(2),
MyText.bodySmall(
uploader,
fontSize: 13,
color: Colors.grey,
),
],
),
),
IconButton(
icon: const Icon(Icons.arrow_forward_ios, color: Colors.black54),
onPressed: () {/* future actions */},
),
],
),
),
),
],
);
}
Widget _buildEmptyState() {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.inbox_outlined, size: 60, color: Colors.grey),
MySpacing.height(18),
MyText.titleMedium(
'No documents found.',
fontWeight: 600,
color: Colors.grey,
),
MySpacing.height(10),
MyText.bodySmall(
'Try adjusting your filters or refresh to reload.',
color: Colors.grey,
),
],
),
);
}
Widget _buildFilterRow(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
alignment: Alignment.centerRight,
child: IconButton(
icon: const Icon(Icons.tune, color: Colors.black),
onPressed: () {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (_) => UserDocumentFilterBottomSheet(
entityId: resolvedEntityId,
entityTypeId: entityTypeId,
),
);
},
),
);
}
Widget _buildBody(BuildContext context) {
return Obx(() {
if (docController.isLoading.value && docController.documents.isEmpty) {
return SingleChildScrollView(
physics: const NeverScrollableScrollPhysics(),
child: SkeletonLoaders.documentSkeletonLoader(),
);
}
final docs = docController.documents;
return SafeArea(
child: Column(
children: [
_buildFilterRow(context),
Expanded(
child: MyRefreshIndicator(
onRefresh: () async {
await docController.fetchDocuments(
entityTypeId: entityTypeId,
entityId: resolvedEntityId,
filter: docController.selectedFilter.value,
reset: true,
);
},
child: ListView(
physics: const AlwaysScrollableScrollPhysics(),
padding: docs.isEmpty
? null
: const EdgeInsets.fromLTRB(0, 0, 0, 80),
children: docs.isEmpty
? [
SizedBox(
height: MediaQuery.of(context).size.height * 0.6,
child: _buildEmptyState(),
),
]
: [
...docs.map(_buildDocumentTile),
if (docController.isLoading.value)
const Padding(
padding: EdgeInsets.all(12),
child: Center(child: CircularProgressIndicator()),
),
if (!docController.hasMore.value)
Padding(
padding: const EdgeInsets.all(12),
child: Center(
child: MyText.bodySmall(
"No more documents",
color: Colors.grey,
),
),
),
],
),
),
),
],
),
);
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFFF1F1F1),
appBar: CustomAppBar(
title: 'Documents',
onBackPressed: () {
Get.back();
},
),
body: _buildBody(context),
floatingActionButton: FloatingActionButton.extended(
onPressed: () {
final uploadController = Get.put(DocumentUploadController());
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (_) => DocumentUploadBottomSheet(
onSubmit: (data) async {
final success = await uploadController.uploadDocument(
name: data["name"],
description: data["description"],
documentId: data["documentId"],
entityId: resolvedEntityId,
documentTypeId: data["documentTypeId"],
fileName: data["attachment"]["fileName"],
base64Data: data["attachment"]["base64Data"],
contentType: data["attachment"]["contentType"],
fileSize: data["attachment"]["fileSize"],
);
if (success) {
// Only close on success
Navigator.pop(context);
// Refresh list
docController.fetchDocuments(
entityTypeId: entityTypeId,
entityId: resolvedEntityId,
reset: true,
);
} else {
// Dont close, show error
Get.snackbar("Error", "Upload failed, please try again");
}
},
),
);
},
icon: const Icon(Icons.add, color: Colors.white),
label: MyText.bodyMedium("Add Document",
color: Colors.white, fontWeight: 600),
backgroundColor: Colors.red,
),
floatingActionButtonLocation: FloatingActionButtonLocation.endFloat,
);
}
}

View File

@ -10,6 +10,8 @@ import 'package:marco/helpers/widgets/avatar.dart';
import 'package:marco/model/employees/employee_info.dart';
import 'package:marco/controller/auth/mpin_controller.dart';
import 'package:marco/view/employees/employee_detail_screen.dart';
import 'package:marco/view/document/user_document_screen.dart';
class UserProfileBar extends StatefulWidget {
final bool isCondensed;
@ -177,6 +179,12 @@ class _UserProfileBarState extends State<UserProfileBar>
onTap: _onProfileTap,
),
SizedBox(height: spacingHeight),
_menuItemRow(
icon: LucideIcons.file_text,
label: 'My Documents',
onTap: _onDocumentsTap,
),
SizedBox(height: spacingHeight),
_menuItemRow(
icon: LucideIcons.settings,
label: 'Settings',
@ -244,6 +252,13 @@ class _UserProfileBarState extends State<UserProfileBar>
));
}
void _onDocumentsTap() {
Get.to(() => UserDocumentsPage(
entityId: "${employeeInfo.id}",
isEmployee: true,
));
}
void _onMpinTap() {
final controller = Get.put(MPINController());
if (hasMpin) controller.setChangeMpinMode();