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