feat: enhance ExpenseDetailModel and ExpenseDetailScreen with additional fields and improved UI structure

This commit is contained in:
Vaibhav Surve 2025-11-19 17:17:04 +05:30
parent 8edd189479
commit 5dd09869ad
2 changed files with 262 additions and 113 deletions

View File

@ -2,7 +2,7 @@
class ExpenseDetailModel {
final String id;
final Project project;
final ExpensesType expensesType;
final ExpensesType expensesType;
final PaymentMode paymentMode;
final Person paidBy;
final Person createdBy;
@ -13,18 +13,25 @@ class ExpenseDetailModel {
final String createdAt;
final String supplerName;
final double amount;
final double? baseAmount;
final double? taxAmount;
final double? tdsPercentage;
final ExpenseStatus status;
final List<ExpenseStatus> nextStatus;
final List nextStatus;
final bool preApproved;
final String transactionId;
final String description;
final String location;
final Currency? currency;
final List<ExpenseDocument> documents;
final List<ExpenseLog> expenseLogs;
final String? gstNumber;
final int noOfPersons;
final int? noOfPersons;
final bool isActive;
final dynamic expensesReimburse;
final String? expenseUId;
final String? paymentRequestUID;
ExpenseDetailModel({
required this.id,
@ -40,51 +47,87 @@ class ExpenseDetailModel {
required this.createdAt,
required this.supplerName,
required this.amount,
this.baseAmount,
this.taxAmount,
this.tdsPercentage,
required this.status,
required this.nextStatus,
required this.preApproved,
required this.transactionId,
required this.description,
required this.location,
this.currency,
required this.documents,
required this.expenseLogs,
this.gstNumber,
required this.noOfPersons,
this.noOfPersons,
required this.isActive,
this.expensesReimburse,
this.expenseUId,
this.paymentRequestUID,
});
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'])
project: json['project'] != null
? Project.fromJson(json['project'])
: Project.empty(),
expensesType: json['expenseCategory'] != null
? ExpensesType.fromJson(json['expenseCategory'])
: 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(),
reviewedBy: json['reviewedBy'] != null ? Person.fromJson(json['reviewedBy']) : null,
approvedBy: json['approvedBy'] != null ? Person.fromJson(json['approvedBy']) : null,
processedBy: json['processedBy'] != null ? Person.fromJson(json['processedBy']) : null,
paidBy: json['paidBy'] != null
? Person.fromJson(json['paidBy'])
: Person.empty(),
createdBy: json['createdBy'] != null
? Person.fromJson(json['createdBy'])
: Person.empty(),
reviewedBy: json['reviewedBy'] != null
? Person.fromJson(json['reviewedBy'])
: null,
approvedBy: json['approvedBy'] != null
? Person.fromJson(json['approvedBy'])
: null,
processedBy: json['processedBy'] != null
? Person.fromJson(json['processedBy'])
: null,
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() ?? [],
baseAmount: (json['baseAmount'] as num?)?.toDouble(),
taxAmount: (json['taxAmount'] as num?)?.toDouble(),
tdsPercentage: (json['tdsPercentage'] as num?)?.toDouble(),
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() ?? [],
expenseLogs: (json['expenseLogs'] as List?)?.map((e) => ExpenseLog.fromJson(e)).toList() ?? [],
currency:
json['currency'] != null ? Currency.fromJson(json['currency']) : null,
documents: (json['documents'] as List?)
?.map((e) => ExpenseDocument.fromJson(e))
.toList() ??
[],
expenseLogs: (json['expenseLogs'] as List?)
?.map((e) => ExpenseLog.fromJson(e))
.toList() ??
[],
gstNumber: json['gstNumber']?.toString(),
noOfPersons: json['noOfPersons'] ?? 0,
noOfPersons: json['noOfPersons'] != null ? json['noOfPersons'] : null,
isActive: json['isActive'] ?? true,
expensesReimburse: json['expensesReimburse'],
expenseUId: json['expenseUId']?.toString(),
paymentRequestUID: json['paymentRequestUID']?.toString(),
);
}
}
@ -94,20 +137,20 @@ 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? 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,
this.projectAddress,
this.contactPerson,
this.startDate,
this.endDate,
required this.projectStatusId,
});
@ -116,10 +159,10 @@ class Project {
id: json['id'] ?? '',
name: json['name'] ?? '',
shortName: json['shortName'] ?? '',
projectAddress: json['projectAddress'] ?? '',
contactPerson: json['contactPerson'] ?? '',
startDate: json['startDate'] ?? '',
endDate: json['endDate'] ?? '',
projectAddress: json['projectAddress'],
contactPerson: json['contactPerson'],
startDate: json['startDate'],
endDate: json['endDate'],
projectStatusId: json['projectStatusId'] ?? '',
);
}
@ -128,10 +171,6 @@ class Project {
id: '',
name: '',
shortName: '',
projectAddress: '',
contactPerson: '',
startDate: '',
endDate: '',
projectStatusId: '',
);
}
@ -141,12 +180,14 @@ class ExpensesType {
final String id;
final String name;
final bool noOfPersonsRequired;
final bool isAttachmentRequried;
final String description;
ExpensesType({
required this.id,
required this.name,
required this.noOfPersonsRequired,
required this.isAttachmentRequried,
required this.description,
});
@ -155,6 +196,7 @@ class ExpensesType {
id: json['id'] ?? '',
name: json['name'] ?? '',
noOfPersonsRequired: json['noOfPersonsRequired'] ?? false,
isAttachmentRequried: json['isAttachmentRequried'] ?? false,
description: json['description'] ?? '',
);
}
@ -163,6 +205,7 @@ class ExpensesType {
id: '',
name: '',
noOfPersonsRequired: false,
isAttachmentRequried: false,
description: '',
);
}
@ -199,6 +242,7 @@ class Person {
final String id;
final String firstName;
final String lastName;
final String? email;
final String photo;
final String jobRoleId;
final String jobRoleName;
@ -207,6 +251,7 @@ class Person {
required this.id,
required this.firstName,
required this.lastName,
this.email,
required this.photo,
required this.jobRoleId,
required this.jobRoleName,
@ -217,7 +262,8 @@ class Person {
id: json['id'] ?? '',
firstName: json['firstName'] ?? '',
lastName: json['lastName'] ?? '',
photo: json['photo'] is String ? json['photo'] : '',
email: json['email'],
photo: json['photo'] ?? '',
jobRoleId: json['jobRoleId'] ?? '',
jobRoleName: json['jobRoleName'] ?? '',
);
@ -227,6 +273,7 @@ class Person {
id: '',
firstName: '',
lastName: '',
email: null,
photo: '',
jobRoleId: '',
jobRoleName: '',
@ -322,10 +369,39 @@ class ExpenseLog {
factory ExpenseLog.fromJson(Map<String, dynamic> json) {
return ExpenseLog(
id: json['id'] ?? '',
updatedBy: json['updatedBy'] != null ? Person.fromJson(json['updatedBy']) : Person.empty(),
updatedBy: json['updatedBy'] != null
? Person.fromJson(json['updatedBy'])
: Person.empty(),
action: json['action'] ?? '',
updateAt: json['updateAt'] ?? '',
comment: json['comment'] ?? '',
);
}
}
// ---------------- Currency ----------------
class Currency {
final String id;
final String currencyCode;
final String currencyName;
final String symbol;
final bool isActive;
Currency({
required this.id,
required this.currencyCode,
required this.currencyName,
required this.symbol,
required this.isActive,
});
factory Currency.fromJson(Map<String, dynamic> json) {
return Currency(
id: json['id'] ?? '',
currencyCode: json['currencyCode'] ?? '',
currencyName: json['currencyName'] ?? '',
symbol: json['symbol'] ?? '',
isActive: json['isActive'] ?? true,
);
}
}

View File

@ -97,49 +97,93 @@ class _ExpenseDetailScreenState extends State<ExpenseDetailScreen>
final formattedAmount = formatExpenseAmount(expense.amount);
return MyRefreshIndicator(
onRefresh: () async {
await controller.fetchExpenseDetails();
},
child: 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(5)),
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),
InvoiceLogs(logs: expense.expenseLogs),
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,
),
const Divider(height: 30, thickness: 1.2),
],
onRefresh: () async {
await controller.fetchExpenseDetails();
},
child: SingleChildScrollView(
padding: EdgeInsets.fromLTRB(
12, 12, 12, 30 + MediaQuery.of(context).padding.bottom),
child: Center(
child: Container(
constraints: const BoxConstraints(maxWidth: 520),
child: Card(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(5)),
elevation: 3,
child: Padding(
padding: const EdgeInsets.symmetric(
vertical: 14, horizontal: 14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// ---------------- Header & Status ----------------
_InvoiceHeader(expense: expense),
const Divider(height: 30, thickness: 1.2),
// ---------------- Activity Logs ----------------
InvoiceLogs(logs: expense.expenseLogs),
const Divider(height: 30, thickness: 1.2),
// ---------------- Amount & Summary ----------------
Row(
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.bodyMedium('Amount',
fontWeight: 600),
const SizedBox(height: 4),
MyText.bodyLarge(
formattedAmount,
fontWeight: 700,
color: statusColor,
),
],
),
const Spacer(),
// Optional: Pre-approved badge
if (expense.preApproved)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: Colors.green.withOpacity(0.15),
borderRadius: BorderRadius.circular(5),
),
child: MyText.bodySmall(
'Pre-Approved',
fontWeight: 600,
color: Colors.green,
),
),
],
),
const Divider(height: 30, thickness: 1.2),
// ---------------- Parties ----------------
_InvoicePartiesTable(expense: expense),
const Divider(height: 30, thickness: 1.2),
// ---------------- Expense Details ----------------
_InvoiceDetailsTable(expense: expense),
const Divider(height: 30, thickness: 1.2),
// ---------------- Documents ----------------
_InvoiceDocuments(documents: expense.documents),
const Divider(height: 30, thickness: 1.2),
// ---------------- Totals ----------------
_InvoiceTotals(
expense: expense,
formattedAmount: formattedAmount,
statusColor: statusColor,
),
],
),
),
),
),
),
),
),
);
));
}),
),
floatingActionButton: Obx(() {
@ -498,24 +542,43 @@ class _InvoiceHeader extends StatelessWidget {
}
}
class _InvoiceParties extends StatelessWidget {
class _InvoicePartiesTable extends StatelessWidget {
final ExpenseDetailModel expense;
const _InvoiceParties({required this.expense});
const _InvoicePartiesTable({required this.expense});
@override
Widget build(BuildContext context) {
// List of label-value pairs
final parties = [
{'label': 'Project', 'value': expense.project.name},
{
'label': 'Paid By',
'value': '${expense.paidBy.firstName} ${expense.paidBy.lastName}'
},
{'label': 'Supplier', 'value': expense.supplerName},
{
'label': 'Created By',
'value': '${expense.createdBy.firstName} ${expense.createdBy.lastName}'
},
];
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}'),
],
children: parties.map((item) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
MyText.bodySmall('${item['label']}:', fontWeight: 600),
const SizedBox(width: 6),
Expanded(
child: MyText.bodySmall(item['value']!, fontWeight: 500),
),
],
),
);
}).toList(),
);
}
}
@ -523,6 +586,7 @@ class _InvoiceParties extends StatelessWidget {
class _InvoiceDetailsTable extends StatelessWidget {
final ExpenseDetailModel expense;
const _InvoiceDetailsTable({required this.expense});
@override
Widget build(BuildContext context) {
final transactionDate = DateTimeUtils.convertUtcToLocal(
@ -531,36 +595,45 @@ class _InvoiceDetailsTable extends StatelessWidget {
final createdAt = DateTimeUtils.convertUtcToLocal(
expense.createdAt.toString(),
format: 'dd MMM yyyy');
// List of all label-value pairs
final details = [
{'label': 'Expense Type', 'value': expense.expensesType.name},
{'label': 'Payment Mode', 'value': expense.paymentMode.name},
{'label': 'Transaction Date', 'value': transactionDate},
{'label': 'Created At', 'value': createdAt},
{'label': 'Pre-Approved', 'value': expense.preApproved ? 'Yes' : 'No'},
{
'label': 'Description',
'value':
expense.description.trim().isNotEmpty ? expense.description : '-'
},
];
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),
],
children: details.map((item) {
final isDescription = item['label'] == 'Description';
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
crossAxisAlignment: isDescription
? CrossAxisAlignment.start
: CrossAxisAlignment.center,
children: [
MyText.bodySmall('${item['label']}:', fontWeight: 600),
const SizedBox(width: 6),
Expanded(
child: isDescription
? ExpandableDescription(description: item['value']!)
: MyText.bodySmall(item['value']!, fontWeight: 500),
),
],
),
);
}).toList(),
);
}
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 {