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

617 lines
22 KiB
Dart

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<ProjectController>();
controller.init(expenseId);
final permissionController = Get.find<PermissionController>();
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<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],
),
),
],
);
},
),
],
),
),
],
),
);
}
@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<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: 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<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,
),
),
),
],
);
}
}