Vaibhav_Feature-#768 #59

Closed
vaibhav.surve wants to merge 74 commits from Vaibhav_Feature-#768 into Feature_Expense
3 changed files with 646 additions and 269 deletions
Showing only changes of commit d28332b55d - Show all commits

View File

@ -1,10 +1,11 @@
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:marco/helpers/services/api_service.dart'; import 'package:marco/helpers/services/api_service.dart';
import 'package:marco/helpers/services/app_logger.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 { class ExpenseDetailController extends GetxController {
final Rx<ExpenseModel?> expense = Rx<ExpenseModel?>(null); final Rx<ExpenseDetailModel?> expense = Rx<ExpenseDetailModel?>(null);
final RxBool isLoading = false.obs; final RxBool isLoading = false.obs;
final RxString errorMessage = ''.obs; final RxString errorMessage = ''.obs;
@ -16,10 +17,11 @@ class ExpenseDetailController extends GetxController {
try { try {
logSafe("Fetching expense details for ID: $expenseId"); logSafe("Fetching expense details for ID: $expenseId");
final result = await ApiService.getExpenseDetailsApi(expenseId: expenseId); final result =
await ApiService.getExpenseDetailsApi(expenseId: expenseId);
if (result != null) { if (result != null) {
try { try {
expense.value = ExpenseModel.fromJson(result); expense.value = ExpenseDetailModel.fromJson(result);
logSafe("Expense details loaded successfully: ${expense.value?.id}"); logSafe("Expense details loaded successfully: ${expense.value?.id}");
} catch (e) { } catch (e) {
errorMessage.value = 'Failed to parse expense details: $e'; errorMessage.value = 'Failed to parse expense details: $e';
@ -52,7 +54,7 @@ class ExpenseDetailController extends GetxController {
); );
if (success) { if (success) {
logSafe("Expense status updated successfully."); logSafe("Expense status updated successfully.");
await fetchExpenseDetails(expenseId); // Refresh details await fetchExpenseDetails(expenseId);
return true; return true;
} else { } else {
errorMessage.value = "Failed to update expense status."; 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:get/get.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:marco/controller/expense/expense_detail_controller.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/utils/date_time_utils.dart';
import 'package:marco/helpers/widgets/my_spacing.dart'; import 'package:marco/helpers/widgets/my_spacing.dart';
import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/model/expense/expense_list_model.dart'; import 'package:marco/model/expense/expense_detail_model.dart';
import 'package:marco/controller/project_controller.dart'; import 'package:marco/helpers/widgets/image_viewer_dialog.dart';
import 'package:url_launcher/url_launcher.dart';
class ExpenseDetailScreen extends StatelessWidget { class ExpenseDetailScreen extends StatelessWidget {
final String expenseId; final String expenseId;
const ExpenseDetailScreen({super.key, required this.expenseId}); const ExpenseDetailScreen({super.key, required this.expenseId});
// Status color logic
static Color getStatusColor(String? status, {String? colorCode}) { static Color getStatusColor(String? status, {String? colorCode}) {
if (colorCode != null && colorCode.isNotEmpty) { if (colorCode != null && colorCode.isNotEmpty) {
try { try {
@ -42,42 +42,30 @@ class ExpenseDetailScreen extends StatelessWidget {
return Scaffold( return Scaffold(
backgroundColor: const Color(0xFFF7F7F7), backgroundColor: const Color(0xFFF7F7F7),
appBar: PreferredSize( appBar: AppBar(
preferredSize: const Size.fromHeight(72),
child: AppBar(
backgroundColor: Colors.white,
elevation: 1,
automaticallyImplyLeading: false, automaticallyImplyLeading: false,
titleSpacing: 0, elevation: 1,
title: Padding( backgroundColor: Colors.white,
padding: MySpacing.xy(16, 0), title: Row(
child: Row(
children: [ children: [
IconButton( IconButton(
icon: const Icon(Icons.arrow_back_ios_new, icon: const Icon(Icons.arrow_back_ios_new,
color: Colors.black, size: 20), color: Colors.black, size: 20),
onPressed: () => onPressed: () => Get.offAllNamed('/dashboard/expense-main-page'),
Get.offAllNamed('/dashboard/expense-main-page'),
), ),
MySpacing.width(8), const SizedBox(width: 8),
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [ children: [
MyText.titleLarge( MyText.titleLarge('Expense Details',
'Expense Details', fontWeight: 700, color: Colors.black),
fontWeight: 700,
color: Colors.black,
),
MySpacing.height(2), MySpacing.height(2),
Obx(() { GetBuilder<ProjectController>(builder: (_) {
final projectName = final projectName =
projectController.selectedProject?.name ?? projectController.selectedProject?.name ??
'Select Project'; 'Select Project';
return InkWell( return Row(
onTap: () => Get.toNamed('/project-selector'),
child: Row(
children: [ children: [
const Icon(Icons.work_outline, const Icon(Icons.work_outline,
size: 14, color: Colors.grey), size: 14, color: Colors.grey),
@ -91,7 +79,6 @@ class ExpenseDetailScreen extends StatelessWidget {
), ),
), ),
], ],
),
); );
}), }),
], ],
@ -100,8 +87,6 @@ class ExpenseDetailScreen extends StatelessWidget {
], ],
), ),
), ),
),
),
body: SafeArea( body: SafeArea(
child: Obx(() { child: Obx(() {
if (controller.isLoading.value) { if (controller.isLoading.value) {
@ -109,23 +94,21 @@ class ExpenseDetailScreen extends StatelessWidget {
} }
if (controller.errorMessage.isNotEmpty) { if (controller.errorMessage.isNotEmpty) {
return Center( return Center(
child: Text( child: MyText.bodyMedium(
controller.errorMessage.value, controller.errorMessage.value,
style: const TextStyle(color: Colors.red, fontSize: 16), color: Colors.red,
), ),
); );
} }
final expense = controller.expense.value; final expense = controller.expense.value;
if (expense == null) { if (expense == null) {
return const Center(child: Text("No expense details found.")); return Center(
child: MyText.bodyMedium("No expense details found."));
} }
final statusColor = getStatusColor( final statusColor = getStatusColor(expense.status.name,
expense.status.name, colorCode: expense.status.color);
colorCode: expense.status.color,
);
final formattedAmount = NumberFormat.currency( final formattedAmount = NumberFormat.currency(
locale: 'en_IN', locale: 'en_IN',
symbol: '', symbol: '',
@ -133,21 +116,39 @@ class ExpenseDetailScreen extends StatelessWidget {
).format(expense.amount); ).format(expense.amount);
return SingleChildScrollView( return SingleChildScrollView(
padding: const EdgeInsets.all(16), 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( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
_ExpenseHeader( _InvoiceHeader(expense: expense),
title: expense.expensesType.name, Divider(height: 30, thickness: 1.2),
amount: formattedAmount, _InvoiceParties(expense: expense),
status: expense.status.name, 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, 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) { if (expense == null || expense.nextStatus.isEmpty) {
return const SizedBox(); return const SizedBox();
} }
return SafeArea( return SafeArea(
child: Container( child: Container(
decoration: const BoxDecoration(
color: Colors.white, color: Colors.white,
border: Border(top: BorderSide(color: Color(0x11000000))),
),
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16), padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
child: Wrap( child: Wrap(
alignment: WrapAlignment.center, alignment: WrapAlignment.center,
spacing: 10, spacing: 10,
runSpacing: 10, runSpacing: 10,
children: expense.nextStatus.map((next) { children: expense.nextStatus.map((next) {
Color buttonColor = Colors.red; Color buttonColor = Colors.red;
if (next.color.isNotEmpty) { if (next.color.isNotEmpty) {
try { try {
@ -174,7 +176,6 @@ class ExpenseDetailScreen extends StatelessWidget {
Color(int.parse(next.color.replaceFirst('#', '0xff'))); Color(int.parse(next.color.replaceFirst('#', '0xff')));
} catch (_) {} } catch (_) {}
} }
return ElevatedButton( return ElevatedButton(
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
minimumSize: const Size(100, 40), minimumSize: const Size(100, 40),
@ -182,8 +183,7 @@ class ExpenseDetailScreen extends StatelessWidget {
const EdgeInsets.symmetric(vertical: 8, horizontal: 12), const EdgeInsets.symmetric(vertical: 8, horizontal: 12),
backgroundColor: buttonColor, backgroundColor: buttonColor,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(6), borderRadius: BorderRadius.circular(6)),
),
), ),
onPressed: () async { onPressed: () async {
final success = await controller.updateExpenseStatus( 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, next.displayName.isNotEmpty ? next.displayName : next.name,
style: const TextStyle(
color: Colors.white, color: Colors.white,
fontWeight: FontWeight.w600, fontWeight: 600,
fontSize: 14,
),
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
); );
@ -223,7 +220,6 @@ class ExpenseDetailScreen extends StatelessWidget {
); );
} }
// Loading skeleton placeholder
Widget _buildLoadingSkeleton() { Widget _buildLoadingSkeleton() {
return ListView( return ListView(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
@ -241,148 +237,151 @@ class ExpenseDetailScreen extends StatelessWidget {
} }
} }
// Expense header card // ---------------- INVOICE SUB-COMPONENTS ----------------
class _ExpenseHeader extends StatelessWidget {
final String title;
final String amount;
final String status;
final Color statusColor;
const _ExpenseHeader({ class _InvoiceHeader extends StatelessWidget {
required this.title, final ExpenseDetailModel expense;
required this.amount, const _InvoiceHeader({required this.expense});
required this.status,
required this.statusColor,
});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Container( final dateString = DateTimeUtils.convertUtcToLocal(
width: double.infinity, expense.transactionDate.toString(),
padding: const EdgeInsets.all(16), format: 'dd-MM-yyyy');
decoration: BoxDecoration(
color: Colors.white, final statusColor = ExpenseDetailScreen.getStatusColor(expense.status.name,
borderRadius: BorderRadius.circular(10), colorCode: expense.status.color);
boxShadow: [
BoxShadow( return Column(
color: Colors.black.withOpacity(0.05),
blurRadius: 5,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Row(
title, mainAxisAlignment: MainAxisAlignment.spaceBetween,
style: const TextStyle( children: [
fontSize: 22, Row(
fontWeight: FontWeight.bold, children: [
color: Colors.black, 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),
],
), ),
),
const SizedBox(height: 6),
Text(
amount,
style: const TextStyle(
fontSize: 26,
fontWeight: FontWeight.w700,
color: Colors.black,
),
),
const SizedBox(height: 12),
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration( decoration: BoxDecoration(
color: statusColor.withOpacity(0.15), color: statusColor.withOpacity(0.15),
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(8),
), ),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
child: Row( child: Row(
mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon(Icons.flag, size: 16, color: statusColor), Icon(Icons.flag, size: 16, color: statusColor),
const SizedBox(width: 6), MySpacing.width(4),
Text( MyText.labelSmall(
status, expense.status.name,
style: TextStyle(
color: statusColor, color: statusColor,
fontWeight: FontWeight.w600, fontWeight: 600,
),
), ),
], ],
), ),
), ),
], ],
), )
],
); );
} }
} }
// Expense details list class _InvoiceParties extends StatelessWidget {
class _ExpenseDetailsList extends StatelessWidget { final ExpenseDetailModel expense;
final ExpenseModel expense; const _InvoiceParties({required this.expense});
const _ExpenseDetailsList({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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final transactionDate = DateTimeUtils.convertUtcToLocal( final transactionDate = DateTimeUtils.convertUtcToLocal(
expense.transactionDate.toString(), expense.transactionDate.toString(),
format: 'dd-MM-yyyy hh:mm a', format: 'dd-MM-yyyy hh:mm a');
);
final createdAt = DateTimeUtils.convertUtcToLocal( final createdAt = DateTimeUtils.convertUtcToLocal(
expense.createdAt.toString(), expense.createdAt.toString(),
format: 'dd-MM-yyyy hh:mm a', format: 'dd-MM-yyyy hh:mm a');
);
return Container( return Column(
padding: const EdgeInsets.all(16), crossAxisAlignment: CrossAxisAlignment.start,
decoration: BoxDecoration( children: [
color: Colors.white, _detailItem("Expense Type:", expense.expensesType.name),
borderRadius: BorderRadius.circular(10), _detailItem("Payment Mode:", expense.paymentMode.name),
boxShadow: [ _detailItem("Transaction Date:", transactionDate),
BoxShadow( _detailItem("Created At:", createdAt),
color: Colors.black.withOpacity(0.05), _detailItem("Pre-Approved:", expense.preApproved ? 'Yes' : 'No'),
blurRadius: 5, _detailItem("Description:",
offset: const Offset(0, 2), 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( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
_DetailRow(title: "Project", value: expense.project.name), MyText.bodySmall(
_DetailRow(title: "Expense Type", value: expense.expensesType.name), title,
_DetailRow(title: "Payment Mode", value: expense.paymentMode.name), fontWeight: 600,
_DetailRow(
title: "Paid By",
value: '${expense.paidBy.firstName} ${expense.paidBy.lastName}',
), ),
_DetailRow( MySpacing.height(3),
title: "Created By", isDescription
value: ? ExpandableDescription(description: value)
'${expense.createdBy.firstName} ${expense.createdBy.lastName}', : MyText.bodySmall(
), value,
_DetailRow(title: "Transaction Date", value: transactionDate), fontWeight: 500,
_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",
), ),
], ],
), ),
@ -390,44 +389,142 @@ class _ExpenseDetailsList extends StatelessWidget {
} }
} }
// A single row for expense details class _InvoiceDocuments extends StatelessWidget {
class _DetailRow extends StatelessWidget { final List<ExpenseDocument> documents;
final String title; const _InvoiceDocuments({required this.documents});
final String value;
const _DetailRow({required this.title, required this.value});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Padding( if (documents.isEmpty) {
padding: const EdgeInsets.only(bottom: 12), return MyText.bodyMedium('No Supporting Documents', color: Colors.grey);
child: Row( }
return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Expanded( MyText.bodySmall("Supporting Documents:", fontWeight: 600),
flex: 3, const SizedBox(height: 8),
child: Text( Wrap(
title, spacing: 10,
style: const TextStyle( children: documents.map((doc) {
fontSize: 13, return GestureDetector(
color: Colors.grey, onTap: () async {
fontWeight: FontWeight.w500, 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],
), ),
Expanded( const SizedBox(width: 7),
flex: 5, MyText.labelSmall(
child: Text( doc.fileName,
value,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
),
softWrap: true,
),
), ),
], ],
), ),
),
);
}).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,
),
),
),
],
); );
} }
} }