From 2518b65cb775f62d3737891fc209919b5e0b4973 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Sat, 2 Aug 2025 14:57:34 +0530 Subject: [PATCH] feat: Implement delete expense functionality with confirmation dialog --- .../expense/expense_screen_controller.dart | 19 ++ lib/helpers/services/api_endpoints.dart | 1 + lib/helpers/services/api_service.dart | 41 ++- lib/view/expense/expense_screen.dart | 257 +++++++++++++----- 4 files changed, 244 insertions(+), 74 deletions(-) diff --git a/lib/controller/expense/expense_screen_controller.dart b/lib/controller/expense/expense_screen_controller.dart index 56f8e58..078b4ad 100644 --- a/lib/controller/expense/expense_screen_controller.dart +++ b/lib/controller/expense/expense_screen_controller.dart @@ -61,6 +61,25 @@ class ExpenseController extends GetxController { await fetchMasterData(); } + Future deleteExpense(String expenseId) async { + try { + logSafe("Attempting to delete expense: $expenseId"); + final success = await ApiService.deleteExpense(expenseId); + if (success) { + expenses.removeWhere((e) => e.id == expenseId); + logSafe("Expense deleted successfully."); + Get.snackbar("Deleted", "Expense has been deleted successfully."); + } else { + logSafe("Failed to delete expense: $expenseId", level: LogLevel.error); + Get.snackbar("Failed", "Failed to delete expense."); + } + } catch (e, stack) { + logSafe("Exception in deleteExpense: $e", level: LogLevel.error); + logSafe("StackTrace: $stack", level: LogLevel.debug); + Get.snackbar("Error", "Something went wrong while deleting."); + } + } + /// Fetch expenses using filters Future fetchExpenses({ List? projectIds, diff --git a/lib/helpers/services/api_endpoints.dart b/lib/helpers/services/api_endpoints.dart index 1e386b2..520feb1 100644 --- a/lib/helpers/services/api_endpoints.dart +++ b/lib/helpers/services/api_endpoints.dart @@ -60,4 +60,5 @@ class ApiEndpoints { static const String getMasterExpenseStatus = "/master/expenses-status"; static const String getMasterExpenseTypes = "/master/expenses-types"; static const String updateExpenseStatus = "/expense/action"; + static const String deleteExpense = "/expense/delete"; } diff --git a/lib/helpers/services/api_service.dart b/lib/helpers/services/api_service.dart index 90c2888..a976f35 100644 --- a/lib/helpers/services/api_service.dart +++ b/lib/helpers/services/api_service.dart @@ -240,6 +240,43 @@ class ApiService { } // === Expense APIs === // + static Future deleteExpense(String expenseId) async { + final endpoint = "${ApiEndpoints.deleteExpense}/$expenseId"; + + try { + final token = await _getToken(); + if (token == null) { + logSafe("Token is null. Cannot proceed with DELETE request.", + level: LogLevel.error); + return false; + } + + final uri = Uri.parse("${ApiEndpoints.baseUrl}$endpoint"); + + logSafe("Sending DELETE request to $uri", level: LogLevel.debug); + + final response = + await http.delete(uri, headers: _headers(token)).timeout(timeout); + + logSafe("DELETE expense response status: ${response.statusCode}"); + logSafe("DELETE expense response body: ${response.body}"); + + final json = jsonDecode(response.body); + if (response.statusCode == 200 && json['success'] == true) { + logSafe("Expense deleted successfully."); + return true; + } else { + logSafe( + "Failed to delete expense: ${json['message'] ?? 'Unknown error'}", + level: LogLevel.warning); + } + } catch (e, stack) { + logSafe("Exception during deleteExpenseApi: $e", level: LogLevel.error); + logSafe("StackTrace: $stack", level: LogLevel.debug); + } + + return false; + } /// Get Expense Details API static Future?> getExpenseDetailsApi({ @@ -352,7 +389,8 @@ class ApiService { return false; } -static Future?> getExpenseListApi({ + + static Future?> getExpenseListApi({ String? filter, int pageSize = 20, int pageNumber = 1, @@ -402,6 +440,7 @@ static Future?> getExpenseListApi({ return null; } } + /// Fetch Master Payment Modes static Future?> getMasterPaymentModes() async { const endpoint = ApiEndpoints.getMasterPaymentModes; diff --git a/lib/view/expense/expense_screen.dart b/lib/view/expense/expense_screen.dart index b8d5f38..ac8b396 100644 --- a/lib/view/expense/expense_screen.dart +++ b/lib/view/expense/expense_screen.dart @@ -6,11 +6,12 @@ import 'package:marco/controller/expense/expense_screen_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/helpers/widgets/my_snackbar.dart'; +import 'package:marco/helpers/widgets/my_custom_skeleton.dart'; import 'package:marco/model/expense/expense_list_model.dart'; import 'package:marco/view/expense/expense_detail_screen.dart'; import 'package:marco/model/expense/add_expense_bottom_sheet.dart'; import 'package:marco/view/expense/expense_filter_bottom_sheet.dart'; -import 'package:marco/helpers/widgets/my_custom_skeleton.dart'; class ExpenseMainScreen extends StatefulWidget { const ExpenseMainScreen({super.key}); @@ -22,10 +23,8 @@ class ExpenseMainScreen extends StatefulWidget { class _ExpenseMainScreenState extends State { bool isHistoryView = false; final searchController = TextEditingController(); - String searchQuery = ''; - - final ProjectController projectController = Get.find(); - final ExpenseController expenseController = Get.put(ExpenseController()); + final expenseController = Get.put(ExpenseController()); + final projectController = Get.find(); @override void initState() { @@ -34,6 +33,7 @@ class _ExpenseMainScreenState extends State { } void _refreshExpenses() => expenseController.fetchExpenses(); + void _openFilterBottomSheet() { showModalBottomSheet( context: context, @@ -47,21 +47,27 @@ class _ExpenseMainScreenState extends State { } List _getFilteredExpenses() { - final lowerQuery = searchQuery.trim().toLowerCase(); + final query = searchController.text.trim().toLowerCase(); final now = DateTime.now(); - final filtered = expenseController.expenses.where((e) { - return lowerQuery.isEmpty || - e.expensesType.name.toLowerCase().contains(lowerQuery) || - e.supplerName.toLowerCase().contains(lowerQuery) || - e.paymentMode.name.toLowerCase().contains(lowerQuery); - }).toList(); - filtered.sort((a, b) => b.transactionDate.compareTo(a.transactionDate)); + final filtered = expenseController.expenses.where((e) { + return query.isEmpty || + e.expensesType.name.toLowerCase().contains(query) || + e.supplerName.toLowerCase().contains(query) || + e.paymentMode.name.toLowerCase().contains(query); + }).toList() + ..sort((a, b) => b.transactionDate.compareTo(a.transactionDate)); return isHistoryView - ? filtered.where((e) => e.transactionDate.isBefore(DateTime(now.year, now.month, 1))).toList() - : filtered.where((e) => - e.transactionDate.month == now.month && e.transactionDate.year == now.year).toList(); + ? filtered + .where((e) => + e.transactionDate.isBefore(DateTime(now.year, now.month))) + .toList() + : filtered + .where((e) => + e.transactionDate.month == now.month && + e.transactionDate.year == now.year) + .toList(); } @override @@ -74,7 +80,7 @@ class _ExpenseMainScreenState extends State { children: [ _SearchAndFilter( controller: searchController, - onChanged: (value) => setState(() => searchQuery = value), + onChanged: (_) => setState(() {}), onFilterTap: _openFilterBottomSheet, onRefreshTap: _refreshExpenses, expenseController: expenseController, @@ -88,6 +94,7 @@ class _ExpenseMainScreenState extends State { if (expenseController.isLoading.value) { return SkeletonLoaders.expenseListSkeletonLoader(); } + if (expenseController.errorMessage.isNotEmpty) { return Center( child: MyText.bodyMedium( @@ -97,16 +104,10 @@ class _ExpenseMainScreenState extends State { ); } - final listToShow = _getFilteredExpenses(); + final filteredList = _getFilteredExpenses(); return _ExpenseList( - expenseList: listToShow, - onViewDetail: () async { - final result = - await Get.to(() => ExpenseDetailScreen(expenseId: listToShow.first.id)); - if (result == true) { - expenseController.fetchExpenses(); - } - }, + expenseList: filteredList, + onViewDetail: () => expenseController.fetchExpenses(), ); }), ), @@ -122,9 +123,9 @@ class _ExpenseMainScreenState extends State { } } -///---------------------- APP BAR ----------------------/// class _ExpenseAppBar extends StatelessWidget implements PreferredSizeWidget { final ProjectController projectController; + const _ExpenseAppBar({required this.projectController}); @override @@ -142,40 +143,38 @@ class _ExpenseAppBar extends StatelessWidget implements PreferredSizeWidget { child: Row( children: [ IconButton( - icon: const Icon(Icons.arrow_back_ios_new, color: Colors.black, size: 20), + icon: const Icon(Icons.arrow_back_ios_new, + color: Colors.black, size: 20), onPressed: () => Get.offNamed('/dashboard'), ), MySpacing.width(8), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, children: [ - MyText.titleLarge( - 'Expenses', - fontWeight: 700, - color: Colors.black, - ), + MyText.titleLarge('Expenses', fontWeight: 700), MySpacing.height(2), GetBuilder( builder: (_) { - final projectName = projectController.selectedProject?.name ?? 'Select Project'; + final name = projectController.selectedProject?.name ?? + 'Select Project'; return Row( children: [ - const Icon(Icons.work_outline, size: 14, color: Colors.grey), + const Icon(Icons.work_outline, + size: 14, color: Colors.grey), MySpacing.width(4), Expanded( child: MyText.bodySmall( - projectName, - fontWeight: 600, + name, overflow: TextOverflow.ellipsis, color: Colors.grey[700], + fontWeight: 600, ), ), ], ); }, - ) + ), ], ), ), @@ -186,7 +185,6 @@ class _ExpenseAppBar extends StatelessWidget implements PreferredSizeWidget { } } -///---------------------- SEARCH AND FILTER ----------------------/// class _SearchAndFilter extends StatelessWidget { final TextEditingController controller; final ValueChanged onChanged; @@ -216,7 +214,8 @@ class _SearchAndFilter extends StatelessWidget { onChanged: onChanged, decoration: InputDecoration( contentPadding: const EdgeInsets.symmetric(horizontal: 12), - prefixIcon: const Icon(Icons.search, size: 20, color: Colors.grey), + prefixIcon: + const Icon(Icons.search, size: 20, color: Colors.grey), hintText: 'Search expenses...', filled: true, fillColor: Colors.white, @@ -235,25 +234,19 @@ class _SearchAndFilter extends StatelessWidget { MySpacing.width(8), Tooltip( message: 'Refresh Data', - child: InkWell( - borderRadius: BorderRadius.circular(24), - onTap: onRefreshTap, - child: const Padding( - padding: EdgeInsets.all(0), - child: Icon(Icons.refresh, color: Colors.green, size: 28), - ), + child: IconButton( + icon: const Icon(Icons.refresh, color: Colors.green, size: 24), + onPressed: onRefreshTap, ), ), - MySpacing.width(8), + MySpacing.width(4), Obx(() { - final bool showRedDot = expenseController.isFilterApplied; return IconButton( - onPressed: onFilterTap, icon: Stack( clipBehavior: Clip.none, children: [ - const Icon(Icons.tune, color: Colors.black, size: 24), - if (showRedDot) + const Icon(Icons.tune, color: Colors.black), + if (expenseController.isFilterApplied) Positioned( top: -1, right: -1, @@ -263,15 +256,13 @@ class _SearchAndFilter extends StatelessWidget { decoration: BoxDecoration( color: Colors.red, shape: BoxShape.circle, - border: Border.all( - color: Colors.white, - width: 1.5, - ), + border: Border.all(color: Colors.white, width: 1.5), ), ), ), ], ), + onPressed: onFilterTap, ); }), ], @@ -280,12 +271,14 @@ class _SearchAndFilter extends StatelessWidget { } } -///---------------------- TOGGLE BUTTONS ----------------------/// class _ToggleButtons extends StatelessWidget { final bool isHistoryView; final ValueChanged onToggle; - const _ToggleButtons({required this.isHistoryView, required this.onToggle}); + const _ToggleButtons({ + required this.isHistoryView, + required this.onToggle, + }); @override Widget build(BuildContext context) { @@ -353,13 +346,12 @@ class _ToggleButton extends StatelessWidget { child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon(icon, size: 16, color: selected ? Colors.white : Colors.grey), + Icon(icon, + size: 16, color: selected ? Colors.white : Colors.grey), const SizedBox(width: 6), - MyText.bodyMedium( - label, - color: selected ? Colors.white : Colors.grey, - fontWeight: 600, - ), + MyText.bodyMedium(label, + color: selected ? Colors.white : Colors.grey, + fontWeight: 600), ], ), ), @@ -368,31 +360,134 @@ class _ToggleButton extends StatelessWidget { } } -///---------------------- EXPENSE LIST ----------------------/// class _ExpenseList extends StatelessWidget { final List expenseList; final Future Function()? onViewDetail; - const _ExpenseList({ - required this.expenseList, - this.onViewDetail, - }); + const _ExpenseList({required this.expenseList, this.onViewDetail}); + + void _showDeleteConfirmation(BuildContext context, ExpenseModel expense) { + final ExpenseController controller = Get.find(); + final RxBool isDeleting = false.obs; + + showDialog( + context: context, + barrierDismissible: false, + builder: (_) => Dialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + child: Obx(() { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 28), + child: isDeleting.value + ? const SizedBox( + height: 100, + child: Center(child: CircularProgressIndicator()), + ) + : Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.delete, + size: 48, color: Colors.redAccent), + const SizedBox(height: 16), + MyText.titleLarge( + "Delete Expense", + fontWeight: 600, + color: Theme.of(context).colorScheme.onBackground, + ), + const SizedBox(height: 12), + MyText.bodySmall( + "Are you sure you want to delete this draft expense?", + textAlign: TextAlign.center, + color: Theme.of(context) + .colorScheme + .onSurface + .withOpacity(0.7), + ), + const SizedBox(height: 24), + + // Updated Button UI + Row( + children: [ + Expanded( + child: ElevatedButton.icon( + onPressed: () => Navigator.pop(context), + icon: + const Icon(Icons.close, color: Colors.white), + label: MyText.bodyMedium( + "Cancel", + color: Colors.white, + fontWeight: 600, + ), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.grey, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + padding: + const EdgeInsets.symmetric(vertical: 12), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: ElevatedButton.icon( + onPressed: () async { + isDeleting.value = true; + await controller.deleteExpense(expense.id); + isDeleting.value = false; + Navigator.pop(context); + + showAppSnackbar( + title: 'Deleted', + message: 'Expense has been deleted.', + type: SnackbarType.success, + ); + }, + icon: const Icon(Icons.delete_forever, + color: Colors.white), + label: MyText.bodyMedium( + "Delete", + color: Colors.white, + fontWeight: 600, + ), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.redAccent, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + padding: + const EdgeInsets.symmetric(vertical: 12), + ), + ), + ), + ], + ), + ], + ), + ); + }), + ), + ); + } @override Widget build(BuildContext context) { if (expenseList.isEmpty) { return Center(child: MyText.bodyMedium('No expenses found.')); } + return ListView.separated( padding: const EdgeInsets.fromLTRB(12, 12, 12, 80), itemCount: expenseList.length, - separatorBuilder: (_, __) => Divider(color: Colors.grey.shade300, height: 20), + separatorBuilder: (_, __) => + Divider(color: Colors.grey.shade300, height: 20), itemBuilder: (context, index) { final expense = expenseList[index]; final formattedDate = DateTimeUtils.convertUtcToLocal( expense.transactionDate.toIso8601String(), format: 'dd MMM yyyy, hh:mm a', ); + return GestureDetector( onTap: () async { final result = await Get.to( @@ -411,8 +506,24 @@ class _ExpenseList extends StatelessWidget { Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - MyText.bodyMedium(expense.expensesType.name, fontWeight: 600), - MyText.bodyMedium('₹ ${expense.amount.toStringAsFixed(2)}', fontWeight: 600), + MyText.bodyMedium(expense.expensesType.name, + fontWeight: 600), + Row( + children: [ + MyText.bodyMedium( + '₹ ${expense.amount.toStringAsFixed(2)}', + fontWeight: 600), + if (expense.status.name.toLowerCase() == 'draft') ...[ + const SizedBox(width: 8), + GestureDetector( + onTap: () => + _showDeleteConfirmation(context, expense), + child: const Icon(Icons.delete, + color: Colors.red, size: 20), + ), + ], + ], + ), ], ), const SizedBox(height: 6),