marco.pms.mobileapp/lib/view/expense/expense_detail_screen.dart
Vaibhav Surve e5b3616245 Refactor expense models and detail screen for improved error handling and data validation
- Enhanced `ExpenseResponse` and `ExpenseData` models to handle null values and provide default values.
- Introduced a new `Filter` class to encapsulate filtering logic for expenses.
- Updated `ExpenseDetailScreen` to utilize a controller for fetching expense details and managing loading states.
- Improved UI responsiveness with loading skeletons and error messages.
- Refactored filter bottom sheet to streamline filter selection and reset functionality.
- Added visual indicators for filter application in the main expense screen.
- Enhanced expense detail display with better formatting and status color handling.
2025-07-28 12:09:13 +05:30

434 lines
14 KiB
Dart

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/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';
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 {
return Color(int.parse(colorCode.replaceFirst('#', '0xff')));
} catch (_) {}
}
switch (status) {
case 'Approval Pending':
return Colors.orange;
case 'Process Pending':
return Colors.blue;
case 'Rejected':
return Colors.red;
case 'Paid':
return Colors.green;
default:
return Colors.black;
}
}
@override
Widget build(BuildContext context) {
final controller = Get.put(ExpenseDetailController());
final projectController = Get.find<ProjectController>();
controller.fetchExpenseDetails(expenseId);
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],
),
),
],
),
);
}),
],
),
),
],
),
),
),
),
body: SafeArea(
child: Obx(() {
if (controller.isLoading.value) {
return _buildLoadingSkeleton();
}
if (controller.errorMessage.isNotEmpty) {
return Center(
child: Text(
controller.errorMessage.value,
style: const TextStyle(color: Colors.red, fontSize: 16),
),
);
}
final expense = controller.expense.value;
if (expense == null) {
return const Center(child: Text("No expense details found."));
}
final statusColor = getStatusColor(
expense.status.name,
colorCode: expense.status.color,
);
final formattedAmount = NumberFormat.currency(
locale: 'en_IN',
symbol: '',
decimalDigits: 2,
).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,
),
const SizedBox(height: 16),
_ExpenseDetailsList(expense: expense),
const SizedBox(height: 100),
],
),
);
}),
),
bottomNavigationBar: Obx(() {
final expense = controller.expense.value;
if (expense == null || expense.nextStatus.isEmpty) {
return const SizedBox();
}
return SafeArea(
child: Container(
color: Colors.white,
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 {
buttonColor =
Color(int.parse(next.color.replaceFirst('#', '0xff')));
} catch (_) {}
}
return ElevatedButton(
style: ElevatedButton.styleFrom(
minimumSize: const Size(100, 40),
padding:
const EdgeInsets.symmetric(vertical: 8, horizontal: 12),
backgroundColor: buttonColor,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(6),
),
),
onPressed: () async {
final success = await controller.updateExpenseStatus(
expense.id, next.id);
if (success) {
Get.snackbar(
'Success',
'Expense moved to ${next.displayName.isNotEmpty ? next.displayName : next.name}',
backgroundColor: Colors.green.withOpacity(0.8),
colorText: Colors.white,
);
await controller.fetchExpenseDetails(expenseId);
} else {
Get.snackbar(
'Error',
'Failed to update status.',
backgroundColor: Colors.red.withOpacity(0.8),
colorText: Colors.white,
);
}
},
child: Text(
next.displayName.isNotEmpty ? next.displayName : next.name,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w600,
fontSize: 14,
),
overflow: TextOverflow.ellipsis,
),
);
}).toList(),
),
),
);
}),
);
}
// Loading skeleton placeholder
Widget _buildLoadingSkeleton() {
return ListView(
padding: const EdgeInsets.all(16),
children: List.generate(5, (index) {
return Container(
margin: const EdgeInsets.only(bottom: 16),
height: 80,
decoration: BoxDecoration(
color: Colors.grey[300],
borderRadius: BorderRadius.circular(10),
),
);
}),
);
}
}
// Expense header card
class _ExpenseHeader extends StatelessWidget {
final String title;
final String amount;
final String status;
final Color statusColor;
const _ExpenseHeader({
required this.title,
required this.amount,
required this.status,
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,
),
),
],
),
),
],
),
);
}
}
// Expense details list
class _ExpenseDetailsList extends StatelessWidget {
final ExpenseModel expense;
const _ExpenseDetailsList({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 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,
),
),
),
Expanded(
flex: 5,
child: Text(
value,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
),
softWrap: true,
),
),
],
),
);
}
}