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

View File

@ -295,7 +295,6 @@ class ApiService {
required String supplerName, required String supplerName,
required double amount, required double amount,
required int noOfPersons, required int noOfPersons,
required String statusId,
required List<Map<String, dynamic>> billAttachments, required List<Map<String, dynamic>> billAttachments,
}) async { }) async {
final payload = { final payload = {
@ -310,7 +309,6 @@ class ApiService {
"supplerName": supplerName, "supplerName": supplerName,
"amount": amount, "amount": amount,
"noOfPersons": noOfPersons, "noOfPersons": noOfPersons,
"statusId": statusId,
"billAttachments": billAttachments, "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/helpers/widgets/my_text.dart';
import 'package:marco/model/expense/payment_types_model.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_type_model.dart';
import 'package:marco/model/expense/expense_status_model.dart';
void showAddExpenseBottomSheet() { void showAddExpenseBottomSheet() {
Get.bottomSheet( Get.bottomSheet(
@ -23,9 +22,9 @@ class _AddExpenseBottomSheet extends StatefulWidget {
class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> { class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> {
final AddExpenseController controller = Get.put(AddExpenseController()); final AddExpenseController controller = Get.put(AddExpenseController());
final RxBool isProjectExpanded = false.obs; final RxBool isProjectExpanded = false.obs;
void _showEmployeeList(BuildContext context) { void _showEmployeeList(BuildContext context) {
final employees = controller.allEmployees; final employees = controller.allEmployees;
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
backgroundColor: Colors.white, backgroundColor: Colors.white,
@ -84,7 +83,10 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> {
// Project Dropdown // Project Dropdown
const _SectionTitle( const _SectionTitle(
icon: Icons.work_outline, title: "Project"), icon: Icons.work_outline,
title: "Project",
requiredField: true,
),
const SizedBox(height: 6), const SizedBox(height: 6),
Obx(() { Obx(() {
return _DropdownTile( return _DropdownTile(
@ -105,6 +107,7 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> {
const _SectionTitle( const _SectionTitle(
icon: Icons.category_outlined, icon: Icons.category_outlined,
title: "Expense Type & GST No.", title: "Expense Type & GST No.",
requiredField: true,
), ),
const SizedBox(height: 6), const SizedBox(height: 6),
Obx(() { Obx(() {
@ -128,7 +131,10 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> {
// Payment Mode // Payment Mode
const _SectionTitle( const _SectionTitle(
icon: Icons.payment, title: "Payment Mode"), icon: Icons.payment,
title: "Payment Mode",
requiredField: true,
),
const SizedBox(height: 6), const SizedBox(height: 6),
Obx(() { Obx(() {
return _DropdownTile( return _DropdownTile(
@ -143,6 +149,14 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> {
); );
}), }),
const SizedBox(height: 16), const SizedBox(height: 16),
// Paid By
const _SectionTitle(
icon: Icons.person_outline,
title: "Paid By",
requiredField: true,
),
const SizedBox(height: 6),
Obx(() { Obx(() {
final selected = controller.selectedPaidBy.value; final selected = controller.selectedPaidBy.value;
return GestureDetector( return GestureDetector(
@ -171,28 +185,12 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> {
); );
}), }),
const SizedBox(height: 16), 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 // Amount
const _SectionTitle( const _SectionTitle(
icon: Icons.currency_rupee, title: "Amount"), icon: Icons.currency_rupee,
title: "Amount",
requiredField: true,
),
const SizedBox(height: 6), const SizedBox(height: 6),
_CustomTextField( _CustomTextField(
controller: controller.amountController, controller: controller.amountController,
@ -200,10 +198,12 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> {
keyboardType: TextInputType.number, keyboardType: TextInputType.number,
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
// Supplier Name // Supplier Name
const _SectionTitle( const _SectionTitle(
icon: Icons.store_mall_directory_outlined, icon: Icons.store_mall_directory_outlined,
title: "Supplier Name", title: "Supplier Name",
requiredField: true,
), ),
const SizedBox(height: 6), const SizedBox(height: 6),
_CustomTextField( _CustomTextField(
@ -211,10 +211,12 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> {
hint: "Enter Supplier Name", hint: "Enter Supplier Name",
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
// Transaction ID // Transaction ID
const _SectionTitle( const _SectionTitle(
icon: Icons.confirmation_number_outlined, icon: Icons.confirmation_number_outlined,
title: "Transaction ID"), title: "Transaction ID",
),
const SizedBox(height: 6), const SizedBox(height: 6),
_CustomTextField( _CustomTextField(
controller: controller.transactionIdController, controller: controller.transactionIdController,
@ -256,13 +258,15 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> {
), ),
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
// Attachments Section // Attachments Section
const _SectionTitle( const _SectionTitle(
icon: Icons.attach_file, title: "Attachments"), icon: Icons.attach_file,
title: "Attachments",
requiredField: true,
),
const SizedBox(height: 6), const SizedBox(height: 6),
Obx(() { Obx(() {
return Wrap( return Wrap(
spacing: 8, spacing: 8,
@ -376,6 +380,7 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> {
const _SectionTitle( const _SectionTitle(
icon: Icons.description_outlined, icon: Icons.description_outlined,
title: "Description", title: "Description",
requiredField: true,
), ),
const SizedBox(height: 6), const SizedBox(height: 6),
_CustomTextField( _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>( Future<void> _showOptionList<T>(
BuildContext context, BuildContext context,
List<T> options, List<T> options,
@ -554,16 +499,38 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> {
class _SectionTitle extends StatelessWidget { class _SectionTitle extends StatelessWidget {
final IconData icon; final IconData icon;
final String title; 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final color = Colors.grey[700]; final color = Colors.grey[700];
return Row( return Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
Icon(icon, color: color, size: 18), Icon(icon, color: color, size: 18),
const SizedBox(width: 8), 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) { if (expenseController.errorMessage.isNotEmpty) {
return Center( return Center(
child: Text( child: MyText.bodyMedium(
expenseController.errorMessage.value, expenseController.errorMessage.value,
style: const TextStyle(color: Colors.red), color: Colors.red,
), ),
); );
} }
if (expenseController.expenses.isEmpty) { 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 = final filteredList =
expenseController.expenses.where((expense) { expenseController.expenses.where((expense) {
final query = searchQuery.value.toLowerCase(); final query = searchQuery.value.toLowerCase();
@ -76,7 +75,6 @@ class _ExpenseMainScreenState extends State<ExpenseMainScreen> {
filteredList.sort( filteredList.sort(
(a, b) => b.transactionDate.compareTo(a.transactionDate)); (a, b) => b.transactionDate.compareTo(a.transactionDate));
// Split into current month and history
final now = DateTime.now(); final now = DateTime.now();
final currentMonthList = filteredList final currentMonthList = filteredList
.where((e) => .where((e) =>
@ -117,23 +115,23 @@ class _ExpenseMainScreenState extends State<ExpenseMainScreen> {
child: Wrap( child: Wrap(
runSpacing: 10, runSpacing: 10,
children: [ children: [
const Text( MyText.bodyLarge(
'Filter Expenses', 'Filter Expenses',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16), fontWeight: 700,
), ),
ListTile( ListTile(
leading: const Icon(Icons.date_range), leading: const Icon(Icons.date_range),
title: const Text('Date Range'), title: MyText.bodyMedium('Date Range'),
onTap: () {}, onTap: () {},
), ),
ListTile( ListTile(
leading: const Icon(Icons.work_outline), leading: const Icon(Icons.work_outline),
title: const Text('Project'), title: MyText.bodyMedium('Project'),
onTap: () {}, onTap: () {},
), ),
ListTile( ListTile(
leading: const Icon(Icons.check_circle_outline), leading: const Icon(Icons.check_circle_outline),
title: const Text('Status'), title: MyText.bodyMedium('Status'),
onTap: () {}, onTap: () {},
), ),
], ],
@ -264,7 +262,7 @@ class _SearchAndFilter extends StatelessWidget {
MySpacing.width(8), MySpacing.width(8),
IconButton( IconButton(
icon: const Icon(Icons.tune, color: Colors.black), 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, Icon(icon,
size: 16, color: selected ? Colors.white : Colors.grey), size: 16, color: selected ? Colors.white : Colors.grey),
const SizedBox(width: 6), const SizedBox(width: 6),
Text( MyText.bodyMedium(
label, label,
style: TextStyle( color: selected ? Colors.white : Colors.grey,
color: selected ? Colors.white : Colors.grey, fontWeight: 600,
fontWeight: FontWeight.w600, fontSize: 13,
fontSize: 13,
),
), ),
], ],
), ),
@ -371,45 +367,27 @@ class _ExpenseList extends StatelessWidget {
const _ExpenseList({required this.expenseList}); 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (expenseList.isEmpty) { if (expenseList.isEmpty) {
return const Center(child: Text('No expenses found.')); return Center(child: MyText.bodyMedium('No expenses found.'));
} }
return ListView.separated( return ListView.separated(
padding: const EdgeInsets.fromLTRB(12, 12, 12, 80), padding: const EdgeInsets.fromLTRB(12, 12, 12, 80),
itemCount: expenseList.length, itemCount: expenseList.length,
separatorBuilder: (_, __) => separatorBuilder: (_, __) =>
Divider(color: Colors.grey.shade300, height: 20), Divider(color: Colors.grey.shade300, height: 20),
itemBuilder: (context, index) { itemBuilder: (context, index) {
final expense = expenseList[index]; final expense = expenseList[index];
final statusColor = _getStatusColor(expense.status.name);
// Convert UTC date to local formatted string
final formattedDate = DateTimeUtils.convertUtcToLocal( final formattedDate = DateTimeUtils.convertUtcToLocal(
expense.transactionDate.toIso8601String(), expense.transactionDate.toIso8601String(),
format: 'dd MMM yyyy, hh:mm a', format: 'dd MMM yyyy, hh:mm a',
); );
return GestureDetector( return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => Get.to( onTap: () => Get.to(
() => const ExpenseDetailScreen(), () => const ExpenseDetailScreen(),
arguments: {'expense': expense}, arguments: {'expense': expense},
@ -419,51 +397,35 @@ class _ExpenseList extends StatelessWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// Title + Amount row
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
Row( Row(
children: [ children: [
const Icon(Icons.receipt_long, MyText.bodyMedium(
size: 20, color: Colors.red),
const SizedBox(width: 8),
Text(
expense.expensesType.name, expense.expensesType.name,
style: const TextStyle( fontWeight: 700,
fontSize: 16,
fontWeight: FontWeight.w600,
),
), ),
], ],
), ),
Text( MyText.bodyMedium(
'${expense.amount.toStringAsFixed(2)}', '${expense.amount.toStringAsFixed(2)}',
style: const TextStyle( fontWeight: 600,
fontSize: 16,
fontWeight: FontWeight.bold,
color: Colors.red,
),
), ),
], ],
), ),
const SizedBox(height: 6), const SizedBox(height: 6),
// Date + Status
Row( Row(
children: [ children: [
Text( MyText.bodySmall(
formattedDate, formattedDate,
style: TextStyle(fontSize: 12, color: Colors.grey[600]), color: Colors.grey[600],
), ),
const Spacer(), const Spacer(),
Text( MyText.bodySmall(
expense.status.name, expense.status.name,
style: TextStyle( fontWeight: 600,
color: statusColor, color: Colors.black,
fontWeight: FontWeight.bold,
fontSize: 12,
),
), ),
], ],
), ),