632 lines
23 KiB
Dart
632 lines
23 KiB
Dart
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';
|
|
import 'dart:convert';
|
|
|
|
|
|
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 {
|
|
final combinedFilter = {
|
|
'uploadedByIds': docController.selectedUploadedBy.toList(),
|
|
'documentCategoryIds':
|
|
docController.selectedCategory.toList(),
|
|
'documentTypeIds': docController.selectedType.toList(),
|
|
'documentTagIds': docController.selectedTag.toList(),
|
|
};
|
|
|
|
await docController.fetchDocuments(
|
|
entityTypeId: entityTypeId,
|
|
entityId: resolvedEntityId,
|
|
filter: jsonEncode(combinedFilter),
|
|
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,
|
|
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,
|
|
);
|
|
}
|
|
}
|