diff --git a/.Jules/changelog.md b/.Jules/changelog.md
index 11fc864e..b3752dd5 100644
--- a/.Jules/changelog.md
+++ b/.Jules/changelog.md
@@ -7,6 +7,15 @@
## [Unreleased]
### Added
+- **Mobile Swipe-to-Delete:** Implemented intuitive swipe gestures for deleting expenses in group details.
+ - **Features:**
+ - Swipe left to reveal a delete action (themed with error color).
+ - Optimistic UI updates to immediately hide the deleted item, preventing flicker.
+ - "Undo" Snackbar notification allowing users to cancel accidental deletions.
+ - Robust unmount handling to ensure pending deletions are committed if the user navigates away.
+ - Haptic feedback on delete triggering.
+ - **Technical:** Integrated `react-native-gesture-handler` and `react-native-reanimated`. Created `mobile/components/SwipeableExpenseRow.js`. Updated `mobile/screens/GroupDetailsScreen.js`.
+
- **Password Strength Meter:** Added a visual password strength indicator to the signup form.
- **Features:**
- Real-time strength calculation (Length, Uppercase, Lowercase, Number, Symbol).
diff --git a/.Jules/knowledge.md b/.Jules/knowledge.md
index 43a9ab01..08265097 100644
--- a/.Jules/knowledge.md
+++ b/.Jules/knowledge.md
@@ -285,6 +285,29 @@ const clearFieldError = (field: string) => {
## Mobile Patterns
+### Swipe-to-Delete with Undo
+
+**Date:** 2026-02-12
+**Context:** Implementing expense deletion in GroupDetails
+
+To implement a robust "Swipe to Delete" with Undo capability in React Native:
+
+1. **Dependencies**: Use `react-native-gesture-handler` (with `GestureHandlerRootView` at root) and `react-native-reanimated`.
+2. **Optimistic UI**: Update local state *immediately* to remove the item. Do not wait for API. This prevents flickering.
+3. **Persistence on Unmount**: Use a `useRef` to track pending deletions. In `useEffect` cleanup, force-commit the deletion if the user navigates away while the "Undo" snackbar is visible.
+4. **Theme Integration**: Pass theme colors (e.g., `theme.colors.error`) to the swipeable component for consistency.
+
+```javascript
+// Example Cleanup Pattern
+useEffect(() => {
+ return () => {
+ if (pendingDeleteIdRef.current) {
+ api.delete(pendingDeleteIdRef.current);
+ }
+ };
+}, []);
+```
+
### React Native Paper Components
**Date:** 2026-01-01
@@ -578,8 +601,6 @@ _Document errors and their solutions here as you encounter them._
- Verification requires triggering an error during render (e.g., conditional throw).
- Must wrap Router if using `useNavigate` or `Link` in fallback.
----
-
### ✅ Successful PR Pattern: Toast Notification System (#227)
**Date:** 2026-01-13
diff --git a/.Jules/todo.md b/.Jules/todo.md
index ebb0c7a5..299fdb5b 100644
--- a/.Jules/todo.md
+++ b/.Jules/todo.md
@@ -87,11 +87,12 @@
### Mobile
-- [ ] **[ux]** Swipe-to-delete for expenses with undo option
+- [x] **[ux]** Swipe-to-delete for expenses with undo option
+ - Completed: 2026-02-12
- File: `mobile/screens/GroupDetailsScreen.js`
- - Context: Add swipeable rows with delete action
+ - Context: Add swipeable rows with delete action and undo snackbar
- Impact: Quick expense management
- - Size: ~55 lines
+ - Size: ~100 lines
- Added: 2026-01-01
- [x] **[style]** Haptic feedback on all button presses
diff --git a/mobile/App.js b/mobile/App.js
index f5496adf..84672c16 100644
--- a/mobile/App.js
+++ b/mobile/App.js
@@ -1,14 +1,17 @@
import React from 'react';
+import { GestureHandlerRootView } from 'react-native-gesture-handler';
import AppNavigator from './navigation/AppNavigator';
import { PaperProvider } from 'react-native-paper';
import { AuthProvider } from './context/AuthContext';
export default function App() {
return (
-
-
-
-
-
+
+
+
+
+
+
+
);
}
diff --git a/mobile/api/groups.js b/mobile/api/groups.js
index 8cf9cdba..5e340619 100644
--- a/mobile/api/groups.js
+++ b/mobile/api/groups.js
@@ -45,3 +45,6 @@ export const updateMemberRole = (groupId, memberId, role) =>
export const removeMember = (groupId, memberId) =>
apiClient.delete(`/groups/${groupId}/members/${memberId}`);
+
+export const deleteExpense = (groupId, expenseId) =>
+ apiClient.delete(`/groups/${groupId}/expenses/${expenseId}`);
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..c53f1ca5
--- /dev/null
+++ b/mobile/components/SwipeableExpenseRow.js
@@ -0,0 +1,69 @@
+import React, { useRef } from 'react';
+import { View, StyleSheet, Animated } from 'react-native';
+import { Swipeable } from 'react-native-gesture-handler';
+import { IconButton } from 'react-native-paper';
+
+const SwipeableExpenseRow = ({ children, onDelete, style, deleteColor }) => {
+ const swipeableRef = useRef(null);
+
+ const renderRightActions = (progress, dragX) => {
+ const scale = dragX.interpolate({
+ inputRange: [-80, 0],
+ outputRange: [1, 0],
+ extrapolate: 'clamp',
+ });
+
+ return (
+
+
+ {
+ swipeableRef.current?.close();
+ onDelete();
+ }}
+ accessibilityLabel="Delete expense"
+ accessibilityRole="button"
+ />
+
+
+ );
+ };
+
+ return (
+
+ {children}
+
+ );
+};
+
+const styles = StyleSheet.create({
+ rightActionContainer: {
+ width: 80,
+ justifyContent: 'center',
+ alignItems: 'center',
+ backgroundColor: '#ff5252',
+ borderTopRightRadius: 12,
+ borderBottomRightRadius: 12,
+ },
+ deleteButton: {
+ flex: 1,
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
+});
+
+export default SwipeableExpenseRow;
diff --git a/mobile/package-lock.json b/mobile/package-lock.json
index c3452165..4f1388e4 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.28.0",
"react-native-paper": "^5.14.5",
+ "react-native-reanimated": "~4.1.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.28.0",
+ "resolved": "https://registry.npmjs.org/react-native-gesture-handler/-/react-native-gesture-handler-2.28.0.tgz",
+ "integrity": "sha512-0msfJ1vRxXKVgTgvL+1ZOoYw3/0z1R+Ked0+udoJhyplC2jbVKIJ8Z1bzWdpQRCV3QcQ87Op0zJVE5DhKK2A0A==",
+ "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,34 @@
"integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==",
"license": "MIT"
},
+ "node_modules/react-native-reanimated": {
+ "version": "4.1.6",
+ "resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.1.6.tgz",
+ "integrity": "sha512-F+ZJBYiok/6Jzp1re75F/9aLzkgoQCOh4yxrnwATa8392RvM3kx+fiXXFvwcgE59v48lMwd9q0nzF1oJLXpfxQ==",
+ "license": "MIT",
+ "dependencies": {
+ "react-native-is-edge-to-edge": "^1.2.1",
+ "semver": "7.7.2"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0",
+ "react": "*",
+ "react-native": "*",
+ "react-native-worklets": ">=0.5.0"
+ }
+ },
+ "node_modules/react-native-reanimated/node_modules/semver": {
+ "version": "7.7.2",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
+ "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
+ "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 +7628,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..f8d75457 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.28.0",
"react-native-paper": "^5.14.5",
+ "react-native-reanimated": "~4.1.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..3aba928b 100644
--- a/mobile/screens/GroupDetailsScreen.js
+++ b/mobile/screens/GroupDetailsScreen.js
@@ -1,16 +1,19 @@
-import { useContext, useEffect, useState } from "react";
+import { useContext, useEffect, useRef, useState } from "react";
import { Alert, FlatList, RefreshControl, StyleSheet, Text, View } from "react-native";
import {
ActivityIndicator,
Paragraph,
+ Snackbar,
Title,
useTheme,
} 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 {
+ deleteExpense,
getGroupExpenses,
getGroupMembers,
getOptimizedSettlements,
@@ -26,6 +29,9 @@ const GroupDetailsScreen = ({ route, navigation }) => {
const [settlements, setSettlements] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const [isRefreshing, setIsRefreshing] = useState(false);
+ const [deletedExpenseId, setDeletedExpenseId] = useState(null);
+ const [snackbarVisible, setSnackbarVisible] = useState(false);
+ const deletedExpenseIdRef = useRef(null);
// Currency configuration - can be made configurable later
const currency = "₹"; // Default to INR, can be changed to '$' for USD
@@ -61,6 +67,57 @@ const GroupDetailsScreen = ({ route, navigation }) => {
setIsRefreshing(false);
};
+ const handleDeleteExpense = (expenseId) => {
+ if (deletedExpenseId) {
+ commitDeletion(deletedExpenseId);
+ }
+ setDeletedExpenseId(expenseId);
+ deletedExpenseIdRef.current = expenseId;
+ setSnackbarVisible(true);
+ Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
+ };
+
+ const commitDeletion = async (id) => {
+ deletedExpenseIdRef.current = null;
+
+ // Optimistic update
+ setExpenses((prev) => prev.filter((e) => e._id !== id));
+
+ try {
+ await deleteExpense(groupId, id);
+ const settlementsResponse = await getOptimizedSettlements(groupId);
+ setSettlements(settlementsResponse.data.optimizedSettlements || []);
+ } catch (error) {
+ console.error("Failed to delete expense:", error);
+ Alert.alert("Error", "Failed to delete expense.");
+ fetchData(false);
+ }
+ };
+
+ const onUndo = () => {
+ setDeletedExpenseId(null);
+ deletedExpenseIdRef.current = null;
+ setSnackbarVisible(false);
+ };
+
+ const onDismissSnackbar = () => {
+ setSnackbarVisible(false);
+ if (deletedExpenseId) {
+ commitDeletion(deletedExpenseId);
+ setDeletedExpenseId(null);
+ }
+ };
+
+ useEffect(() => {
+ return () => {
+ if (deletedExpenseIdRef.current) {
+ deleteExpense(groupId, deletedExpenseIdRef.current).catch((err) =>
+ console.error("Unmount delete failed", err)
+ );
+ }
+ };
+ }, [groupId]);
+
useEffect(() => {
navigation.setOptions({
title: groupName,
@@ -103,22 +160,28 @@ const GroupDetailsScreen = ({ route, navigation }) => {
}
return (
- handleDeleteExpense(item._id)}
style={styles.card}
- accessibilityRole="button"
- accessibilityLabel={`Expense: ${item.description}, Amount: ${formatCurrency(
- item.amount
- )}. Paid by ${getMemberName(item.paidBy || item.createdBy)}. ${balanceText}`}
+ deleteColor={theme.colors.error}
>
-
- {item.description}
- Amount: {formatCurrency(item.amount)}
-
- Paid by: {getMemberName(item.paidBy || item.createdBy)}
-
- {balanceText}
-
-
+
+
+ {item.description}
+ Amount: {formatCurrency(item.amount)}
+
+ Paid by: {getMemberName(item.paidBy || item.createdBy)}
+
+ {balanceText}
+
+
+
);
};
@@ -213,7 +276,7 @@ const GroupDetailsScreen = ({ route, navigation }) => {
e._id !== deletedExpenseId)}
renderItem={renderExpense}
keyExtractor={(item) => item._id}
ListHeaderComponent={renderHeader}
@@ -238,6 +301,19 @@ const GroupDetailsScreen = ({ route, navigation }) => {
accessibilityLabel="Add expense"
accessibilityRole="button"
/>
+
+
+ Expense deleted
+
);
};