import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:intl/intl.dart'; import 'package:marco/controller/expense/expense_detail_controller.dart'; import 'package:marco/controller/project_controller.dart'; import 'package:marco/helpers/utils/date_time_utils.dart'; import 'package:marco/helpers/widgets/my_spacing.dart'; import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/model/expense/expense_detail_model.dart'; import 'package:marco/helpers/widgets/image_viewer_dialog.dart'; import 'package:url_launcher/url_launcher.dart'; class ExpenseDetailScreen extends StatelessWidget { final String expenseId; const ExpenseDetailScreen({super.key, required this.expenseId}); static Color getStatusColor(String? status, {String? colorCode}) { if (colorCode != null && colorCode.isNotEmpty) { try { return Color(int.parse(colorCode.replaceFirst('#', '0xff'))); } catch (_) {} } switch (status) { case 'Approval Pending': return Colors.orange; case 'Process Pending': return Colors.blue; case 'Rejected': return Colors.red; case 'Paid': return Colors.green; default: return Colors.black; } } @override Widget build(BuildContext context) { final controller = Get.put(ExpenseDetailController()); final projectController = Get.find(); controller.fetchExpenseDetails(expenseId); return Scaffold( backgroundColor: const Color(0xFFF7F7F7), appBar: AppBar( automaticallyImplyLeading: false, elevation: 1, backgroundColor: Colors.white, title: Row( children: [ IconButton( icon: const Icon(Icons.arrow_back_ios_new, color: Colors.black, size: 20), onPressed: () => Get.offAllNamed('/dashboard/expense-main-page'), ), const SizedBox(width: 8), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ MyText.titleLarge('Expense Details', fontWeight: 700, color: Colors.black), MySpacing.height(2), GetBuilder(builder: (_) { final projectName = 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( projectName, fontWeight: 600, overflow: TextOverflow.ellipsis, color: Colors.grey[700], ), ), ], ); }), ], ), ), ], ), ), body: SafeArea( child: Obx(() { if (controller.isLoading.value) { return _buildLoadingSkeleton(); } if (controller.errorMessage.isNotEmpty) { return Center( child: MyText.bodyMedium( controller.errorMessage.value, color: Colors.red, ), ); } final expense = controller.expense.value; if (expense == null) { return Center( child: MyText.bodyMedium("No expense details found.")); } final statusColor = getStatusColor(expense.status.name, colorCode: expense.status.color); final formattedAmount = NumberFormat.currency( locale: 'en_IN', symbol: '₹ ', decimalDigits: 2, ).format(expense.amount); return SingleChildScrollView( padding: const EdgeInsets.all(8), child: Center( child: Container( constraints: const BoxConstraints(maxWidth: 520), child: Card( shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(10)), elevation: 3, child: Padding( padding: const EdgeInsets.symmetric( vertical: 14, horizontal: 14), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _InvoiceHeader(expense: expense), Divider(height: 30, thickness: 1.2), _InvoiceParties(expense: expense), Divider(height: 30, thickness: 1.2), _InvoiceDetailsTable(expense: expense), Divider(height: 30, thickness: 1.2), _InvoiceDocuments(documents: expense.documents), Divider(height: 30, thickness: 1.2), _InvoiceTotals( expense: expense, formattedAmount: formattedAmount, statusColor: statusColor, ), ], ), ), ), ), ), ); }), ), bottomNavigationBar: Obx(() { final expense = controller.expense.value; if (expense == null || expense.nextStatus.isEmpty) { return const SizedBox(); } return SafeArea( child: Container( decoration: const BoxDecoration( color: Colors.white, border: Border(top: BorderSide(color: Color(0x11000000))), ), padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16), child: Wrap( alignment: WrapAlignment.center, spacing: 10, runSpacing: 10, children: expense.nextStatus.map((next) { Color buttonColor = Colors.red; if (next.color.isNotEmpty) { try { buttonColor = Color(int.parse(next.color.replaceFirst('#', '0xff'))); } catch (_) {} } return ElevatedButton( style: ElevatedButton.styleFrom( minimumSize: const Size(100, 40), padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 12), backgroundColor: buttonColor, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(6)), ), onPressed: () async { final success = await controller.updateExpenseStatus( expense.id, next.id); if (success) { Get.snackbar( 'Success', 'Expense moved to ${next.displayName.isNotEmpty ? next.displayName : next.name}', backgroundColor: Colors.green.withOpacity(0.8), colorText: Colors.white, ); await controller.fetchExpenseDetails(expenseId); } else { Get.snackbar( 'Error', 'Failed to update status.', backgroundColor: Colors.red.withOpacity(0.8), colorText: Colors.white, ); } }, child: MyText.labelMedium( next.displayName.isNotEmpty ? next.displayName : next.name, color: Colors.white, fontWeight: 600, overflow: TextOverflow.ellipsis, ), ); }).toList(), ), ), ); }), ); } Widget _buildLoadingSkeleton() { return ListView( padding: const EdgeInsets.all(16), children: List.generate(5, (index) { return Container( margin: const EdgeInsets.only(bottom: 16), height: 80, decoration: BoxDecoration( color: Colors.grey[300], borderRadius: BorderRadius.circular(10), ), ); }), ); } } // ---------------- INVOICE SUB-COMPONENTS ---------------- class _InvoiceHeader extends StatelessWidget { final ExpenseDetailModel expense; const _InvoiceHeader({required this.expense}); @override Widget build(BuildContext context) { final dateString = DateTimeUtils.convertUtcToLocal( expense.transactionDate.toString(), format: 'dd-MM-yyyy'); final statusColor = ExpenseDetailScreen.getStatusColor(expense.status.name, colorCode: expense.status.color); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Row( children: [ const Icon(Icons.calendar_month, size: 18, color: Colors.grey), MySpacing.width(6), MyText.bodySmall('Date:', fontWeight: 600), MySpacing.width(6), MyText.bodySmall(dateString, fontWeight: 600), ], ), Container( decoration: BoxDecoration( color: statusColor.withOpacity(0.15), borderRadius: BorderRadius.circular(8), ), padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), child: Row( children: [ Icon(Icons.flag, size: 16, color: statusColor), MySpacing.width(4), MyText.labelSmall( expense.status.name, color: statusColor, fontWeight: 600, ), ], ), ), ], ) ], ); } } class _InvoiceParties extends StatelessWidget { final ExpenseDetailModel expense; const _InvoiceParties({required this.expense}); @override Widget build(BuildContext context) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _labelValueBlock('Project', expense.project.name), MySpacing.height(16), _labelValueBlock( 'Paid By:', '${expense.paidBy.firstName} ${expense.paidBy.lastName}', ), MySpacing.height(16), _labelValueBlock('Supplier', expense.supplerName), MySpacing.height(16), _labelValueBlock( 'Created By:', '${expense.createdBy.firstName} ${expense.createdBy.lastName}', ), ], ); } Widget _labelValueBlock(String label, String value) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ MyText.bodySmall( label, fontWeight: 600, ), MySpacing.height(4), MyText.bodySmall( value, fontWeight: 500, softWrap: true, maxLines: null, // Allow full wrapping ), ], ); } } class _InvoiceDetailsTable extends StatelessWidget { final ExpenseDetailModel expense; const _InvoiceDetailsTable({required this.expense}); @override Widget build(BuildContext context) { final transactionDate = DateTimeUtils.convertUtcToLocal( expense.transactionDate.toString(), format: 'dd-MM-yyyy hh:mm a'); final createdAt = DateTimeUtils.convertUtcToLocal( expense.createdAt.toString(), format: 'dd-MM-yyyy hh:mm a'); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _detailItem("Expense Type:", expense.expensesType.name), _detailItem("Payment Mode:", expense.paymentMode.name), _detailItem("Transaction Date:", transactionDate), _detailItem("Created At:", createdAt), _detailItem("Pre-Approved:", expense.preApproved ? 'Yes' : 'No'), _detailItem("Description:", expense.description.trim().isNotEmpty ? expense.description : '-', isDescription: true), ], ); } Widget _detailItem(String title, String value, {bool isDescription = false}) { return Padding( padding: const EdgeInsets.symmetric(vertical: 10), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ MyText.bodySmall( title, fontWeight: 600, ), MySpacing.height(3), isDescription ? ExpandableDescription(description: value) : MyText.bodySmall( value, fontWeight: 500, ), ], ), ); } } class _InvoiceDocuments extends StatelessWidget { final List documents; const _InvoiceDocuments({required this.documents}); @override Widget build(BuildContext context) { if (documents.isEmpty) { return MyText.bodyMedium('No Supporting Documents', color: Colors.grey); } return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ MyText.bodySmall("Supporting Documents:", fontWeight: 600), const SizedBox(height: 8), Wrap( spacing: 10, children: documents.map((doc) { return GestureDetector( onTap: () async { final imageDocs = documents .where((d) => d.contentType.startsWith('image/')) .toList(); final initialIndex = imageDocs.indexWhere((d) => d.documentId == doc.documentId); if (imageDocs.isNotEmpty && initialIndex != -1) { showDialog( context: context, builder: (_) => ImageViewerDialog( imageSources: imageDocs.map((e) => e.preSignedUrl).toList(), initialIndex: initialIndex, ), ); } else { final Uri url = Uri.parse(doc.preSignedUrl); if (await canLaunchUrl(url)) { await launchUrl(url, mode: LaunchMode.externalApplication); } else { Get.snackbar("Error", "Could not open the document."); } } }, child: Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), decoration: BoxDecoration( border: Border.all(color: Colors.grey.shade300), borderRadius: BorderRadius.circular(6), color: Colors.grey.shade100, ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon( doc.contentType.startsWith('image/') ? Icons.image : Icons.insert_drive_file, size: 20, color: Colors.grey[600], ), const SizedBox(width: 7), MyText.labelSmall( doc.fileName, ), ], ), ), ); }).toList(), ), ], ); } } class _InvoiceTotals extends StatelessWidget { final ExpenseDetailModel expense; final String formattedAmount; final Color statusColor; const _InvoiceTotals({ required this.expense, required this.formattedAmount, required this.statusColor, }); @override Widget build(BuildContext context) { return Row( children: [ MyText.bodyLarge("Total:", fontWeight: 700), const Spacer(), MyText.bodyLarge(formattedAmount, fontWeight: 700), ], ); } } class ExpandableDescription extends StatefulWidget { final String description; const ExpandableDescription({super.key, required this.description}); @override State createState() => _ExpandableDescriptionState(); } class _ExpandableDescriptionState extends State { bool isExpanded = false; @override Widget build(BuildContext context) { final isLong = widget.description.length > 100; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ MyText.bodySmall( widget.description, maxLines: isExpanded ? null : 2, overflow: isExpanded ? TextOverflow.visible : TextOverflow.ellipsis, fontWeight: 500, ), if (isLong || !isExpanded) InkWell( onTap: () => setState(() => isExpanded = !isExpanded), child: Padding( padding: const EdgeInsets.only(top: 4), child: MyText.labelSmall( isExpanded ? 'Show less' : 'Show more', fontWeight: 600, color: Colors.blue, ), ), ), ], ); } }