feat: implement ExpenseDetailModel and update ExpenseDetailController and Screen for improved expense detail handling
This commit is contained in:
parent
98836f8157
commit
d28332b55d
@ -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.";
|
||||
|
278
lib/model/expense/expense_detail_model.dart
Normal file
278
lib/model/expense/expense_detail_model.dart
Normal 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'] ?? '',
|
||||
);
|
||||
}
|
||||
}
|
@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user