feat: implement ExpenseDetailModel and update ExpenseDetailController and Screen for improved expense detail handling

This commit is contained in:
Vaibhav Surve 2025-07-30 16:45:21 +05:30
parent 98836f8157
commit d28332b55d
3 changed files with 646 additions and 269 deletions

View File

@ -1,10 +1,11 @@
import 'package:get/get.dart';
import 'package:marco/helpers/services/api_service.dart';
import 'package:marco/helpers/services/app_logger.dart';
import 'package:marco/model/expense/expense_list_model.dart';
import 'package:marco/model/expense/expense_detail_model.dart';
class ExpenseDetailController extends GetxController {
final Rx<ExpenseModel?> expense = Rx<ExpenseModel?>(null);
final Rx<ExpenseDetailModel?> expense = Rx<ExpenseDetailModel?>(null);
final RxBool isLoading = false.obs;
final RxString errorMessage = ''.obs;
@ -16,10 +17,11 @@ class ExpenseDetailController extends GetxController {
try {
logSafe("Fetching expense details for ID: $expenseId");
final result = await ApiService.getExpenseDetailsApi(expenseId: expenseId);
final result =
await ApiService.getExpenseDetailsApi(expenseId: expenseId);
if (result != null) {
try {
expense.value = ExpenseModel.fromJson(result);
expense.value = ExpenseDetailModel.fromJson(result);
logSafe("Expense details loaded successfully: ${expense.value?.id}");
} catch (e) {
errorMessage.value = 'Failed to parse expense details: $e';
@ -52,7 +54,7 @@ class ExpenseDetailController extends GetxController {
);
if (success) {
logSafe("Expense status updated successfully.");
await fetchExpenseDetails(expenseId); // Refresh details
await fetchExpenseDetails(expenseId);
return true;
} else {
errorMessage.value = "Failed to update expense status.";

View File

@ -0,0 +1,278 @@
class ExpenseDetailModel {
final String id;
final Project project;
final ExpensesType expensesType;
final PaymentMode paymentMode;
final Person paidBy;
final Person createdBy;
final String transactionDate;
final String createdAt;
final String supplerName;
final double amount;
final ExpenseStatus status;
final List<ExpenseStatus> nextStatus;
final bool preApproved;
final String transactionId;
final String description;
final String location;
final List<ExpenseDocument> documents;
final String? gstNumber;
final int noOfPersons;
final bool isActive;
ExpenseDetailModel({
required this.id,
required this.project,
required this.expensesType,
required this.paymentMode,
required this.paidBy,
required this.createdBy,
required this.transactionDate,
required this.createdAt,
required this.supplerName,
required this.amount,
required this.status,
required this.nextStatus,
required this.preApproved,
required this.transactionId,
required this.description,
required this.location,
required this.documents,
this.gstNumber,
required this.noOfPersons,
required this.isActive,
});
factory ExpenseDetailModel.fromJson(Map<String, dynamic> json) {
return ExpenseDetailModel(
id: json['id'] ?? '',
project: json['project'] != null ? Project.fromJson(json['project']) : Project.empty(),
expensesType: json['expensesType'] != null ? ExpensesType.fromJson(json['expensesType']) : ExpensesType.empty(),
paymentMode: json['paymentMode'] != null ? PaymentMode.fromJson(json['paymentMode']) : PaymentMode.empty(),
paidBy: json['paidBy'] != null ? Person.fromJson(json['paidBy']) : Person.empty(),
createdBy: json['createdBy'] != null ? Person.fromJson(json['createdBy']) : Person.empty(),
transactionDate: json['transactionDate'] ?? '',
createdAt: json['createdAt'] ?? '',
supplerName: json['supplerName'] ?? '',
amount: (json['amount'] as num?)?.toDouble() ?? 0.0,
status: json['status'] != null ? ExpenseStatus.fromJson(json['status']) : ExpenseStatus.empty(),
nextStatus: (json['nextStatus'] as List?)?.map((e) => ExpenseStatus.fromJson(e)).toList() ?? [],
preApproved: json['preApproved'] ?? false,
transactionId: json['transactionId'] ?? '',
description: json['description'] ?? '',
location: json['location'] ?? '',
documents: (json['documents'] as List?)?.map((e) => ExpenseDocument.fromJson(e)).toList() ?? [],
gstNumber: json['gstNumber']?.toString(),
noOfPersons: json['noOfPersons'] ?? 0,
isActive: json['isActive'] ?? true,
);
}
}
class Project {
final String id;
final String name;
final String shortName;
final String projectAddress;
final String contactPerson;
final String startDate;
final String endDate;
final String projectStatusId;
Project({
required this.id,
required this.name,
required this.shortName,
required this.projectAddress,
required this.contactPerson,
required this.startDate,
required this.endDate,
required this.projectStatusId,
});
factory Project.fromJson(Map<String, dynamic> json) {
return Project(
id: json['id'] ?? '',
name: json['name'] ?? '',
shortName: json['shortName'] ?? '',
projectAddress: json['projectAddress'] ?? '',
contactPerson: json['contactPerson'] ?? '',
startDate: json['startDate'] ?? '',
endDate: json['endDate'] ?? '',
projectStatusId: json['projectStatusId'] ?? '',
);
}
factory Project.empty() => Project(
id: '',
name: '',
shortName: '',
projectAddress: '',
contactPerson: '',
startDate: '',
endDate: '',
projectStatusId: '',
);
}
class ExpensesType {
final String id;
final String name;
final bool noOfPersonsRequired;
final String description;
ExpensesType({
required this.id,
required this.name,
required this.noOfPersonsRequired,
required this.description,
});
factory ExpensesType.fromJson(Map<String, dynamic> json) {
return ExpensesType(
id: json['id'] ?? '',
name: json['name'] ?? '',
noOfPersonsRequired: json['noOfPersonsRequired'] ?? false,
description: json['description'] ?? '',
);
}
factory ExpensesType.empty() => ExpensesType(
id: '',
name: '',
noOfPersonsRequired: false,
description: '',
);
}
class PaymentMode {
final String id;
final String name;
final String description;
PaymentMode({
required this.id,
required this.name,
required this.description,
});
factory PaymentMode.fromJson(Map<String, dynamic> json) {
return PaymentMode(
id: json['id'] ?? '',
name: json['name'] ?? '',
description: json['description'] ?? '',
);
}
factory PaymentMode.empty() => PaymentMode(
id: '',
name: '',
description: '',
);
}
class Person {
final String id;
final String firstName;
final String lastName;
final String photo;
final String jobRoleId;
final String jobRoleName;
Person({
required this.id,
required this.firstName,
required this.lastName,
required this.photo,
required this.jobRoleId,
required this.jobRoleName,
});
factory Person.fromJson(Map<String, dynamic> json) {
return Person(
id: json['id'] ?? '',
firstName: json['firstName'] ?? '',
lastName: json['lastName'] ?? '',
photo: json['photo'] is String ? json['photo'] : '',
jobRoleId: json['jobRoleId'] ?? '',
jobRoleName: json['jobRoleName'] ?? '',
);
}
factory Person.empty() => Person(
id: '',
firstName: '',
lastName: '',
photo: '',
jobRoleId: '',
jobRoleName: '',
);
}
class ExpenseStatus {
final String id;
final String name;
final String displayName;
final String description;
final String? permissionIds;
final String color;
final bool isSystem;
ExpenseStatus({
required this.id,
required this.name,
required this.displayName,
required this.description,
required this.permissionIds,
required this.color,
required this.isSystem,
});
factory ExpenseStatus.fromJson(Map<String, dynamic> json) {
return ExpenseStatus(
id: json['id'] ?? '',
name: json['name'] ?? '',
displayName: json['displayName'] ?? '',
description: json['description'] ?? '',
permissionIds: json['permissionIds']?.toString(),
color: json['color'] ?? '',
isSystem: json['isSystem'] ?? false,
);
}
factory ExpenseStatus.empty() => ExpenseStatus(
id: '',
name: '',
displayName: '',
description: '',
permissionIds: null,
color: '',
isSystem: false,
);
}
class ExpenseDocument {
final String documentId;
final String fileName;
final String contentType;
final String preSignedUrl;
final String thumbPreSignedUrl;
ExpenseDocument({
required this.documentId,
required this.fileName,
required this.contentType,
required this.preSignedUrl,
required this.thumbPreSignedUrl,
});
factory ExpenseDocument.fromJson(Map<String, dynamic> json) {
return ExpenseDocument(
documentId: json['documentId'] ?? '',
fileName: json['fileName'] ?? '',
contentType: json['contentType'] ?? '',
preSignedUrl: json['preSignedUrl'] ?? '',
thumbPreSignedUrl: json['thumbPreSignedUrl'] ?? '',
);
}
}

View File

@ -2,18 +2,18 @@ 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_list_model.dart';
import 'package:marco/controller/project_controller.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});
// Status color logic
static Color getStatusColor(String? status, {String? colorCode}) {
if (colorCode != null && colorCode.isNotEmpty) {
try {
@ -42,64 +42,49 @@ class ExpenseDetailScreen extends StatelessWidget {
return Scaffold(
backgroundColor: const Color(0xFFF7F7F7),
appBar: PreferredSize(
preferredSize: const Size.fromHeight(72),
child: AppBar(
backgroundColor: Colors.white,
elevation: 1,
automaticallyImplyLeading: false,
titleSpacing: 0,
title: Padding(
padding: MySpacing.xy(16, 0),
child: Row(
children: [
IconButton(
icon: const Icon(Icons.arrow_back_ios_new,
color: Colors.black, size: 20),
onPressed: () =>
Get.offAllNamed('/dashboard/expense-main-page'),
),
MySpacing.width(8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
MyText.titleLarge(
'Expense Details',
fontWeight: 700,
color: Colors.black,
),
MySpacing.height(2),
Obx(() {
final projectName =
projectController.selectedProject?.name ??
'Select Project';
return InkWell(
onTap: () => Get.toNamed('/project-selector'),
child: 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],
),
),
],
),
);
}),
],
),
),
],
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(
@ -109,23 +94,21 @@ class ExpenseDetailScreen extends StatelessWidget {
}
if (controller.errorMessage.isNotEmpty) {
return Center(
child: Text(
child: MyText.bodyMedium(
controller.errorMessage.value,
style: const TextStyle(color: Colors.red, fontSize: 16),
color: Colors.red,
),
);
}
final expense = controller.expense.value;
if (expense == null) {
return const Center(child: Text("No expense details found."));
return Center(
child: MyText.bodyMedium("No expense details found."));
}
final statusColor = getStatusColor(
expense.status.name,
colorCode: expense.status.color,
);
final statusColor = getStatusColor(expense.status.name,
colorCode: expense.status.color);
final formattedAmount = NumberFormat.currency(
locale: 'en_IN',
symbol: '',
@ -133,20 +116,38 @@ class ExpenseDetailScreen extends StatelessWidget {
).format(expense.amount);
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_ExpenseHeader(
title: expense.expensesType.name,
amount: formattedAmount,
status: expense.status.name,
statusColor: statusColor,
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,
),
],
),
),
),
const SizedBox(height: 16),
_ExpenseDetailsList(expense: expense),
const SizedBox(height: 100),
],
),
),
);
}),
@ -156,17 +157,18 @@ class ExpenseDetailScreen extends StatelessWidget {
if (expense == null || expense.nextStatus.isEmpty) {
return const SizedBox();
}
return SafeArea(
child: Container(
color: Colors.white,
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 {
@ -174,7 +176,6 @@ class ExpenseDetailScreen extends StatelessWidget {
Color(int.parse(next.color.replaceFirst('#', '0xff')));
} catch (_) {}
}
return ElevatedButton(
style: ElevatedButton.styleFrom(
minimumSize: const Size(100, 40),
@ -182,8 +183,7 @@ class ExpenseDetailScreen extends StatelessWidget {
const EdgeInsets.symmetric(vertical: 8, horizontal: 12),
backgroundColor: buttonColor,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(6),
),
borderRadius: BorderRadius.circular(6)),
),
onPressed: () async {
final success = await controller.updateExpenseStatus(
@ -205,13 +205,10 @@ class ExpenseDetailScreen extends StatelessWidget {
);
}
},
child: Text(
child: MyText.labelMedium(
next.displayName.isNotEmpty ? next.displayName : next.name,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w600,
fontSize: 14,
),
color: Colors.white,
fontWeight: 600,
overflow: TextOverflow.ellipsis,
),
);
@ -223,7 +220,6 @@ class ExpenseDetailScreen extends StatelessWidget {
);
}
// Loading skeleton placeholder
Widget _buildLoadingSkeleton() {
return ListView(
padding: const EdgeInsets.all(16),
@ -241,193 +237,294 @@ class ExpenseDetailScreen extends StatelessWidget {
}
}
// Expense header card
class _ExpenseHeader extends StatelessWidget {
final String title;
final String amount;
final String status;
final Color statusColor;
// ---------------- INVOICE SUB-COMPONENTS ----------------
const _ExpenseHeader({
required this.title,
required this.amount,
required this.status,
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 Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(10),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 5,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,
color: Colors.black,
),
),
const SizedBox(height: 6),
Text(
amount,
style: const TextStyle(
fontSize: 26,
fontWeight: FontWeight.w700,
color: Colors.black,
),
),
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: statusColor.withOpacity(0.15),
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.flag, size: 16, color: statusColor),
const SizedBox(width: 6),
Text(
status,
style: TextStyle(
color: statusColor,
fontWeight: FontWeight.w600,
),
),
],
),
),
],
),
return Row(
children: [
MyText.bodyLarge("Total:", fontWeight: 700),
const Spacer(),
MyText.bodyLarge(formattedAmount, fontWeight: 700),
],
);
}
}
// Expense details list
class _ExpenseDetailsList extends StatelessWidget {
final ExpenseModel expense;
class ExpandableDescription extends StatefulWidget {
final String description;
const ExpandableDescription({super.key, required this.description});
const _ExpenseDetailsList({required this.expense});
@override
State<ExpandableDescription> createState() => _ExpandableDescriptionState();
}
class _ExpandableDescriptionState extends State<ExpandableDescription> {
bool isExpanded = false;
@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',
);
final isLong = widget.description.length > 100;
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(10),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 5,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_DetailRow(title: "Project", value: expense.project.name),
_DetailRow(title: "Expense Type", value: expense.expensesType.name),
_DetailRow(title: "Payment Mode", value: expense.paymentMode.name),
_DetailRow(
title: "Paid By",
value: '${expense.paidBy.firstName} ${expense.paidBy.lastName}',
),
_DetailRow(
title: "Created By",
value:
'${expense.createdBy.firstName} ${expense.createdBy.lastName}',
),
_DetailRow(title: "Transaction Date", value: transactionDate),
_DetailRow(title: "Created At", value: createdAt),
_DetailRow(title: "Supplier Name", value: expense.supplerName),
_DetailRow(
title: "Amount",
value: NumberFormat.currency(
locale: 'en_IN',
symbol: '',
decimalDigits: 2,
).format(expense.amount),
),
_DetailRow(title: "Status", value: expense.status.name),
_DetailRow(
title: "Next Status",
value: expense.nextStatus.map((e) => e.name).join(", "),
),
_DetailRow(
title: "Pre-Approved",
value: expense.preApproved ? "Yes" : "No",
),
],
),
);
}
}
// A single row for expense details
class _DetailRow extends StatelessWidget {
final String title;
final String value;
const _DetailRow({required this.title, required this.value});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
flex: 3,
child: Text(
title,
style: const TextStyle(
fontSize: 13,
color: Colors.grey,
fontWeight: FontWeight.w500,
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,
),
),
),
Expanded(
flex: 5,
child: Text(
value,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
),
softWrap: true,
),
),
],
),
],
);
}
}