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,64 +42,49 @@ 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), automaticallyImplyLeading: false,
child: AppBar( elevation: 1,
backgroundColor: Colors.white, backgroundColor: Colors.white,
elevation: 1, title: Row(
automaticallyImplyLeading: false, children: [
titleSpacing: 0, IconButton(
title: Padding( icon: const Icon(Icons.arrow_back_ios_new,
padding: MySpacing.xy(16, 0), color: Colors.black, size: 20),
child: Row( onPressed: () => Get.offAllNamed('/dashboard/expense-main-page'),
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],
),
),
],
),
);
}),
],
),
),
],
), ),
), 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( body: SafeArea(
@ -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,20 +116,38 @@ 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: Column( child: Center(
crossAxisAlignment: CrossAxisAlignment.start, child: Container(
children: [ constraints: const BoxConstraints(maxWidth: 520),
_ExpenseHeader( child: Card(
title: expense.expensesType.name, shape: RoundedRectangleBorder(
amount: formattedAmount, borderRadius: BorderRadius.circular(10)),
status: expense.status.name, elevation: 3,
statusColor: statusColor, 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) { if (expense == null || expense.nextStatus.isEmpty) {
return const SizedBox(); return const SizedBox();
} }
return SafeArea( return SafeArea(
child: Container( 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), 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: 600,
fontWeight: FontWeight.w600,
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,193 +237,294 @@ 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,
@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, required this.statusColor,
}); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Container( return Row(
width: double.infinity, children: [
padding: const EdgeInsets.all(16), MyText.bodyLarge("Total:", fontWeight: 700),
decoration: BoxDecoration( const Spacer(),
color: Colors.white, MyText.bodyLarge(formattedAmount, fontWeight: 700),
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,
),
),
],
),
),
],
),
); );
} }
} }
// Expense details list class ExpandableDescription extends StatefulWidget {
class _ExpenseDetailsList extends StatelessWidget { final String description;
final ExpenseModel expense; 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final transactionDate = DateTimeUtils.convertUtcToLocal( final isLong = widget.description.length > 100;
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 Container( return Column(
padding: const EdgeInsets.all(16), crossAxisAlignment: CrossAxisAlignment.start,
decoration: BoxDecoration( children: [
color: Colors.white, MyText.bodySmall(
borderRadius: BorderRadius.circular(10), widget.description,
boxShadow: [ maxLines: isExpanded ? null : 2,
BoxShadow( overflow: isExpanded ? TextOverflow.visible : TextOverflow.ellipsis,
color: Colors.black.withOpacity(0.05), fontWeight: 500,
blurRadius: 5, ),
offset: const Offset(0, 2), if (isLong || !isExpanded)
), InkWell(
], onTap: () => setState(() => isExpanded = !isExpanded),
), child: Padding(
child: Column( padding: const EdgeInsets.only(top: 4),
crossAxisAlignment: CrossAxisAlignment.start, child: MyText.labelSmall(
children: [ isExpanded ? 'Show less' : 'Show more',
_DetailRow(title: "Project", value: expense.project.name), fontWeight: 600,
_DetailRow(title: "Expense Type", value: expense.expensesType.name), color: Colors.blue,
_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,
), ),
), ),
), ),
Expanded( ],
flex: 5,
child: Text(
value,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
),
softWrap: true,
),
),
],
),
); );
} }
} }