marco.pms.mobileapp/lib/view/expense/expense_detail_screen.dart

584 lines
20 KiB
Dart

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';
import 'package:marco/model/expense/reimbursement_bottom_sheet.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<ProjectController>();
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<ProjectController>(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 {
if (expense.status.id ==
'f18c5cfd-7815-4341-8da2-2c2d65778e27') {
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:
() {}, // <-- This is the missing required parameter
onSubmit: ({
required String comment,
required String reimburseTransactionId,
required String reimburseDate,
required String reimburseById,
}) async {
final success = await controller
.updateExpenseStatusWithReimbursement(
expenseId: expense.id,
comment: comment,
reimburseTransactionId: reimburseTransactionId,
reimburseDate: reimburseDate,
reimburseById: reimburseById,
);
if (success) {
Get.snackbar(
'Success',
'Expense reimbursed successfully.',
backgroundColor: Colors.green.withOpacity(0.8),
colorText: Colors.white,
);
await controller.fetchExpenseDetails(expenseId);
return true;
} else {
Get.snackbar(
'Error',
'Failed to reimburse expense.',
backgroundColor: Colors.red.withOpacity(0.8),
colorText: Colors.white,
);
return false;
}
},
),
);
} else {
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<ExpenseDocument> 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<ExpandableDescription> createState() => _ExpandableDescriptionState();
}
class _ExpandableDescriptionState extends State<ExpandableDescription> {
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,
),
),
),
],
);
}
}