- Updated ReimbursementBottomSheet to use BaseBottomSheet for consistent styling and functionality. - Improved input field decorations and added spacing helpers for better layout. - Simplified the employee selection process and integrated it into the new design. - Refactored ExpenseDetailScreen to utilize controller initialization method. - Enhanced ExpenseFilterBottomSheet with a cleaner structure and improved field handling. - Removed unnecessary wrapper for ExpenseFilterBottomSheet and integrated it directly into the expense screen.
586 lines
20 KiB
Dart
586 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.init(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();
|
|
}
|
|
|
|
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 = NumberFormat.currency(
|
|
locale: 'en_IN',
|
|
symbol: '₹ ',
|
|
decimalDigits: 2,
|
|
).format(expense.amount);
|
|
|
|
// === CHANGE: Add proper bottom padding to always keep content away from device nav bar ===
|
|
return SingleChildScrollView(
|
|
padding: EdgeInsets.fromLTRB(
|
|
8,
|
|
8,
|
|
8,
|
|
16 + MediaQuery.of(context).padding.bottom, // KEY LINE
|
|
),
|
|
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: () {},
|
|
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) {
|
|
Get.snackbar(
|
|
'Success',
|
|
'Expense reimbursed successfully.',
|
|
backgroundColor: Colors.green.withOpacity(0.8),
|
|
colorText: Colors.white,
|
|
);
|
|
await controller.fetchExpenseDetails();
|
|
|
|
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(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();
|
|
} 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,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|