From ff01c05a734b55e4c2a9bfd0439efd502883dacb Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Mon, 21 Jul 2025 15:24:18 +0530 Subject: [PATCH] feat(expense): improve expense submission validation and UI feedback --- .../expense/add_expense_controller.dart | 26 +-- lib/helpers/services/api_service.dart | 2 - .../expense/add_expense_bottom_sheet.dart | 149 +++++++----------- lib/view/expense/expense_screen.dart | 88 +++-------- 4 files changed, 98 insertions(+), 167 deletions(-) diff --git a/lib/controller/expense/add_expense_controller.dart b/lib/controller/expense/add_expense_controller.dart index 17d8078..9073035 100644 --- a/lib/controller/expense/add_expense_controller.dart +++ b/lib/controller/expense/add_expense_controller.dart @@ -173,6 +173,7 @@ class AddExpenseController extends GetxController { } } + // === Submit Expense === // === Submit Expense === Future 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 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, ); diff --git a/lib/helpers/services/api_service.dart b/lib/helpers/services/api_service.dart index 0427760..405fe1f 100644 --- a/lib/helpers/services/api_service.dart +++ b/lib/helpers/services/api_service.dart @@ -295,7 +295,6 @@ class ApiService { required String supplerName, required double amount, required int noOfPersons, - required String statusId, required List> billAttachments, }) async { final payload = { @@ -310,7 +309,6 @@ class ApiService { "supplerName": supplerName, "amount": amount, "noOfPersons": noOfPersons, - "statusId": statusId, "billAttachments": billAttachments, }; diff --git a/lib/model/expense/add_expense_bottom_sheet.dart b/lib/model/expense/add_expense_bottom_sheet.dart index d7e6e11..ac6e5af 100644 --- a/lib/model/expense/add_expense_bottom_sheet.dart +++ b/lib/model/expense/add_expense_bottom_sheet.dart @@ -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( - 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( - 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 _showOptionList( BuildContext context, List 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), + ), + ], + ), + ), ], ); } diff --git a/lib/view/expense/expense_screen.dart b/lib/view/expense/expense_screen.dart index c01b69e..96803c1 100644 --- a/lib/view/expense/expense_screen.dart +++ b/lib/view/expense/expense_screen.dart @@ -51,18 +51,17 @@ class _ExpenseMainScreenState extends State { 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 { 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 { 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, ), ], ),