import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:intl/intl.dart'; import 'package:marco/model/expense/add_expense_bottom_sheet.dart'; import 'package:marco/controller/expense/expense_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_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'; import 'package:marco/model/expense/reimbursement_bottom_sheet.dart'; import 'package:marco/helpers/widgets/my_snackbar.dart'; import 'package:marco/model/expense/comment_bottom_sheet.dart'; import 'package:marco/controller/expense/add_expense_controller.dart'; import 'package:marco/helpers/services/app_logger.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.init(expenseId); final permissionController = Get.find(); return Scaffold( backgroundColor: const Color(0xFFF7F7F7), appBar: _AppBar(projectController: projectController), body: SafeArea( child: Obx(() { if (controller.isLoading.value) return _buildLoadingSkeleton(); final expense = controller.expense.value; if (controller.errorMessage.isNotEmpty || expense == null) { return Center(child: MyText.bodyMedium("No data to display.")); } final statusColor = getStatusColor(expense.status.name, colorCode: expense.status.color); final formattedAmount = _formatAmount(expense.amount); return SingleChildScrollView( padding: EdgeInsets.fromLTRB( 8, 8, 8, 30 + MediaQuery.of(context).padding.bottom), 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), const Divider(height: 30, thickness: 1.2), _InvoiceParties(expense: expense), const Divider(height: 30, thickness: 1.2), _InvoiceDetailsTable(expense: expense), const Divider(height: 30, thickness: 1.2), _InvoiceDocuments(documents: expense.documents), const Divider(height: 30, thickness: 1.2), _InvoiceTotals( expense: expense, formattedAmount: formattedAmount, statusColor: statusColor), ], ), ), ), ), ), ); }), ), floatingActionButton: Obx(() { final expense = controller.expense.value; if (expense == null) return const SizedBox.shrink(); // Allowed status Ids const allowedStatusIds = [ "d1ee5eec-24b6-4364-8673-a8f859c60729", "965eda62-7907-4963-b4a1-657fb0b2724b", "297e0d8f-f668-41b5-bfea-e03b354251c8" ]; // Show edit button only if status id is in allowedStatusIds if (!allowedStatusIds.contains(expense.status.id)) { return const SizedBox.shrink(); } return FloatingActionButton( onPressed: () async { final editData = { 'id': expense.id, 'projectName': expense.project.name, 'amount': expense.amount, 'supplerName': expense.supplerName, 'description': expense.description, 'transactionId': expense.transactionId, 'location': expense.location, 'transactionDate': expense.transactionDate, 'noOfPersons': expense.noOfPersons, 'expensesTypeId': expense.expensesType.id, 'paymentModeId': expense.paymentMode.id, 'paidById': expense.paidBy.id, // ==== Add these lines below ==== 'paidByFirstName': expense.paidBy.firstName, 'paidByLastName': expense.paidBy.lastName, // ================================= 'attachments': expense.documents .map((doc) => { 'url': doc.preSignedUrl, 'fileName': doc.fileName, 'documentId': doc.documentId, 'contentType': doc.contentType, }) .toList(), }; logSafe('editData: $editData', level: LogLevel.info); final addCtrl = Get.put(AddExpenseController()); await addCtrl.loadMasterData(); addCtrl.populateFieldsForEdit(editData); await showAddExpenseBottomSheet(isEdit: true); // Refresh expense details after editing await controller.fetchExpenseDetails(); }, backgroundColor: Colors.red, tooltip: 'Edit Expense', child: Icon(Icons.edit), ); }), 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 .where((next) => permissionController.hasAnyPermission( controller.parsePermissionIds(next.permissionIds))) .map((next) => _statusButton(context, controller, expense, next)) .toList(), ), ), ); }), ); } Widget _statusButton(BuildContext context, ExpenseDetailController controller, ExpenseDetailModel expense, dynamic 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 { // For brevity, couldn't refactor the logic since it's business-specific. const reimbursementId = 'f18c5cfd-7815-4341-8da2-2c2d65778e27'; if (expense.status.id == reimbursementId) { showModalBottomSheet( context: context, isScrollControlled: true, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(16))), builder: (context) => ReimbursementBottomSheet( expenseId: expense.id, statusId: next.id, onClose: () {}, onSubmit: ({ required String comment, required String reimburseTransactionId, required String reimburseDate, required String reimburseById, required String statusId, }) async { final success = await controller.updateExpenseStatusWithReimbursement( comment: comment, reimburseTransactionId: reimburseTransactionId, reimburseDate: reimburseDate, reimburseById: reimburseById, statusId: statusId, ); if (success) { showAppSnackbar( title: 'Success', message: 'Expense reimbursed successfully.', type: SnackbarType.success); await controller.fetchExpenseDetails(); return true; } else { showAppSnackbar( title: 'Error', message: 'Failed to reimburse expense.', type: SnackbarType.error); return false; } }, ), ); } else { final comment = await showCommentBottomSheet(context, next.name); if (comment == null) return; final success = await controller.updateExpenseStatus(next.id, comment: comment); if (success) { showAppSnackbar( title: 'Success', message: 'Expense moved to ${next.displayName.isNotEmpty ? next.displayName : next.name}', type: SnackbarType.success); await controller.fetchExpenseDetails(); } else { showAppSnackbar( title: 'Error', message: 'Failed to update status.', type: SnackbarType.error); } } }, child: MyText.labelMedium( next.displayName.isNotEmpty ? next.displayName : next.name, color: Colors.white, fontWeight: 600, overflow: TextOverflow.ellipsis, ), ); } static String _formatAmount(double amount) { return NumberFormat.currency( locale: 'en_IN', symbol: '₹ ', decimalDigits: 2) .format(amount); } Widget _buildLoadingSkeleton() { return ListView.builder( padding: const EdgeInsets.all(16), itemCount: 5, itemBuilder: (_, __) => Container( margin: const EdgeInsets.only(bottom: 16), height: 80, decoration: BoxDecoration( color: Colors.grey[300], borderRadius: BorderRadius.circular(10)), ), ); } } class _AppBar extends StatelessWidget implements PreferredSizeWidget { final ProjectController projectController; const _AppBar({required this.projectController}); @override Widget build(BuildContext context) { return 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], ), ), ], ); }, ), ], ), ), ], ), ); } @override Size get preferredSize => const Size.fromHeight(kToolbarHeight); } // -------- Invoice Sub-Components, unchanged except formatting/const ---------------- 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) => Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ MyText.bodySmall(label, fontWeight: 600), MySpacing.height(4), MyText.bodySmall(value, fontWeight: 500, softWrap: true, maxLines: null), ], ); } 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}) => 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: 12), ListView.separated( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), itemCount: documents.length, separatorBuilder: (_, __) => const SizedBox(height: 8), itemBuilder: (context, index) { final doc = documents[index]; 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 { showAppSnackbar( title: 'Error', message: 'Could not open the 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(6), color: Colors.grey.shade100, ), child: Row( children: [ Icon( doc.contentType.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 _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, ), ), ), ], ); } }