marco.pms.mobileapp/lib/view/document/user_document_screen.dart

656 lines
24 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 'package:marco/controller/document/document_details_controller.dart';
import 'dart:convert';
import 'package:marco/helpers/utils/mixins/ui_mixin.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> with UIMixin {
final DocumentController docController = Get.put(DocumentController());
final PermissionController permissionController =
Get.find<PermissionController>();
final DocumentDetailsController controller =
Get.put(DocumentDetailsController());
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, bool showDateHeader) {
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: [
if (showDateHeader)
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(5),
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(5),
),
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 == "restore") {
// existing activate flow (unchanged)
final success = await docController.toggleDocumentActive(
doc.id,
isActive: true,
entityTypeId: entityTypeId,
entityId: resolvedEntityId,
);
if (success) {
showAppSnackbar(
title: "Restored",
message: "Document reastored successfully",
type: SnackbarType.success,
);
} else {
showAppSnackbar(
title: "Error",
message: "Failed to restore 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: "restore",
child: Text("Restore"),
),
],
),
],
),
),
),
],
);
}
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,
);
},
);
},
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(5),
borderSide:
BorderSide(color: contentTheme.primary, width: 1.5),
),
hintText: 'Search documents...',
filled: true,
fillColor: Colors.white,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(5),
borderSide: BorderSide(color: Colors.grey.shade300),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(5),
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(5),
),
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(5)),
),
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(5),
),
child: PopupMenuButton<int>(
padding: EdgeInsets.zero,
icon:
const Icon(Icons.more_vert, size: 20, color: Colors.black87),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(5),
),
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 Deleted Documents')),
Switch.adaptive(
value: docController.showInactive.value,
activeColor: contentTheme.primary,
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;
if (!isInactive) return const SizedBox.shrink(); // hide when active
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
color: Colors.red.shade50,
child: Row(
children: [
Icon(
Icons.visibility_off,
color: Colors.red,
size: 18,
),
const SizedBox(width: 8),
Text(
"Showing Deleted Documents",
style: TextStyle(
color: Colors.red,
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.asMap().entries.map((entry) {
final index = entry.key;
final doc = entry.value;
final currentDate = DateFormat("dd MMM yyyy")
.format(doc.uploadedAt.toLocal());
final prevDate = index > 0
? DateFormat("dd MMM yyyy").format(
docs[index - 1].uploadedAt.toLocal())
: null;
final showDateHeader = currentDate != prevDate;
return _buildDocumentTile(doc, showDateHeader);
}),
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 {
showAppSnackbar(
title: "Error",
message: "Upload failed, please try again",
type: SnackbarType.error,
);
}
},
),
);
},
icon: const Icon(Icons.add, color: Colors.white),
label: MyText.bodyMedium(
"Add Document",
color: Colors.white,
fontWeight: 600,
),
backgroundColor: contentTheme.primary,
)
: null,
floatingActionButtonLocation: FloatingActionButtonLocation.endFloat,
);
}
}