From d94f32d38e06be643e2761c4efd555d95b18aa08 Mon Sep 17 00:00:00 2001
From: "google-labs-jules[bot]"
<161369871+google-labs-jules[bot]@users.noreply.github.com>
Date: Thu, 12 Feb 2026 19:43:45 +0000
Subject: [PATCH] [jules] enhance: Add swipe-to-delete for expenses with undo
- Added `react-native-gesture-handler` and `react-native-reanimated` dependencies.
- Created `SwipeableExpenseRow` component using `ReanimatedSwipeable`.
- Implemented swipe-to-delete in `GroupDetailsScreen` with undo capability via `Snackbar`.
- Added `deleteExpense` API function.
- Ensured proper handling of race conditions during multiple deletions.
Co-authored-by: Devasy23 <110348311+Devasy23@users.noreply.github.com>
---
mobile/App.js | 13 +-
mobile/api/groups.js | 3 +
mobile/babel.config.js | 7 ++
mobile/components/SwipeableExpenseRow.js | 69 ++++++++++
mobile/package-lock.json | 153 +++++++++++++++++++++++
mobile/package.json | 2 +
mobile/screens/GroupDetailsScreen.js | 105 +++++++++++++---
7 files changed, 330 insertions(+), 22 deletions(-)
create mode 100644 mobile/babel.config.js
create mode 100644 mobile/components/SwipeableExpenseRow.js
diff --git a/mobile/App.js b/mobile/App.js
index f5496adf..b84665e6 100644
--- a/mobile/App.js
+++ b/mobile/App.js
@@ -1,14 +1,17 @@
import React from 'react';
import AppNavigator from './navigation/AppNavigator';
import { PaperProvider } from 'react-native-paper';
+import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { AuthProvider } from './context/AuthContext';
export default function App() {
return (
-
-
-
-
-
+
+
+
+
+
+
+
);
}
diff --git a/mobile/api/groups.js b/mobile/api/groups.js
index 8cf9cdba..f02fca4b 100644
--- a/mobile/api/groups.js
+++ b/mobile/api/groups.js
@@ -8,6 +8,9 @@ export const getOptimizedSettlements = (groupId) =>
export const createExpense = (groupId, expenseData) =>
apiClient.post(`/groups/${groupId}/expenses`, expenseData);
+export const deleteExpense = (groupId, expenseId) =>
+ apiClient.delete(`/groups/${groupId}/expenses/${expenseId}`);
+
export const getGroupDetails = (groupId) => {
return Promise.all([getGroupMembers(groupId), getGroupExpenses(groupId)]);
};
diff --git a/mobile/babel.config.js b/mobile/babel.config.js
new file mode 100644
index 00000000..db538eba
--- /dev/null
+++ b/mobile/babel.config.js
@@ -0,0 +1,7 @@
+module.exports = function(api) {
+ api.cache(true);
+ return {
+ presets: ['babel-preset-expo'],
+ plugins: ['react-native-reanimated/plugin'],
+ };
+};
diff --git a/mobile/components/SwipeableExpenseRow.js b/mobile/components/SwipeableExpenseRow.js
new file mode 100644
index 00000000..7db39035
--- /dev/null
+++ b/mobile/components/SwipeableExpenseRow.js
@@ -0,0 +1,69 @@
+import React, { useRef } from 'react';
+import { StyleSheet, View } from 'react-native';
+import ReanimatedSwipeable from 'react-native-gesture-handler/ReanimatedSwipeable';
+import Reanimated, { useAnimatedStyle, interpolate, Extrapolation } from 'react-native-reanimated';
+import { IconButton } from 'react-native-paper';
+
+const SwipeableExpenseRow = ({ children, onSwipeableOpen }) => {
+ const swipeableRef = useRef(null);
+
+ const renderRightActions = (progress, drag) => {
+ const style = useAnimatedStyle(() => {
+ const scale = interpolate(
+ drag.value,
+ [-80, 0],
+ [1, 0],
+ Extrapolation.CLAMP
+ );
+ return {
+ transform: [{ scale }],
+ };
+ });
+
+ return (
+
+
+ {
+ swipeableRef.current?.close();
+ onSwipeableOpen();
+ }}
+ />
+
+
+ );
+ };
+
+ return (
+
+ {children}
+
+ );
+};
+
+const styles = StyleSheet.create({
+ rightActionContainer: {
+ width: 80,
+ backgroundColor: '#dd2c00',
+ justifyContent: 'center',
+ alignItems: 'center',
+ marginBottom: 16, // Matches the card margin in GroupDetailsScreen
+ borderTopRightRadius: 12, // Approximate card radius
+ borderBottomRightRadius: 12,
+ },
+ rightAction: {
+ flex: 1,
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
+});
+
+export default SwipeableExpenseRow;
diff --git a/mobile/package-lock.json b/mobile/package-lock.json
index c3452165..5cee84d3 100644
--- a/mobile/package-lock.json
+++ b/mobile/package-lock.json
@@ -21,7 +21,9 @@
"react": "19.1.0",
"react-dom": "19.1.0",
"react-native": "0.81.5",
+ "react-native-gesture-handler": "^2.30.0",
"react-native-paper": "^5.14.5",
+ "react-native-reanimated": "^4.2.1",
"react-native-safe-area-context": "^5.4.0",
"react-native-screens": "^4.11.1",
"react-native-web": "^0.21.0"
@@ -1372,6 +1374,22 @@
"@babel/core": "^7.0.0-0"
}
},
+ "node_modules/@babel/plugin-transform-template-literals": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.27.1.tgz",
+ "integrity": "sha512-fBJKiV7F2DxZUkg5EtHKXQdbsbURW3DZKQUWphDum0uRP6eHGGa/He9mc0mypL680pb+e/lDIthRohlv8NCHkg==",
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
"node_modules/@babel/plugin-transform-typescript": {
"version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.28.5.tgz",
@@ -1541,6 +1559,18 @@
"node": ">=0.10.0"
}
},
+ "node_modules/@egjs/hammerjs": {
+ "version": "2.0.17",
+ "resolved": "https://registry.npmjs.org/@egjs/hammerjs/-/hammerjs-2.0.17.tgz",
+ "integrity": "sha512-XQsZgjm2EcVUiZQf11UBJQfmZeEmOW8DpI1gsFeln6w0ae0ii4dMQEQ0kjl6DspdWX1aGY1/loyXnP0JS06e/A==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hammerjs": "^2.0.36"
+ },
+ "engines": {
+ "node": ">=0.8.0"
+ }
+ },
"node_modules/@expo/code-signing-certificates": {
"version": "0.0.5",
"resolved": "https://registry.npmjs.org/@expo/code-signing-certificates/-/code-signing-certificates-0.0.5.tgz",
@@ -3099,6 +3129,12 @@
"@types/node": "*"
}
},
+ "node_modules/@types/hammerjs": {
+ "version": "2.0.46",
+ "resolved": "https://registry.npmjs.org/@types/hammerjs/-/hammerjs-2.0.46.tgz",
+ "integrity": "sha512-ynRvcq6wvqexJ9brDMS4BnBLzmr0e14d6ZJTEShTBWKymQiHwlAyGu0ZPEFI2Fh1U53F7tN9ufClWM5KvqkKOw==",
+ "license": "MIT"
+ },
"node_modules/@types/istanbul-lib-coverage": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz",
@@ -7439,6 +7475,21 @@
}
}
},
+ "node_modules/react-native-gesture-handler": {
+ "version": "2.30.0",
+ "resolved": "https://registry.npmjs.org/react-native-gesture-handler/-/react-native-gesture-handler-2.30.0.tgz",
+ "integrity": "sha512-5YsnKHGa0X9C8lb5oCnKm0fLUPM6CRduvUUw2Bav4RIj/C3HcFh4RIUnF8wgG6JQWCL1//gRx4v+LVWgcIQdGA==",
+ "license": "MIT",
+ "dependencies": {
+ "@egjs/hammerjs": "^2.0.17",
+ "hoist-non-react-statics": "^3.3.0",
+ "invariant": "^2.2.4"
+ },
+ "peerDependencies": {
+ "react": "*",
+ "react-native": "*"
+ }
+ },
"node_modules/react-native-is-edge-to-edge": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/react-native-is-edge-to-edge/-/react-native-is-edge-to-edge-1.2.1.tgz",
@@ -7494,6 +7545,33 @@
"integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==",
"license": "MIT"
},
+ "node_modules/react-native-reanimated": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.2.1.tgz",
+ "integrity": "sha512-/NcHnZMyOvsD/wYXug/YqSKw90P9edN0kEPL5lP4PFf1aQ4F1V7MKe/E0tvfkXKIajy3Qocp5EiEnlcrK/+BZg==",
+ "license": "MIT",
+ "dependencies": {
+ "react-native-is-edge-to-edge": "1.2.1",
+ "semver": "7.7.3"
+ },
+ "peerDependencies": {
+ "react": "*",
+ "react-native": "*",
+ "react-native-worklets": ">=0.7.0"
+ }
+ },
+ "node_modules/react-native-reanimated/node_modules/semver": {
+ "version": "7.7.3",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
+ "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/react-native-safe-area-context": {
"version": "5.6.2",
"resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-5.6.2.tgz",
@@ -7549,6 +7627,81 @@
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz",
"integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw=="
},
+ "node_modules/react-native-worklets": {
+ "version": "0.7.3",
+ "resolved": "https://registry.npmjs.org/react-native-worklets/-/react-native-worklets-0.7.3.tgz",
+ "integrity": "sha512-m/CIUCHvLQulboBn0BtgpsesXjOTeubU7t+V0lCPpBj0t2ExigwqDHoKj3ck7OeErnjgkD27wdAtQCubYATe3g==",
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@babel/plugin-transform-arrow-functions": "7.27.1",
+ "@babel/plugin-transform-class-properties": "7.27.1",
+ "@babel/plugin-transform-classes": "7.28.4",
+ "@babel/plugin-transform-nullish-coalescing-operator": "7.27.1",
+ "@babel/plugin-transform-optional-chaining": "7.27.1",
+ "@babel/plugin-transform-shorthand-properties": "7.27.1",
+ "@babel/plugin-transform-template-literals": "7.27.1",
+ "@babel/plugin-transform-unicode-regex": "7.27.1",
+ "@babel/preset-typescript": "7.27.1",
+ "convert-source-map": "2.0.0",
+ "semver": "7.7.3"
+ },
+ "peerDependencies": {
+ "@babel/core": "*",
+ "react": "*",
+ "react-native": "*"
+ }
+ },
+ "node_modules/react-native-worklets/node_modules/@babel/plugin-transform-optional-chaining": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.27.1.tgz",
+ "integrity": "sha512-BQmKPPIuc8EkZgNKsv0X4bPmOoayeu4F1YCwx2/CfmDSXDbp7GnzlUH+/ul5VGfRg1AoFPsrIThlEBj2xb4CAg==",
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.27.1",
+ "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/react-native-worklets/node_modules/@babel/preset-typescript": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.27.1.tgz",
+ "integrity": "sha512-l7WfQfX0WK4M0v2RudjuQK4u99BS6yLHYEmdtVPP7lKV013zr9DygFuWNlnbvQ9LR+LS0Egz/XAvGx5U9MX0fQ==",
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.27.1",
+ "@babel/helper-validator-option": "^7.27.1",
+ "@babel/plugin-syntax-jsx": "^7.27.1",
+ "@babel/plugin-transform-modules-commonjs": "^7.27.1",
+ "@babel/plugin-transform-typescript": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/react-native-worklets/node_modules/semver": {
+ "version": "7.7.3",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
+ "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
+ "license": "ISC",
+ "peer": true,
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/react-native/node_modules/@react-native/virtualized-lists": {
"version": "0.81.5",
"resolved": "https://registry.npmjs.org/@react-native/virtualized-lists/-/virtualized-lists-0.81.5.tgz",
diff --git a/mobile/package.json b/mobile/package.json
index a425a9c1..400c2241 100644
--- a/mobile/package.json
+++ b/mobile/package.json
@@ -22,7 +22,9 @@
"react": "19.1.0",
"react-dom": "19.1.0",
"react-native": "0.81.5",
+ "react-native-gesture-handler": "^2.30.0",
"react-native-paper": "^5.14.5",
+ "react-native-reanimated": "^4.2.1",
"react-native-safe-area-context": "^5.4.0",
"react-native-screens": "^4.11.1",
"react-native-web": "^0.21.0"
diff --git a/mobile/screens/GroupDetailsScreen.js b/mobile/screens/GroupDetailsScreen.js
index 7ac1ee8c..a2f68d65 100644
--- a/mobile/screens/GroupDetailsScreen.js
+++ b/mobile/screens/GroupDetailsScreen.js
@@ -1,19 +1,22 @@
-import { useContext, useEffect, useState } from "react";
+import { useContext, useEffect, useState, useRef } from "react";
import { Alert, FlatList, RefreshControl, StyleSheet, Text, View } from "react-native";
import {
ActivityIndicator,
Paragraph,
Title,
useTheme,
+ Snackbar,
} from "react-native-paper";
import HapticCard from '../components/ui/HapticCard';
import HapticFAB from '../components/ui/HapticFAB';
import HapticIconButton from '../components/ui/HapticIconButton';
+import SwipeableExpenseRow from '../components/SwipeableExpenseRow';
import * as Haptics from "expo-haptics";
import {
getGroupExpenses,
getGroupMembers,
getOptimizedSettlements,
+ deleteExpense,
} from "../api/groups";
import { AuthContext } from "../context/AuthContext";
@@ -26,6 +29,9 @@ const GroupDetailsScreen = ({ route, navigation }) => {
const [settlements, setSettlements] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const [isRefreshing, setIsRefreshing] = useState(false);
+ const [visible, setVisible] = useState(false);
+ const [pendingExpense, setPendingExpense] = useState(null);
+ const undoTimeoutRef = useRef(null);
// Currency configuration - can be made configurable later
const currency = "₹"; // Default to INR, can be changed to '$' for USD
@@ -33,6 +39,58 @@ const GroupDetailsScreen = ({ route, navigation }) => {
// Helper function to format currency amounts
const formatCurrency = (amount) => `${currency}${amount.toFixed(2)}`;
+ const commitPendingDelete = async (expense) => {
+ try {
+ await deleteExpense(groupId, expense._id);
+ } catch (error) {
+ console.error("Failed to delete expense:", error);
+ Alert.alert("Error", "Failed to delete expense.");
+ }
+ };
+
+ const handleDelete = (expense) => {
+ if (undoTimeoutRef.current) clearTimeout(undoTimeoutRef.current);
+
+ // If there is already a pending delete, commit it immediately
+ if (pendingExpense) {
+ commitPendingDelete(pendingExpense);
+ }
+
+ setPendingExpense(expense);
+ setExpenses((prev) => prev.filter((e) => e._id !== expense._id));
+ setVisible(true);
+
+ undoTimeoutRef.current = setTimeout(async () => {
+ await commitPendingDelete(expense);
+ setPendingExpense((prev) => (prev?._id === expense._id ? null : prev));
+ }, 4000);
+ };
+
+ const onUndo = () => {
+ if (undoTimeoutRef.current) clearTimeout(undoTimeoutRef.current);
+ if (pendingExpense) {
+ setExpenses((prev) => [pendingExpense, ...prev]);
+ setPendingExpense(null);
+ }
+ setVisible(false);
+ };
+
+ const onDismissSnackBar = () => {
+ setVisible(false);
+ if (undoTimeoutRef.current) clearTimeout(undoTimeoutRef.current);
+ // Force commit if dismissed manually
+ if (pendingExpense) {
+ commitPendingDelete(pendingExpense);
+ setPendingExpense(null);
+ }
+ };
+
+ useEffect(() => {
+ return () => {
+ if (undoTimeoutRef.current) clearTimeout(undoTimeoutRef.current);
+ };
+ }, []);
+
const fetchData = async (showLoading = true) => {
try {
if (showLoading) setIsLoading(true);
@@ -103,22 +161,24 @@ const GroupDetailsScreen = ({ route, navigation }) => {
}
return (
-
-
- {item.description}
- Amount: {formatCurrency(item.amount)}
-
- Paid by: {getMemberName(item.paidBy || item.createdBy)}
-
- {balanceText}
-
-
+ handleDelete(item)}>
+
+
+ {item.description}
+ Amount: {formatCurrency(item.amount)}
+
+ Paid by: {getMemberName(item.paidBy || item.createdBy)}
+
+ {balanceText}
+
+
+
);
};
@@ -238,6 +298,17 @@ const GroupDetailsScreen = ({ route, navigation }) => {
accessibilityLabel="Add expense"
accessibilityRole="button"
/>
+
+ Expense deleted.
+
);
};