diff --git a/lib/controller/expense/expense_detail_controller.dart b/lib/controller/expense/expense_detail_controller.dart index 4ff0bec..116cc84 100644 --- a/lib/controller/expense/expense_detail_controller.dart +++ b/lib/controller/expense/expense_detail_controller.dart @@ -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 expense = Rx(null); + final Rx expense = Rx(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."; diff --git a/lib/model/expense/expense_detail_model.dart b/lib/model/expense/expense_detail_model.dart new file mode 100644 index 0000000..e056d3c --- /dev/null +++ b/lib/model/expense/expense_detail_model.dart @@ -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 nextStatus; + final bool preApproved; + final String transactionId; + final String description; + final String location; + final List 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 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 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 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 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 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 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 json) { + return ExpenseDocument( + documentId: json['documentId'] ?? '', + fileName: json['fileName'] ?? '', + contentType: json['contentType'] ?? '', + preSignedUrl: json['preSignedUrl'] ?? '', + thumbPreSignedUrl: json['thumbPreSignedUrl'] ?? '', + ); + } +} diff --git a/lib/view/expense/expense_detail_screen.dart b/lib/view/expense/expense_detail_screen.dart index ad1991d..f7204a9 100644 --- a/lib/view/expense/expense_detail_screen.dart +++ b/lib/view/expense/expense_detail_screen.dart @@ -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(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 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 createState() => _ExpandableDescriptionState(); +} + +class _ExpandableDescriptionState extends State { + 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, - ), - ), - ], - ), + ], ); } }