marco.pms.mobileapp/lib/view/document/document_details_page.dart
2025-10-08 11:06:39 +05:30

430 lines
15 KiB
Dart

import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:intl/intl.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/helpers/widgets/my_spacing.dart';
import 'package:marco/helpers/widgets/my_custom_skeleton.dart';
import 'package:marco/helpers/widgets/my_refresh_indicator.dart';
import 'package:marco/model/document/document_details_model.dart';
import 'package:marco/controller/document/document_details_controller.dart';
import 'package:marco/helpers/widgets/custom_app_bar.dart';
import 'package:marco/helpers/widgets/my_snackbar.dart';
import 'package:marco/model/document/document_edit_bottom_sheet.dart';
import 'package:marco/controller/permission_controller.dart';
import 'package:marco/helpers/utils/permission_constants.dart';
class DocumentDetailsPage extends StatefulWidget {
final String documentId;
const DocumentDetailsPage({super.key, required this.documentId});
@override
State<DocumentDetailsPage> createState() => _DocumentDetailsPageState();
}
class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
final DocumentDetailsController controller =
Get.find<DocumentDetailsController>();
final permissionController = Get.put(PermissionController());
@override
void initState() {
super.initState();
_fetchDetails();
}
Future<void> _fetchDetails() async {
await controller.fetchDocumentDetails(widget.documentId);
final parentId = controller.documentDetails.value?.data?.parentAttachmentId;
if (parentId != null && parentId.isNotEmpty) {
await controller.fetchDocumentVersions(parentId);
}
}
Future<void> _onRefresh() async {
await _fetchDetails();
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFFF1F1F1),
appBar: CustomAppBar(
title: 'Document Details',
onBackPressed: () {
Get.back();
},
),
body: Obx(() {
if (controller.isLoading.value) {
return SkeletonLoaders.documentDetailsSkeletonLoader();
}
final docResponse = controller.documentDetails.value;
if (docResponse == null || docResponse.data == null) {
return Center(
child: MyText.bodyMedium(
"Failed to load document details.",
color: Colors.grey,
),
);
}
final doc = docResponse.data!;
return MyRefreshIndicator(
onRefresh: _onRefresh,
child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildDetailsCard(doc),
const SizedBox(height: 20),
MyText.titleMedium("Versions",
fontWeight: 700, color: Colors.black),
const SizedBox(height: 10),
_buildVersionsSection(),
],
),
),
);
}),
);
}
/// ---------------- DOCUMENT DETAILS CARD ----------------
Widget _buildDetailsCard(DocumentDetails doc) {
final uploadDate =
DateFormat("dd MMM yyyy, hh:mm a").format(doc.uploadedAt.toLocal());
final updateDate = doc.updatedAt != null
? DateFormat("dd MMM yyyy, hh:mm a").format(doc.updatedAt!.toLocal())
: "-";
return Container(
constraints: const BoxConstraints(maxWidth: 460),
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(5),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.06),
blurRadius: 16,
offset: const Offset(0, 4),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header with Edit button
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Row(
children: [
CircleAvatar(
backgroundColor: Colors.blue.shade50,
radius: 28,
child: const Icon(Icons.description,
color: Colors.blue, size: 28),
),
MySpacing.width(16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.titleLarge(doc.name,
fontWeight: 700, color: Colors.black),
MyText.bodySmall(
doc.documentType.name,
color: Colors.blueGrey,
fontWeight: 600,
),
],
),
),
],
),
),
if (permissionController
.hasPermission(Permissions.modifyDocument))
IconButton(
icon: const Icon(Icons.edit, color: Colors.red),
onPressed: () async {
// existing bottom sheet flow
await controller
.fetchDocumentVersions(doc.parentAttachmentId);
final latestVersion = controller.versions.isNotEmpty
? controller.versions.reduce((a, b) =>
a.uploadedAt.isAfter(b.uploadedAt) ? a : b)
: null;
final documentData = {
"id": doc.id,
"documentId": doc.documentId,
"name": doc.name,
"description": doc.description,
"tags": doc.tags
.map((t) => {"name": t.name, "isActive": t.isActive})
.toList(),
"category": doc.documentType.documentCategory?.toJson(),
"type": doc.documentType.toJson(),
"attachment": latestVersion != null
? {
"id": latestVersion.id,
"fileName": latestVersion.name,
"contentType": latestVersion.contentType,
"fileSize": latestVersion.fileSize,
}
: null,
};
showModalBottomSheet(
context: context,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius:
BorderRadius.vertical(top: Radius.circular(5)),
),
builder: (_) {
return DocumentEditBottomSheet(
documentData: documentData,
onSubmit: (updatedData) async {
await _fetchDetails();
},
);
},
);
},
)
],
),
MySpacing.height(12),
// Tags
if (doc.tags.isNotEmpty)
Wrap(
children: doc.tags.map((t) => _buildTagChip(t.name)).toList(),
),
MySpacing.height(16),
// Info rows
_buildDetailRow("Document ID", doc.documentId),
_buildDetailRow("Description", doc.description ?? "-"),
_buildDetailRow(
"Category", doc.documentType.documentCategory?.name ?? "-"),
_buildDetailRow("Version", "v${doc.version}"),
_buildDetailRow(
"Current Version", doc.isCurrentVersion ? "Yes" : "No"),
_buildDetailRow("Verified",
doc.isVerified == null ? '-' : (doc.isVerified! ? "Yes" : "No")),
_buildDetailRow("Uploaded By",
"${doc.uploadedBy.firstName} ${doc.uploadedBy.lastName}"),
_buildDetailRow("Uploaded On", uploadDate),
if (doc.updatedAt != null)
_buildDetailRow("Last Updated On", updateDate),
MySpacing.height(12),
// Show buttons only if user has permission AND document is not verified yet
if (permissionController.hasPermission(Permissions.verifyDocument) &&
doc.isVerified == null) ...[
Row(
children: [
Expanded(
child: ElevatedButton.icon(
icon: const Icon(Icons.check, color: Colors.white),
label: MyText.bodyMedium(
"Verify",
color: Colors.white,
fontWeight: 600,
),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.green,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(5),
),
padding: const EdgeInsets.symmetric(vertical: 8),
),
onPressed: () async {
final success = await controller.verifyDocument(doc.id);
if (success) {
showAppSnackbar(
title: "Success",
message: "Document verified successfully",
type: SnackbarType.success,
);
} else {
showAppSnackbar(
title: "Error",
message: "Failed to verify document",
type: SnackbarType.error,
);
}
},
),
),
const SizedBox(width: 12),
Expanded(
child: ElevatedButton.icon(
icon: const Icon(Icons.close, color: Colors.white),
label: MyText.bodyMedium(
"Reject",
color: Colors.white,
fontWeight: 600,
),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(5),
),
padding: const EdgeInsets.symmetric(vertical: 8),
),
onPressed: () async {
final success = await controller.rejectDocument(doc.id);
if (success) {
showAppSnackbar(
title: "Rejected",
message: "Document rejected successfully",
type: SnackbarType.warning,
);
} else {
showAppSnackbar(
title: "Error",
message: "Failed to reject document",
type: SnackbarType.error,
);
}
},
),
),
],
),
],
],
),
);
}
/// ---------------- VERSIONS SECTION ----------------
Widget _buildVersionsSection() {
return Obx(() {
if (controller.isVersionsLoading.value) {
return const Center(child: CircularProgressIndicator());
}
if (controller.versions.isEmpty) {
return MyText.bodySmall("No versions found", color: Colors.grey);
}
final sorted = [...controller.versions];
sorted.sort((a, b) => b.uploadedAt.compareTo(a.uploadedAt));
return ListView.separated(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: sorted.length,
separatorBuilder: (_, __) => Divider(height: 1),
itemBuilder: (context, index) {
final version = sorted[index];
final uploadDate =
DateFormat("dd MMM yyyy, hh:mm a").format(version.uploadedAt);
return ListTile(
leading: const Icon(Icons.description, color: Colors.blue),
title: MyText.bodyMedium(
"${version.name} (v${version.version})",
fontWeight: 600,
color: Colors.black,
),
subtitle: MyText.bodySmall(
"Uploaded by ${version.uploadedBy.firstName} ${version.uploadedBy.lastName}$uploadDate",
color: Colors.grey.shade600,
),
trailing:
permissionController.hasPermission(Permissions.viewDocument)
? IconButton(
icon: const Icon(Icons.open_in_new, color: Colors.blue),
onPressed: () async {
final url =
await controller.fetchPresignedUrl(version.id);
if (url != null) {
_openDocument(url);
} else {
showAppSnackbar(
title: "Error",
message: "Failed to fetch document link",
type: SnackbarType.error,
);
}
},
)
: null,
);
},
);
});
}
/// ---------------- HELPERS ----------------
Widget _buildTagChip(String label) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
margin: const EdgeInsets.only(right: 6, bottom: 6),
decoration: BoxDecoration(
color: Colors.blue.shade100,
borderRadius: BorderRadius.circular(5),
),
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,
);
}
}
}