import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:marco/controller/finance/payment_request_detail_controller.dart'; import 'package:marco/controller/project_controller.dart'; import 'package:marco/controller/permission_controller.dart'; import 'package:marco/helpers/utils/date_time_utils.dart'; import 'package:marco/helpers/widgets/my_refresh_indicator.dart'; import 'package:marco/helpers/widgets/my_spacing.dart'; import 'package:marco/helpers/widgets/my_text.dart'; import 'package:timeline_tile/timeline_tile.dart'; import 'package:marco/helpers/utils/mixins/ui_mixin.dart'; import 'package:marco/model/finance/payment_request_details_model.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:marco/helpers/widgets/image_viewer_dialog.dart'; import 'package:marco/helpers/widgets/my_custom_skeleton.dart'; import 'package:timeago/timeago.dart' as timeago; import 'package:marco/model/expense/comment_bottom_sheet.dart'; import 'package:marco/helpers/services/storage/local_storage.dart'; import 'package:marco/model/employees/employee_info.dart'; import 'package:marco/helpers/widgets/my_snackbar.dart'; import 'package:marco/model/finance/payment_request_rembursement_bottom_sheet.dart'; import 'package:marco/model/finance/make_expense_bottom_sheet.dart'; import 'package:marco/model/finance/add_payment_request_bottom_sheet.dart'; class PaymentRequestDetailScreen extends StatefulWidget { final String paymentRequestId; const PaymentRequestDetailScreen({super.key, required this.paymentRequestId}); @override State createState() => _PaymentRequestDetailScreenState(); } class _PaymentRequestDetailScreenState extends State with UIMixin { final controller = Get.put(PaymentRequestDetailController()); final projectController = Get.find(); final permissionController = Get.find(); final RxBool canSubmit = false.obs; bool _checkedPermission = false; EmployeeInfo? employeeInfo; @override void initState() { super.initState(); controller.init(widget.paymentRequestId); _loadEmployeeInfo(); } void _checkPermissionToSubmit(PaymentRequestData request) { const draftStatusId = '6537018f-f4e9-4cb3-a210-6c3b2da999d7'; final isCreatedByCurrentUser = employeeInfo?.id == request.createdBy.id; final hasDraftNextStatus = request.nextStatus.any((s) => s.id == draftStatusId); canSubmit.value = isCreatedByCurrentUser && hasDraftNextStatus; } Future _loadEmployeeInfo() async { employeeInfo = await LocalStorage.getEmployeeInfo(); setState(() {}); } Color _parseColor(String hexColor) { String hex = hexColor.toUpperCase().replaceAll('#', ''); if (hex.length == 6) hex = 'FF$hex'; return Color(int.parse(hex, radix: 16)); } void _openEditPaymentRequestBottomSheet(request) { showPaymentRequestBottomSheet( isEdit: true, existingData: { "paymentRequestId": request.paymentRequestUID, "title": request.title, "projectId": request.project.id, "projectName": request.project.name, "expenseCategoryId": request.expenseCategory.id, "expenseCategoryName": request.expenseCategory.name, "amount": request.amount.toString(), "currencyId": request.currency.id, "currencySymbol": request.currency.symbol, "payee": request.payee, "description": request.description, "isAdvancePayment": request.isAdvancePayment, "dueDate": request.dueDate, "attachments": request.attachments .map((a) => { "url": a.url, "fileName": a.fileName, "documentId": a.id, "contentType": a.contentType, }) .toList(), }, onUpdated: () async { await controller.fetchPaymentRequestDetail(); }, ); } @override Widget build(BuildContext context) { return Scaffold( backgroundColor: Colors.white, appBar: _buildAppBar(), body: SafeArea( child: Obx(() { if (controller.isLoading.value) { return SkeletonLoaders.paymentRequestDetailSkeletonLoader(); } final request = controller.paymentRequest.value; if (controller.errorMessage.isNotEmpty || request == null) { return Center(child: MyText.bodyMedium("No data to display.")); } return MyRefreshIndicator( onRefresh: controller.fetchPaymentRequestDetail, child: SingleChildScrollView( padding: EdgeInsets.fromLTRB( 12, 12, 12, 60 + MediaQuery.of(context).padding.bottom, ), child: Center( child: Container( constraints: const BoxConstraints(maxWidth: 520), child: Card( shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(5)), elevation: 3, child: Padding( padding: const EdgeInsets.symmetric( vertical: 14, horizontal: 14), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _Header(request: request, colorParser: _parseColor), const Divider(height: 30, thickness: 1.2), _Logs( logs: request.updateLogs, colorParser: _parseColor), const Divider(height: 30, thickness: 1.2), _Parties(request: request), const Divider(height: 30, thickness: 1.2), _DetailsTable(request: request), const Divider(height: 30, thickness: 1.2), _Documents(documents: request.attachments), MySpacing.height(24), ], ), ), ), ), ), ), ); }), ), bottomNavigationBar: _buildBottomActionBar(), // ✅ Added Floating Action Button for Edit floatingActionButton: Obx(() { if (controller.isLoading.value) return const SizedBox.shrink(); final request = controller.paymentRequest.value; if (controller.errorMessage.isNotEmpty || request == null) { return const SizedBox.shrink(); } if (!_checkedPermission) { _checkedPermission = true; WidgetsBinding.instance.addPostFrameCallback((_) { _checkPermissionToSubmit(request); }); } final canEdit = PaymentRequestPermissionHelper.canEditPaymentRequest( employeeInfo, request, ); if (!canEdit) return const SizedBox.shrink(); return FloatingActionButton.extended( onPressed: () => _openEditPaymentRequestBottomSheet(request), backgroundColor: contentTheme.primary, icon: const Icon(Icons.edit), label: MyText.bodyMedium( "Edit Payment Request", fontWeight: 600, color: Colors.white, ), ); }), ); } Widget _buildBottomActionBar() { return Obx(() { final request = controller.paymentRequest.value; if (request == null || controller.isLoading.value || employeeInfo == null) { return const SizedBox.shrink(); } if (!_checkedPermission) { _checkedPermission = true; WidgetsBinding.instance.addPostFrameCallback((_) { _checkPermissionToSubmit(request); }); } const reimbursementStatusId = '61578360-3a49-4c34-8604-7b35a3787b95'; const draftStatusId = '6537018f-f4e9-4cb3-a210-6c3b2da999d7'; final availableStatuses = request.nextStatus.where((status) { if (status.id == draftStatusId) { return employeeInfo?.id == request.createdBy.id; } return permissionController .hasAnyPermission(status.permissionIds ?? []); }).toList(); if (availableStatuses.isEmpty) { return SafeArea( child: Container( padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16), decoration: BoxDecoration( color: Colors.white, border: Border(top: BorderSide(color: Colors.grey.shade300)), ), child: ElevatedButton( style: ElevatedButton.styleFrom( padding: const EdgeInsets.symmetric(vertical: 14), backgroundColor: Colors.blue, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(8), ), ), onPressed: () => showCreateExpenseBottomSheet(), child: const Text( "Create Expense", style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold), ), ), ), ); } return SafeArea( child: Container( decoration: BoxDecoration( color: Colors.white, border: Border(top: BorderSide(color: Colors.grey.shade300)), ), padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16), child: Wrap( alignment: WrapAlignment.center, spacing: 10, runSpacing: 10, children: availableStatuses.map((status) { final color = _parseColor(status.color); return ElevatedButton( style: ElevatedButton.styleFrom( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), backgroundColor: color, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(8), ), ), onPressed: () async { if (status.id == reimbursementStatusId) { showModalBottomSheet( context: context, isScrollControlled: true, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(5)), ), builder: (ctx) => UpdatePaymentRequestWithReimbursement( expenseId: request.paymentRequestUID, statusId: status.id, onClose: () {}, ), ); } else { final comment = await showCommentBottomSheet( context, status.displayName); if (comment == null || comment.trim().isEmpty) return; final success = await controller.updatePaymentRequestStatus( statusId: status.id, comment: comment.trim(), ); showAppSnackbar( title: success ? 'Success' : 'Error', message: success ? 'Status updated successfully' : 'Failed to update status', type: success ? SnackbarType.success : SnackbarType.error, ); if (success) await controller.fetchPaymentRequestDetail(); } }, child: Text(status.displayName, style: const TextStyle(color: Colors.white)), ); }).toList(), ), ), ); }); } PreferredSizeWidget _buildAppBar() { return PreferredSize( preferredSize: const Size.fromHeight(72), child: AppBar( backgroundColor: const Color(0xFFF5F5F5), elevation: 0.5, automaticallyImplyLeading: false, titleSpacing: 0, title: Padding( padding: MySpacing.xy(16, 0), child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ IconButton( icon: const Icon(Icons.arrow_back_ios_new, color: Colors.black, size: 20), onPressed: () => Get.back(), ), MySpacing.width(8), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ MyText.titleLarge( 'Payment Request Details', fontWeight: 700, color: Colors.black, ), MySpacing.height(2), GetBuilder(builder: (_) { final name = projectController.selectedProject?.name ?? 'Select Project'; return Row( children: [ const Icon(Icons.work_outline, size: 14, color: Colors.grey), MySpacing.width(4), Expanded( child: MyText.bodySmall( name, fontWeight: 600, overflow: TextOverflow.ellipsis, color: Colors.grey[700], ), ), ], ); }), ], ), ), ], ), ), ), ); } } class PaymentRequestPermissionHelper { static bool canEditPaymentRequest( EmployeeInfo? employee, PaymentRequestData request) { return employee?.id == request.createdBy.id && _isInAllowedEditStatus(request.expenseStatus.id); } static bool canSubmitPaymentRequest( EmployeeInfo? employee, PaymentRequestData request) { return employee?.id == request.createdBy.id && request.nextStatus.isNotEmpty; } static bool _isInAllowedEditStatus(String statusId) { const editableStatusIds = [ "d1ee5eec-24b6-4364-8673-a8f859c60729", "965eda62-7907-4963-b4a1-657fb0b2724b", "297e0d8f-f668-41b5-bfea-e03b354251c8", ]; return editableStatusIds.contains(statusId); } } class _Header extends StatelessWidget { final PaymentRequestData request; final Color Function(String) colorParser; const _Header({required this.request, required this.colorParser}); @override Widget build(BuildContext context) { final statusColor = colorParser(request.expenseStatus.color); return Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Expanded( child: Row( children: [ const Icon(Icons.calendar_month, size: 18, color: Colors.grey), MySpacing.width(6), MyText.bodySmall('Created At:', fontWeight: 600), MySpacing.width(6), Expanded( child: MyText.bodySmall( DateTimeUtils.convertUtcToLocal( request.createdAt.toIso8601String(), format: 'dd MMM yyyy'), fontWeight: 600, overflow: TextOverflow.ellipsis, ), ), ], ), ), Container( decoration: BoxDecoration( color: statusColor.withOpacity(0.15), borderRadius: BorderRadius.circular(5)), padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), child: Row( children: [ Icon(Icons.flag, size: 16, color: statusColor), MySpacing.width(4), SizedBox( width: 100, child: MyText.labelSmall( request.expenseStatus.displayName, color: statusColor, fontWeight: 600, overflow: TextOverflow.ellipsis, ), ), ], ), ), ], ); } } class _Logs extends StatelessWidget { final List logs; final Color Function(String) colorParser; const _Logs({required this.logs, required this.colorParser}); DateTime _parseTimestamp(DateTime ts) => ts; @override Widget build(BuildContext context) { if (logs.isEmpty) { return MyText.bodyMedium('No Timeline', color: Colors.grey); } final reversedLogs = logs.reversed.toList(); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ MyText.bodySmall("Timeline:", fontWeight: 600), ListView.builder( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), itemCount: reversedLogs.length, itemBuilder: (_, index) { final log = reversedLogs[index]; final status = log.status?.name ?? 'Unknown'; final description = log.status?.description ?? ''; final statusColor = log.status != null ? colorParser(log.status!.color) : Colors.grey; final comment = log.comment; final nextStatusName = log.nextStatus.name; final updatedBy = log.updatedBy; final initials = '${updatedBy.firstName.isNotEmpty == true ? updatedBy.firstName[0] : ''}' '${updatedBy.lastName.isNotEmpty == true ? updatedBy.lastName[0] : ''}'; final name = '${updatedBy.firstName} ${updatedBy.lastName}'; final timestamp = _parseTimestamp(log.updatedAt); final timeAgo = timeago.format(timestamp); final nextStatusColor = colorParser(log.nextStatus.color); return TimelineTile( alignment: TimelineAlign.start, isFirst: index == 0, isLast: index == reversedLogs.length - 1, indicatorStyle: IndicatorStyle( width: 16, height: 16, indicator: Container( decoration: BoxDecoration(shape: BoxShape.circle, color: statusColor), ), ), beforeLineStyle: LineStyle(color: Colors.grey.shade300, thickness: 2), endChild: Padding( padding: const EdgeInsets.all(12.0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ MyText.bodyMedium(status, fontWeight: 600, color: statusColor), MyText.bodySmall(timeAgo, color: Colors.grey[600], textAlign: TextAlign.right), ], ), if (description.isNotEmpty) ...[ const SizedBox(height: 4), MyText.bodySmall(description, color: Colors.grey[800]), ], if (comment.isNotEmpty) ...[ const SizedBox(height: 8), MyText.bodyMedium(comment, fontWeight: 500), ], const SizedBox(height: 8), Row( children: [ Container( padding: const EdgeInsets.symmetric( horizontal: 6, vertical: 2), decoration: BoxDecoration( color: Colors.grey.shade300, borderRadius: BorderRadius.circular(4), ), child: MyText.bodySmall(initials, fontWeight: 600), ), const SizedBox(width: 6), Expanded( child: MyText.bodySmall(name, overflow: TextOverflow.ellipsis)), if (nextStatusName.isNotEmpty) Container( padding: const EdgeInsets.symmetric( horizontal: 8, vertical: 4), decoration: BoxDecoration( color: nextStatusColor.withOpacity(0.15), borderRadius: BorderRadius.circular(4), ), child: MyText.bodySmall(nextStatusName, fontWeight: 600, color: nextStatusColor), ), ], ), ], ), ), ); }, ) ], ); } } class _Parties extends StatelessWidget { final PaymentRequestData request; const _Parties({required this.request}); @override Widget build(BuildContext context) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _labelValueRow('Project', request.project.name), _labelValueRow('Payee', request.payee), _labelValueRow('Created By', '${request.createdBy.firstName} ${request.createdBy.lastName}'), _labelValueRow('Pre-Approved', request.isAdvancePayment ? 'Yes' : 'No'), ], ); } } class _DetailsTable extends StatelessWidget { final PaymentRequestData request; const _DetailsTable({required this.request}); @override Widget build(BuildContext context) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _labelValueRow("Payment Request ID:", request.paymentRequestUID), _labelValueRow("Expense Category:", request.expenseCategory.name), _labelValueRow("Amount:", "${request.currency.symbol} ${request.amount.toStringAsFixed(2)}"), _labelValueRow( "Due Date:", DateTimeUtils.convertUtcToLocal(request.dueDate.toIso8601String(), format: 'dd MMM yyyy')), _labelValueRow("Description:", request.description), _labelValueRow( "Attachment:", request.attachments.isNotEmpty ? "Yes" : "No"), ], ); } } class _Documents extends StatelessWidget { final List documents; const _Documents({required this.documents}); @override Widget build(BuildContext context) { if (documents.isEmpty) return MyText.bodyMedium('No Documents', color: Colors.grey); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ MyText.bodySmall("Documents:", fontWeight: 600), const SizedBox(height: 12), ListView.separated( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), itemCount: documents.length, separatorBuilder: (_, __) => const SizedBox(height: 8), itemBuilder: (context, index) { final doc = documents[index]; final isImage = doc.contentType.startsWith('image/'); return GestureDetector( onTap: () async { final imageDocs = documents .where((d) => d.contentType.startsWith('image/')) .toList(); final initialIndex = imageDocs.indexWhere((d) => d.id == doc.id); if (isImage && imageDocs.isNotEmpty && initialIndex != -1) { showDialog( context: context, builder: (_) => ImageViewerDialog( imageSources: imageDocs.map((e) => e.url).toList(), initialIndex: initialIndex, ), ); } else { final Uri url = Uri.parse(doc.url); if (await canLaunchUrl(url)) { await launchUrl(url, mode: LaunchMode.externalApplication); } else { showAppSnackbar( title: 'Error', message: 'Could not open document.', type: SnackbarType.error, ); } } }, child: Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), decoration: BoxDecoration( border: Border.all(color: Colors.grey.shade300), borderRadius: BorderRadius.circular(5), color: Colors.grey.shade100, ), child: Row( children: [ Icon(isImage ? Icons.image : Icons.insert_drive_file, size: 20, color: Colors.grey[600]), const SizedBox(width: 7), Expanded( child: MyText.labelSmall( doc.fileName, overflow: TextOverflow.ellipsis, ), ), ], ), ), ); }, ), ], ); } } // Utility widget for label-value row. Widget _labelValueRow(String label, String value) => Padding( padding: const EdgeInsets.symmetric(vertical: 6), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ SizedBox( width: 120, child: MyText.bodySmall(label, fontWeight: 600), ), Expanded( child: MyText.bodySmall(value, fontWeight: 500, softWrap: true), ), ], ), );