diff --git a/mobile/app/(tabs)/groups/[groupId].tsx b/mobile/app/(tabs)/groups/[groupId].tsx index c1568ac..1687c92 100644 --- a/mobile/app/(tabs)/groups/[groupId].tsx +++ b/mobile/app/(tabs)/groups/[groupId].tsx @@ -1,12 +1,13 @@ 'use client'; -import React from 'react'; -import { SafeAreaView, ScrollView, View, Text, Pressable, StyleSheet, Alert, FlatList } from 'react-native'; +import React, { useCallback } from 'react'; +import { SafeAreaView, View, Text, Pressable, StyleSheet, Alert, FlatList, RefreshControl } from 'react-native'; import { useLocalSearchParams, useRouter } from 'expo-router'; import { Ionicons } from '@expo/vector-icons'; import * as Clipboard from 'expo-clipboard'; import { Badge } from '../../../components/ui/Badge'; import { MemberAvatarStack } from '../../../components/groups/MemberAvatarStack'; +import { useRefresh } from '../../../hooks/useRefresh'; import { formatXLM } from '../../../utils/stellar'; type Member = { @@ -129,6 +130,10 @@ export default function GroupDetailPage() { const params = useLocalSearchParams<{ groupId?: string }>(); const groupId = params.groupId ?? ''; const group = MOCK_GROUPS.find((item) => item.id === groupId) || null; + const refreshGroup = useCallback(async () => { + await new Promise((resolve) => setTimeout(resolve, 600)); + }, []); + const { refreshing, onRefresh } = useRefresh(refreshGroup); const sections: Section[] = group ? [{ key: 'header' }, { key: 'members' }, { key: 'overview' }, { key: 'groupId' }] @@ -190,6 +195,14 @@ export default function GroupDetailPage() { keyExtractor={(item) => item.key} renderItem={renderItem} contentContainerStyle={styles.content} + refreshControl={ + + } /> ); diff --git a/mobile/app/(tabs)/groups/page.tsx b/mobile/app/(tabs)/groups/page.tsx index d9710b0..e4ed95d 100644 --- a/mobile/app/(tabs)/groups/page.tsx +++ b/mobile/app/(tabs)/groups/page.tsx @@ -1,10 +1,11 @@ 'use client'; -import React, { useState, useCallback, useEffect } from 'react'; +import React, { useState, useCallback, useEffect, useMemo } from 'react'; import { FlatList, RefreshControl, SafeAreaView, View, Text, Pressable, StyleSheet } from 'react-native'; import { useRouter } from 'expo-router'; import { Badge, ErrorState, LoadingSkeleton, TextInput } from '../../../components/ui'; import { useDebounce } from '../../../hooks/useDebounce'; +import { useRefresh } from '../../../hooks/useRefresh'; import { formatXLM } from '../../../utils/stellar'; type GroupStatus = 'Active' | 'Open' | 'Paused' | 'Closed' | 'Pending'; @@ -83,15 +84,16 @@ function getFilteredGroups(filter: FilterKey) { export default function GroupsPage() { const router = useRouter(); const [activeFilter, setActiveFilter] = useState('All'); - const [refreshing, setRefreshing] = useState(false); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [groups, setGroups] = useState([]); const [searchQuery, setSearchQuery] = useState(''); const debouncedSearchQuery = useDebounce(searchQuery, 300); - const fetchGroups = useCallback(async () => { - setLoading(true); + const fetchGroups = useCallback(async ({ showFullLoader = true } = {}) => { + if (showFullLoader) { + setLoading(true); + } setError(null); // Simulate network delay @@ -100,12 +102,16 @@ export default function GroupsPage() { // 30% failure rate simulation if (Math.random() < 0.3) { setError('Failed to fetch groups. Please check your connection and try again.'); - setLoading(false); + if (showFullLoader) { + setLoading(false); + } return; } setGroups(MOCK_GROUPS); - setLoading(false); + if (showFullLoader) { + setLoading(false); + } }, []); useEffect(() => { @@ -123,11 +129,11 @@ export default function GroupsPage() { [activeFilter, groups, debouncedSearchQuery], ); - const onRefresh = useCallback(async () => { - setRefreshing(true); - await fetchGroups(); - setRefreshing(false); - }, [fetchGroups]); + const refreshGroups = useCallback( + () => fetchGroups({ showFullLoader: false }), + [fetchGroups], + ); + const { refreshing, onRefresh } = useRefresh(refreshGroups); // useCallback: stable reference prevents FlatList from re-rendering all items on parent update const renderGroup = useCallback(({ item }: { item: Group }) => ( diff --git a/mobile/app/_layout.tsx b/mobile/app/_layout.tsx index 7405019..d00c19d 100644 --- a/mobile/app/_layout.tsx +++ b/mobile/app/_layout.tsx @@ -1,4 +1,5 @@ import AsyncStorage from '@react-native-async-storage/async-storage'; +import { QueryClientProvider } from '@tanstack/react-query'; import * as Notifications from 'expo-notifications'; import { Slot, useRouter } from 'expo-router'; import React, { useCallback, useEffect, useRef, useState } from 'react'; @@ -17,6 +18,7 @@ import { ThemeProvider, useTheme } from '../context/ThemeContext'; import { useAutoLock } from '../hooks/useAutoLock'; import { loadLanguage } from '../constants/i18n'; import { getRouteFromNotificationData } from '../services/notifications/notificationRouting'; +import { queryClient } from '../services/queryClient'; import { biometricService } from '../services/security'; import { logger } from '../utils/logger'; @@ -227,9 +229,11 @@ function RootLayoutContent() { export default function RootLayout() { return ( - - - + + + + + ); } diff --git a/mobile/app/groups/[groupId].tsx b/mobile/app/groups/[groupId].tsx index fc6cafa..e00d4b4 100644 --- a/mobile/app/groups/[groupId].tsx +++ b/mobile/app/groups/[groupId].tsx @@ -1,7 +1,7 @@ 'use client'; import React, { useState, useEffect, useCallback, useMemo, memo } from 'react'; -import { View, Text, ScrollView, StyleSheet, Alert, TouchableOpacity, Share, SafeAreaView, Pressable } from 'react-native'; +import { View, Text, ScrollView, StyleSheet, Alert, TouchableOpacity, Share, SafeAreaView, Pressable, RefreshControl } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { useLocalSearchParams, useRouter } from 'expo-router'; import { MemberAvatarStack } from '../../components/groups/MemberAvatarStack'; @@ -9,6 +9,7 @@ import { SectionHeader } from '../../components/ui/SectionHeader'; import { Card } from '../../components/ui/Card'; import { Divider } from '../../components/ui/Divider'; import { LoadingSkeleton } from '../../components/ui/LoadingSkeleton'; +import { useRefresh } from '../../hooks/useRefresh'; interface Member { address: string; @@ -197,6 +198,12 @@ export default function GroupDetailScreen() { }, 1000); }, [groupId]); + const refreshGroup = useCallback(async () => { + await new Promise((r) => setTimeout(r, 600)); + setGroup((current) => (current ? { ...current } : current)); + }, []); + const { refreshing, onRefresh } = useRefresh(refreshGroup); + const handleTabPress = useCallback( async (tab: TabKey) => { if (tab === activeTab) return; @@ -256,7 +263,18 @@ export default function GroupDetailScreen() { return ( - + + } + > {/* Header */} router.back()} style={styles.backButton}> diff --git a/mobile/app/transaction/history.tsx b/mobile/app/transaction/history.tsx index f77d8c8..7b3540e 100644 --- a/mobile/app/transaction/history.tsx +++ b/mobile/app/transaction/history.tsx @@ -11,6 +11,7 @@ import { } from 'react-native'; import { useRouter } from 'expo-router'; import { TransactionItem, TransactionType } from '../../components/transactions/TransactionItem'; +import { useRefresh } from '../../hooks/useRefresh'; type TabKey = 'All' | 'Contributions' | 'Payouts'; @@ -84,7 +85,6 @@ const getItemLayout = (_: unknown, index: number) => ({ export default function TransactionHistoryScreen() { const router = useRouter(); const [activeTab, setActiveTab] = useState('All'); - const [refreshing, setRefreshing] = useState(false); const [data, setData] = useState(MOCK_TRANSACTIONS); const counts = useMemo>( @@ -101,13 +101,12 @@ export default function TransactionHistoryScreen() { return typeFilter ? data.filter((t) => t.type === typeFilter) : data; }, [activeTab, data]); - const onRefresh = useCallback(async () => { - setRefreshing(true); + const refreshTransactions = useCallback(async () => { // Replace with real fetch await new Promise((r) => setTimeout(r, 800)); setData([...MOCK_TRANSACTIONS]); - setRefreshing(false); }, []); + const { refreshing, onRefresh } = useRefresh(refreshTransactions); const renderItem = useCallback( ({ item }: ListRenderItemInfo) => ( diff --git a/mobile/app/transactions/index.tsx b/mobile/app/transactions/index.tsx index 1e846b7..d446ca9 100644 --- a/mobile/app/transactions/index.tsx +++ b/mobile/app/transactions/index.tsx @@ -6,12 +6,14 @@ import { StyleSheet, ActivityIndicator, TouchableOpacity, + RefreshControl, } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { Ionicons } from '@expo/vector-icons'; import { useRouter } from 'expo-router'; import { TransactionItem, TransactionType } from '../../components/transactions/TransactionItem'; import { EmptyState } from '../../components/ui/EmptyState'; +import { useRefresh } from '../../hooks/useRefresh'; interface Transaction { id: string; @@ -42,24 +44,39 @@ export default function TransactionHistory() { const [loadingMore, setLoadingMore] = useState(false); const [hasMore, setHasMore] = useState(true); - const loadPage = useCallback(async (pageNum: number, reset = false) => { - if (pageNum === 0) setLoading(true); else setLoadingMore(true); - await new Promise((r) => setTimeout(r, 600)); - const data = generateMockTransactions(pageNum); - setTransactions((prev) => (reset ? data : [...prev, ...data])); - setHasMore(pageNum < 2); - setPage(pageNum); - setLoading(false); - setLoadingMore(false); + const loadPage = useCallback(async ( + pageNum: number, + { reset = false, showFullLoader = true } = {}, + ) => { + if (pageNum === 0 && showFullLoader) { + setLoading(true); + } else if (pageNum > 0) { + setLoadingMore(true); + } + + try { + await new Promise((r) => setTimeout(r, 600)); + const data = generateMockTransactions(pageNum); + setTransactions((prev) => (reset ? data : [...prev, ...data])); + setHasMore(pageNum < 2); + setPage(pageNum); + } finally { + setLoading(false); + setLoadingMore(false); + } }, []); useEffect(() => { loadPage(0); }, [loadPage]); - const handleRefresh = useCallback(() => loadPage(0, true), [loadPage]); + const handleRefresh = useCallback( + () => loadPage(0, { reset: true, showFullLoader: false }), + [loadPage], + ); + const { refreshing, onRefresh } = useRefresh(handleRefresh); const handleLoadMore = useCallback(() => { - if (!loadingMore && hasMore) loadPage(page + 1); - }, [loadingMore, hasMore, page, loadPage]); + if (!loadingMore && !refreshing && hasMore) loadPage(page + 1); + }, [loadingMore, refreshing, hasMore, page, loadPage]); return ( @@ -92,8 +109,14 @@ export default function TransactionHistory() { transactions.length === 0 && styles.listEmpty, ]} ItemSeparatorComponent={() => } - onRefresh={handleRefresh} - refreshing={loading} + refreshControl={ + + } onEndReached={handleLoadMore} onEndReachedThreshold={0.3} ListEmptyComponent={ diff --git a/mobile/hooks/useGroups.ts b/mobile/hooks/useGroups.ts index ffde16d..126b39a 100644 --- a/mobile/hooks/useGroups.ts +++ b/mobile/hooks/useGroups.ts @@ -1,3 +1,4 @@ +import { useCallback } from 'react'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { groupsApi } from '../services/api/groupsApi'; import { queryKeys } from '../services/queryClient'; @@ -20,5 +21,8 @@ export function useGroupById(groupId: string) { export function useInvalidateGroups() { const queryClient = useQueryClient(); - return () => queryClient.invalidateQueries({ queryKey: queryKeys.groups.all }); + return useCallback( + () => queryClient.invalidateQueries({ queryKey: queryKeys.groups.all }), + [queryClient], + ); } diff --git a/mobile/hooks/useNotifications.ts b/mobile/hooks/useNotifications.ts index c555241..9b2cd26 100644 --- a/mobile/hooks/useNotifications.ts +++ b/mobile/hooks/useNotifications.ts @@ -1,3 +1,4 @@ +import { useCallback } from 'react'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { notificationsApi } from '../services/api/notificationsApi'; import { queryKeys } from '../services/queryClient'; @@ -12,5 +13,8 @@ export function useUserNotifications(userAddress: string) { export function useInvalidateNotifications() { const queryClient = useQueryClient(); - return () => queryClient.invalidateQueries({ queryKey: queryKeys.notifications.all }); + return useCallback( + () => queryClient.invalidateQueries({ queryKey: queryKeys.notifications.all }), + [queryClient], + ); } diff --git a/mobile/hooks/useRefresh.ts b/mobile/hooks/useRefresh.ts index 9377922..7bdd7ff 100644 --- a/mobile/hooks/useRefresh.ts +++ b/mobile/hooks/useRefresh.ts @@ -1,4 +1,4 @@ -import { useState, useCallback } from 'react'; +import { useCallback, useRef, useState } from 'react'; /** * Hook that manages pull-to-refresh state. @@ -8,14 +8,25 @@ import { useState, useCallback } from 'react'; */ export function useRefresh(fetchFn: () => Promise) { const [refreshing, setRefreshing] = useState(false); + const refreshPromiseRef = useRef | null>(null); const onRefresh = useCallback(async () => { - setRefreshing(true); - try { - await fetchFn(); - } finally { - setRefreshing(false); + if (refreshPromiseRef.current) { + return refreshPromiseRef.current; } + + setRefreshing(true); + const refreshPromise = (async () => { + try { + await fetchFn(); + } finally { + refreshPromiseRef.current = null; + setRefreshing(false); + } + })(); + + refreshPromiseRef.current = refreshPromise; + return refreshPromise; }, [fetchFn]); return { refreshing, onRefresh }; diff --git a/mobile/hooks/useTransactions.ts b/mobile/hooks/useTransactions.ts index ec9e0f9..d981b1f 100644 --- a/mobile/hooks/useTransactions.ts +++ b/mobile/hooks/useTransactions.ts @@ -1,3 +1,4 @@ +import { useCallback } from 'react'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { transactionsApi } from '../services/api/transactionsApi'; import { queryKeys } from '../services/queryClient'; @@ -12,5 +13,8 @@ export function useUserTransactions(userAddress: string) { export function useInvalidateTransactions() { const queryClient = useQueryClient(); - return () => queryClient.invalidateQueries({ queryKey: queryKeys.transactions.all }); + return useCallback( + () => queryClient.invalidateQueries({ queryKey: queryKeys.transactions.all }), + [queryClient], + ); }