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; 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(); @override void initState() { super.initState(); controller.init(widget.paymentRequestId); } @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 as PaymentRequestData?; 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, 30 + 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), const Divider(height: 30, thickness: 1.2), // Move Logs here, right after header _Logs(logs: request.updateLogs), 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), ], ), ), ), ), ), ), ); }), ), ); } 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], ), ), ], ); }, ), ], ), ), ], ), ), ), ); } } // Header Row class _Header extends StatelessWidget { final PaymentRequestData request; const _Header({required this.request}); // Helper to parse hex color string to Color Color parseColorFromHex(String hexColor) { hexColor = hexColor.toUpperCase().replaceAll("#", ""); if (hexColor.length == 6) { hexColor = "FF" + hexColor; // Add alpha if missing } return Color(int.parse(hexColor, radix: 16)); } @override Widget build(BuildContext context) { final statusColor = parseColorFromHex(request.expenseStatus.color); return Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ // Left side: wrap in Expanded to prevent overflow 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, ), ), ], ), ), // Right side: Status Chip 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( // Prevent overflow of long status text width: 100, child: MyText.labelSmall( request.expenseStatus.displayName, color: statusColor, fontWeight: 600, overflow: TextOverflow.ellipsis, ), ), ], ), ), ], ); } } // Horizontal 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, ), ), ], ), ); // Parties Section 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'), ], ); } } // Details Table 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"), ], ); } } // Documents Section 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] as Map; return GestureDetector( onTap: () async { final imageDocs = documents .where((d) => (d['contentType'] as String).startsWith('image/')) .toList(); final initialIndex = imageDocs.indexWhere((d) => d['id'] == doc['id']); if (imageDocs.isNotEmpty && initialIndex != -1) { showDialog( context: context, builder: (_) => ImageViewerDialog( imageSources: imageDocs.map((e) => e['url'] as String).toList(), initialIndex: initialIndex, ), ); } else { final Uri url = Uri.parse(doc['url'] as String); if (await canLaunchUrl(url)) { await launchUrl(url, mode: LaunchMode.externalApplication); } else { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Could not open document.')), ); } } }, 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( (doc['contentType'] as String).startsWith('image/') ? 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, ), ), ], ), ), ); }, ), ], ); } } class _Logs extends StatelessWidget { final List logs; const _Logs({required this.logs}); // Helper to parse hex color string to Color Color parseColorFromHex(String hexColor) { hexColor = hexColor.toUpperCase().replaceAll("#", ""); if (hexColor.length == 6) { hexColor = "FF" + hexColor; // Add alpha for opacity if missing } return Color(int.parse(hexColor, radix: 16)); } DateTime parseTimestamp(String ts) => DateTime.parse(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] as Map; final statusMap = log['status'] ?? {}; final status = statusMap['name'] ?? ''; final description = statusMap['description'] ?? ''; final comment = log['comment'] ?? ''; final nextStatusMap = log['nextStatus'] ?? {}; final nextStatusName = nextStatusMap['name'] ?? ''; final updatedBy = log['updatedBy'] ?? {}; final initials = "${(updatedBy['firstName'] ?? '').isNotEmpty ? (updatedBy['firstName']![0]) : ''}" "${(updatedBy['lastName'] ?? '').isNotEmpty ? (updatedBy['lastName']![0]) : ''}"; final name = "${updatedBy['firstName'] ?? ''} ${updatedBy['lastName'] ?? ''}"; final timestamp = parseTimestamp(log['updatedAt']); final timeAgo = timeago.format(timestamp); final statusColor = statusMap['color'] != null ? parseColorFromHex(statusMap['color']) : Colors.black; final nextStatusColor = nextStatusMap['color'] != null ? parseColorFromHex(nextStatusMap['color']) : Colors.blue.shade700; 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: [ // Status and time in one row 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, ), ), ], ), ], ), ), ); }, ) ], ); } }