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