feat: Implement delete expense functionality with confirmation dialog

This commit is contained in:
Vaibhav Surve 2025-08-02 14:57:34 +05:30
parent 5f66c4c647
commit 2518b65cb7
4 changed files with 244 additions and 74 deletions

View File

@ -61,6 +61,25 @@ class ExpenseController extends GetxController {
await fetchMasterData();
}
Future<void> 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<void> fetchExpenses({
List<String>? projectIds,

View File

@ -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";
}

View File

@ -240,6 +240,43 @@ class ApiService {
}
// === Expense APIs === //
static Future<bool> 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<Map<String, dynamic>?> getExpenseDetailsApi({
@ -352,7 +389,8 @@ class ApiService {
return false;
}
static Future<Map<String, dynamic>?> getExpenseListApi({
static Future<Map<String, dynamic>?> getExpenseListApi({
String? filter,
int pageSize = 20,
int pageNumber = 1,
@ -402,6 +440,7 @@ static Future<Map<String, dynamic>?> getExpenseListApi({
return null;
}
}
/// Fetch Master Payment Modes
static Future<List<dynamic>?> getMasterPaymentModes() async {
const endpoint = ApiEndpoints.getMasterPaymentModes;

View File

@ -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<ExpenseMainScreen> {
bool isHistoryView = false;
final searchController = TextEditingController();
String searchQuery = '';
final ProjectController projectController = Get.find<ProjectController>();
final ExpenseController expenseController = Get.put(ExpenseController());
final expenseController = Get.put(ExpenseController());
final projectController = Get.find<ProjectController>();
@override
void initState() {
@ -34,6 +33,7 @@ class _ExpenseMainScreenState extends State<ExpenseMainScreen> {
}
void _refreshExpenses() => expenseController.fetchExpenses();
void _openFilterBottomSheet() {
showModalBottomSheet(
context: context,
@ -47,21 +47,27 @@ class _ExpenseMainScreenState extends State<ExpenseMainScreen> {
}
List<ExpenseModel> _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<ExpenseMainScreen> {
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<ExpenseMainScreen> {
if (expenseController.isLoading.value) {
return SkeletonLoaders.expenseListSkeletonLoader();
}
if (expenseController.errorMessage.isNotEmpty) {
return Center(
child: MyText.bodyMedium(
@ -97,16 +104,10 @@ class _ExpenseMainScreenState extends State<ExpenseMainScreen> {
);
}
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<ExpenseMainScreen> {
}
}
///---------------------- 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<ProjectController>(
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<String> 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<bool> 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<ExpenseModel> expenseList;
final Future<void> 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<ExpenseController>();
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),