diff --git a/.Jules/changelog.md b/.Jules/changelog.md index 16a7b0ce..17b3cd5f 100644 --- a/.Jules/changelog.md +++ b/.Jules/changelog.md @@ -7,6 +7,13 @@ ## [Unreleased] ### Added +- **Mobile Pull-to-Refresh with Haptics:** Implemented native pull-to-refresh functionality for HomeScreen and GroupDetailsScreen. + - **Features:** + - Integrated `expo-haptics` for tactile feedback during refresh. + - Separated `isLoading` (initial load) from `isRefreshing` (pull actions) to prevent content flashing. + - Ensured lists remain visible while refreshing. + - **Technical:** Refactored `fetchGroups` and `fetchData` to accept refresh flags and manage state independently. + - **Error Boundary System:** Implemented a global React Error Boundary to catch render errors gracefully. - **Features:** - Dual-theme support (Glassmorphism & Neobrutalism) for the error fallback UI. diff --git a/.Jules/knowledge.md b/.Jules/knowledge.md index ad48a44b..411fe6a0 100644 --- a/.Jules/knowledge.md +++ b/.Jules/knowledge.md @@ -270,6 +270,44 @@ Commonly used components: Most screens use `` - consider wrapping in `SafeAreaView` for notched devices. +### Mobile List Refresh Pattern + +**Date:** 2026-01-14 +**Context:** Implementing Pull-to-Refresh in React Native + +When implementing `RefreshControl` on a `FlatList`, you must separate the **initial loading state** from the **refreshing state** to avoid UI flashing. + +**Bad Pattern:** +```javascript +// Causes list to unmount and show spinner on refresh +if (isLoading) return ; + +``` + +**Good Pattern:** +```javascript +const [isLoading, setIsLoading] = useState(true); // Initial load +const [isRefreshing, setIsRefreshing] = useState(false); // Pull actions + +const fetchData = async (refresh = false) => { + if (refresh) setIsRefreshing(true); + else setIsLoading(true); + // ... fetch ... + setIsLoading(false); + setIsRefreshing(false); +} + +// Only show full loader on initial mount +if (isLoading) return ; + +return ( + fetchData(true)} + refreshing={isRefreshing} // List stays visible + /> +); +``` + --- ## API Response Patterns diff --git a/.Jules/todo.md b/.Jules/todo.md index 4539a8a2..855e3a88 100644 --- a/.Jules/todo.md +++ b/.Jules/todo.md @@ -43,12 +43,10 @@ ### Mobile -- [ ] **[ux]** Pull-to-refresh with haptic feedback on all list screens - - Files: `mobile/screens/HomeScreen.js`, `mobile/screens/GroupDetailsScreen.js` - - Context: Add RefreshControl + Expo Haptics to main lists - - Impact: Native feel, users can easily refresh data - - Size: ~45 lines - - Added: 2026-01-01 +- [x] **[ux]** Pull-to-refresh with haptic feedback on all list screens + - Completed: 2026-01-14 + - Files modified: `mobile/screens/HomeScreen.js`, `mobile/screens/GroupDetailsScreen.js` + - Impact: Users can now pull to refresh lists with tactile feedback; list remains visible during refresh. - [ ] **[ux]** Complete skeleton loading for HomeScreen groups - File: `mobile/screens/HomeScreen.js` diff --git a/mobile/package-lock.json b/mobile/package-lock.json index 329f6465..bb568bfc 100644 --- a/mobile/package-lock.json +++ b/mobile/package-lock.json @@ -15,6 +15,7 @@ "@react-navigation/native-stack": "^7.3.23", "axios": "^1.11.0", "expo": "^54.0.25", + "expo-haptics": "^15.0.8", "expo-image-picker": "~17.0.8", "expo-status-bar": "~3.0.8", "react": "19.1.0", @@ -4587,6 +4588,15 @@ "react-native": "*" } }, + "node_modules/expo-haptics": { + "version": "15.0.8", + "resolved": "https://registry.npmjs.org/expo-haptics/-/expo-haptics-15.0.8.tgz", + "integrity": "sha512-lftutojy8Qs8zaDzzjwM3gKHFZ8bOOEZDCkmh2Ddpe95Ra6kt2izeOfOfKuP/QEh0MZ1j9TfqippyHdRd1ZM9g==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-image-loader": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/expo-image-loader/-/expo-image-loader-6.0.0.tgz", diff --git a/mobile/package.json b/mobile/package.json index c3b85321..af36e6b4 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -16,6 +16,7 @@ "@react-navigation/native-stack": "^7.3.23", "axios": "^1.11.0", "expo": "^54.0.25", + "expo-haptics": "^15.0.8", "expo-image-picker": "~17.0.8", "expo-status-bar": "~3.0.8", "react": "19.1.0", diff --git a/mobile/screens/GroupDetailsScreen.js b/mobile/screens/GroupDetailsScreen.js index a1050b9f..bf527886 100644 --- a/mobile/screens/GroupDetailsScreen.js +++ b/mobile/screens/GroupDetailsScreen.js @@ -1,5 +1,6 @@ import { useContext, useEffect, useState } from "react"; import { Alert, FlatList, StyleSheet, Text, View } from "react-native"; +import * as Haptics from "expo-haptics"; import { ActivityIndicator, Card, @@ -22,6 +23,7 @@ const GroupDetailsScreen = ({ route, navigation }) => { const [expenses, setExpenses] = useState([]); const [settlements, setSettlements] = useState([]); const [isLoading, setIsLoading] = useState(true); + const [isRefreshing, setIsRefreshing] = useState(false); // Currency configuration - can be made configurable later const currency = "₹"; // Default to INR, can be changed to '$' for USD @@ -29,9 +31,14 @@ const GroupDetailsScreen = ({ route, navigation }) => { // Helper function to format currency amounts const formatCurrency = (amount) => `${currency}${amount.toFixed(2)}`; - const fetchData = async () => { + const fetchData = async (refresh = false) => { try { - setIsLoading(true); + if (refresh) { + setIsRefreshing(true); + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + } else { + setIsLoading(true); + } // Fetch members, expenses, and settlements in parallel const [membersResponse, expensesResponse, settlementsResponse] = await Promise.all([ @@ -47,6 +54,7 @@ const GroupDetailsScreen = ({ route, navigation }) => { Alert.alert("Error", "Failed to fetch group details."); } finally { setIsLoading(false); + setIsRefreshing(false); } }; @@ -61,7 +69,7 @@ const GroupDetailsScreen = ({ route, navigation }) => { ), }); if (token && groupId) { - fetchData(); + fetchData(false); } }, [token, groupId]); @@ -202,6 +210,8 @@ const GroupDetailsScreen = ({ route, navigation }) => { No expenses recorded yet. } contentContainerStyle={{ paddingBottom: 80 }} // To avoid FAB overlap + onRefresh={() => fetchData(true)} + refreshing={isRefreshing} /> { const { token, logout, user } = useContext(AuthContext); const [groups, setGroups] = useState([]); const [isLoading, setIsLoading] = useState(true); + const [isRefreshing, setIsRefreshing] = useState(false); const [groupSettlements, setGroupSettlements] = useState({}); // Track settlement status for each group // State for the Create Group modal @@ -66,9 +68,15 @@ const HomeScreen = ({ navigation }) => { } }; - const fetchGroups = async () => { + const fetchGroups = async (refresh = false) => { try { - setIsLoading(true); + if (refresh) { + setIsRefreshing(true); + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + } else { + setIsLoading(true); + } + const response = await getGroups(); const groupsList = response.data.groups; setGroups(groupsList); @@ -92,12 +100,13 @@ const HomeScreen = ({ navigation }) => { Alert.alert("Error", "Failed to fetch groups."); } finally { setIsLoading(false); + setIsRefreshing(false); } }; useEffect(() => { if (token) { - fetchGroups(); + fetchGroups(false); } }, [token]); @@ -111,7 +120,7 @@ const HomeScreen = ({ navigation }) => { await createGroup(newGroupName); hideModal(); setNewGroupName(""); - await fetchGroups(); // Refresh the groups list + await fetchGroups(true); // Refresh the groups list } catch (error) { console.error("Failed to create group:", error); Alert.alert("Error", "Failed to create group."); @@ -226,7 +235,9 @@ const HomeScreen = ({ navigation }) => { - navigation.navigate("JoinGroup", { onGroupJoined: fetchGroups }) + navigation.navigate("JoinGroup", { + onGroupJoined: () => fetchGroups(true), + }) } /> @@ -246,8 +257,8 @@ const HomeScreen = ({ navigation }) => { No groups found. Create or join one! } - onRefresh={fetchGroups} - refreshing={isLoading} + onRefresh={() => fetchGroups(true)} + refreshing={isRefreshing} /> )}