feat(expense): improve expense submission validation and UI feedback

This commit is contained in:
Vaibhav Surve 2025-07-21 15:24:18 +05:30
parent a7bb24ee29
commit ff01c05a73
4 changed files with 98 additions and 167 deletions

View File

@ -173,6 +173,7 @@ class AddExpenseController extends GetxController {
}
}
// === Submit Expense ===
// === Submit Expense ===
Future<void> submitExpense() async {
if (isSubmitting.value) return; // Prevent multiple taps
@ -180,17 +181,21 @@ class AddExpenseController extends GetxController {
try {
// === Validation ===
if (selectedProject.value.isEmpty ||
selectedExpenseType.value == null ||
selectedPaymentMode.value == null ||
descriptionController.text.isEmpty ||
supplierController.text.isEmpty ||
amountController.text.isEmpty ||
selectedExpenseStatus.value == null ||
attachments.isEmpty) {
List<String> missingFields = [];
if (selectedProject.value.isEmpty) missingFields.add("Project");
if (selectedExpenseType.value == null) missingFields.add("Expense Type");
if (selectedPaymentMode.value == null) missingFields.add("Payment Mode");
if (selectedPaidBy.value == null) missingFields.add("Paid By");
if (amountController.text.isEmpty) missingFields.add("Amount");
if (supplierController.text.isEmpty) missingFields.add("Supplier Name");
if (descriptionController.text.isEmpty) missingFields.add("Description");
if (attachments.isEmpty) missingFields.add("Attachments");
if (missingFields.isNotEmpty) {
showAppSnackbar(
title: "Error",
message: "Please fill all required fields.",
title: "Missing Fields",
message: "Please provide: ${missingFields.join(', ')}.",
type: SnackbarType.error,
);
return;
@ -247,7 +252,6 @@ class AddExpenseController extends GetxController {
supplerName: supplierController.text,
amount: amount,
noOfPersons: 0,
statusId: selectedExpenseStatus.value!.id,
billAttachments: attachmentData,
);

View File

@ -295,7 +295,6 @@ class ApiService {
required String supplerName,
required double amount,
required int noOfPersons,
required String statusId,
required List<Map<String, dynamic>> billAttachments,
}) async {
final payload = {
@ -310,7 +309,6 @@ class ApiService {
"supplerName": supplerName,
"amount": amount,
"noOfPersons": noOfPersons,
"statusId": statusId,
"billAttachments": billAttachments,
};

View File

@ -4,7 +4,6 @@ import 'package:marco/controller/expense/add_expense_controller.dart';
import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/model/expense/payment_types_model.dart';
import 'package:marco/model/expense/expense_type_model.dart';
import 'package:marco/model/expense/expense_status_model.dart';
void showAddExpenseBottomSheet() {
Get.bottomSheet(
@ -23,9 +22,9 @@ class _AddExpenseBottomSheet extends StatefulWidget {
class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> {
final AddExpenseController controller = Get.put(AddExpenseController());
final RxBool isProjectExpanded = false.obs;
void _showEmployeeList(BuildContext context) {
final employees = controller.allEmployees;
showModalBottomSheet(
context: context,
backgroundColor: Colors.white,
@ -84,7 +83,10 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> {
// Project Dropdown
const _SectionTitle(
icon: Icons.work_outline, title: "Project"),
icon: Icons.work_outline,
title: "Project",
requiredField: true,
),
const SizedBox(height: 6),
Obx(() {
return _DropdownTile(
@ -105,6 +107,7 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> {
const _SectionTitle(
icon: Icons.category_outlined,
title: "Expense Type & GST No.",
requiredField: true,
),
const SizedBox(height: 6),
Obx(() {
@ -128,7 +131,10 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> {
// Payment Mode
const _SectionTitle(
icon: Icons.payment, title: "Payment Mode"),
icon: Icons.payment,
title: "Payment Mode",
requiredField: true,
),
const SizedBox(height: 6),
Obx(() {
return _DropdownTile(
@ -143,6 +149,14 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> {
);
}),
const SizedBox(height: 16),
// Paid By
const _SectionTitle(
icon: Icons.person_outline,
title: "Paid By",
requiredField: true,
),
const SizedBox(height: 6),
Obx(() {
final selected = controller.selectedPaidBy.value;
return GestureDetector(
@ -171,28 +185,12 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> {
);
}),
const SizedBox(height: 16),
// Expense Status
const _SectionTitle(
icon: Icons.flag_outlined, title: "Status"),
const SizedBox(height: 6),
Obx(() {
return _DropdownTile(
title: controller.selectedExpenseStatus.value?.name ??
"Select Status",
onTap: () => _showOptionList<ExpenseStatusModel>(
context,
controller.expenseStatuses.toList(),
(s) => s.name,
(val) =>
controller.selectedExpenseStatus.value = val,
),
);
}),
const SizedBox(height: 16),
// Amount
const _SectionTitle(
icon: Icons.currency_rupee, title: "Amount"),
icon: Icons.currency_rupee,
title: "Amount",
requiredField: true,
),
const SizedBox(height: 6),
_CustomTextField(
controller: controller.amountController,
@ -200,10 +198,12 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> {
keyboardType: TextInputType.number,
),
const SizedBox(height: 16),
// Supplier Name
const _SectionTitle(
icon: Icons.store_mall_directory_outlined,
title: "Supplier Name",
requiredField: true,
),
const SizedBox(height: 6),
_CustomTextField(
@ -211,10 +211,12 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> {
hint: "Enter Supplier Name",
),
const SizedBox(height: 16),
// Transaction ID
const _SectionTitle(
icon: Icons.confirmation_number_outlined,
title: "Transaction ID"),
icon: Icons.confirmation_number_outlined,
title: "Transaction ID",
),
const SizedBox(height: 6),
_CustomTextField(
controller: controller.transactionIdController,
@ -256,13 +258,15 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> {
),
),
),
const SizedBox(height: 16),
// Attachments Section
const _SectionTitle(
icon: Icons.attach_file, title: "Attachments"),
icon: Icons.attach_file,
title: "Attachments",
requiredField: true,
),
const SizedBox(height: 6),
Obx(() {
return Wrap(
spacing: 8,
@ -376,6 +380,7 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> {
const _SectionTitle(
icon: Icons.description_outlined,
title: "Description",
requiredField: true,
),
const SizedBox(height: 6),
_CustomTextField(
@ -439,28 +444,6 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> {
),
);
}),
// Project Selection List
Obx(() {
if (!isProjectExpanded.value) return const SizedBox.shrink();
return Positioned(
top: 110,
left: 16,
right: 16,
child: Material(
elevation: 4,
borderRadius: BorderRadius.circular(12),
child: Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
),
padding: const EdgeInsets.all(10),
child: _buildProjectSelectionList(),
),
),
);
}),
],
),
),
@ -468,44 +451,6 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> {
);
}
Widget _buildProjectSelectionList() {
return ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 300),
child: ListView.builder(
shrinkWrap: true,
itemCount: controller.globalProjects.length,
itemBuilder: (context, index) {
final project = controller.globalProjects[index];
final isSelected = project == controller.selectedProject.value;
return RadioListTile<String>(
value: project,
groupValue: controller.selectedProject.value,
onChanged: (val) {
controller.selectedProject.value = val!;
isProjectExpanded.value = false;
},
title: Text(
project,
style: TextStyle(
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
color: isSelected ? Colors.blueAccent : Colors.black87,
),
),
activeColor: Colors.blueAccent,
tileColor: isSelected
? Colors.blueAccent.withOpacity(0.1)
: Colors.transparent,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(6),
),
visualDensity: const VisualDensity(vertical: -4),
);
},
),
);
}
Future<void> _showOptionList<T>(
BuildContext context,
List<T> options,
@ -554,16 +499,38 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> {
class _SectionTitle extends StatelessWidget {
final IconData icon;
final String title;
const _SectionTitle({required this.icon, required this.title});
final bool requiredField;
const _SectionTitle({
required this.icon,
required this.title,
this.requiredField = false,
});
@override
Widget build(BuildContext context) {
final color = Colors.grey[700];
return Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Icon(icon, color: color, size: 18),
const SizedBox(width: 8),
MyText.bodyMedium(title, fontWeight: 600),
RichText(
text: TextSpan(
style: DefaultTextStyle.of(context).style.copyWith(
fontWeight: FontWeight.w600,
color: Colors.black87,
),
children: [
TextSpan(text: title),
if (requiredField)
const TextSpan(
text: ' *',
style: TextStyle(color: Colors.red),
),
],
),
),
],
);
}

View File

@ -51,18 +51,17 @@ class _ExpenseMainScreenState extends State<ExpenseMainScreen> {
if (expenseController.errorMessage.isNotEmpty) {
return Center(
child: Text(
child: MyText.bodyMedium(
expenseController.errorMessage.value,
style: const TextStyle(color: Colors.red),
color: Colors.red,
),
);
}
if (expenseController.expenses.isEmpty) {
return const Center(child: Text("No expenses found."));
return Center(child: MyText.bodyMedium("No expenses found."));
}
// Apply search filter
final filteredList =
expenseController.expenses.where((expense) {
final query = searchQuery.value.toLowerCase();
@ -76,7 +75,6 @@ class _ExpenseMainScreenState extends State<ExpenseMainScreen> {
filteredList.sort(
(a, b) => b.transactionDate.compareTo(a.transactionDate));
// Split into current month and history
final now = DateTime.now();
final currentMonthList = filteredList
.where((e) =>
@ -117,23 +115,23 @@ class _ExpenseMainScreenState extends State<ExpenseMainScreen> {
child: Wrap(
runSpacing: 10,
children: [
const Text(
MyText.bodyLarge(
'Filter Expenses',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
fontWeight: 700,
),
ListTile(
leading: const Icon(Icons.date_range),
title: const Text('Date Range'),
title: MyText.bodyMedium('Date Range'),
onTap: () {},
),
ListTile(
leading: const Icon(Icons.work_outline),
title: const Text('Project'),
title: MyText.bodyMedium('Project'),
onTap: () {},
),
ListTile(
leading: const Icon(Icons.check_circle_outline),
title: const Text('Status'),
title: MyText.bodyMedium('Status'),
onTap: () {},
),
],
@ -264,7 +262,7 @@ class _SearchAndFilter extends StatelessWidget {
MySpacing.width(8),
IconButton(
icon: const Icon(Icons.tune, color: Colors.black),
onPressed: null,
onPressed: null, // Disabled as per request
),
],
),
@ -349,13 +347,11 @@ class _ToggleButton extends StatelessWidget {
Icon(icon,
size: 16, color: selected ? Colors.white : Colors.grey),
const SizedBox(width: 6),
Text(
MyText.bodyMedium(
label,
style: TextStyle(
color: selected ? Colors.white : Colors.grey,
fontWeight: FontWeight.w600,
fontSize: 13,
),
color: selected ? Colors.white : Colors.grey,
fontWeight: 600,
fontSize: 13,
),
],
),
@ -371,45 +367,27 @@ class _ExpenseList extends StatelessWidget {
const _ExpenseList({required this.expenseList});
static Color _getStatusColor(String status) {
switch (status) {
case 'Requested':
return Colors.blue;
case 'Review':
return Colors.orange;
case 'Approved':
return Colors.green;
case 'Paid':
return Colors.purple;
case 'Closed':
return Colors.grey;
default:
return Colors.black;
}
}
@override
Widget build(BuildContext context) {
if (expenseList.isEmpty) {
return const Center(child: Text('No expenses found.'));
return Center(child: MyText.bodyMedium('No expenses found.'));
}
return ListView.separated(
padding: const EdgeInsets.fromLTRB(12, 12, 12, 80),
padding: const EdgeInsets.fromLTRB(12, 12, 12, 80),
itemCount: expenseList.length,
separatorBuilder: (_, __) =>
Divider(color: Colors.grey.shade300, height: 20),
itemBuilder: (context, index) {
final expense = expenseList[index];
final statusColor = _getStatusColor(expense.status.name);
// Convert UTC date to local formatted string
final formattedDate = DateTimeUtils.convertUtcToLocal(
expense.transactionDate.toIso8601String(),
format: 'dd MMM yyyy, hh:mm a',
);
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => Get.to(
() => const ExpenseDetailScreen(),
arguments: {'expense': expense},
@ -419,51 +397,35 @@ class _ExpenseList extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Title + Amount row
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
const Icon(Icons.receipt_long,
size: 20, color: Colors.red),
const SizedBox(width: 8),
Text(
MyText.bodyMedium(
expense.expensesType.name,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
fontWeight: 700,
),
],
),
Text(
MyText.bodyMedium(
'${expense.amount.toStringAsFixed(2)}',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Colors.red,
),
fontWeight: 600,
),
],
),
const SizedBox(height: 6),
// Date + Status
Row(
children: [
Text(
MyText.bodySmall(
formattedDate,
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
color: Colors.grey[600],
),
const Spacer(),
Text(
MyText.bodySmall(
expense.status.name,
style: TextStyle(
color: statusColor,
fontWeight: FontWeight.bold,
fontSize: 12,
),
fontWeight: 600,
color: Colors.black,
),
],
),