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. + ); };