From cc7fde6bbd43edc96538494aa916d5f319d8503f Mon Sep 17 00:00:00 2001 From: MaryammAli Date: Fri, 29 May 2026 15:44:18 +0100 Subject: [PATCH 1/2] Implement-Pull-to-Refresh-Across-Screens Implement-Pull-to-Refresh-Across-Screens --- mobile/app/(tabs)/groups/[groupId].tsx | 17 ++++++++- mobile/app/(tabs)/groups/page.tsx | 28 ++++++++------ mobile/app/_layout.tsx | 10 +++-- mobile/app/groups/[groupId].tsx | 22 ++++++++++- mobile/app/transaction/history.tsx | 7 ++-- mobile/app/transactions/index.tsx | 51 +++++++++++++++++++------- mobile/hooks/useGroups.ts | 6 ++- mobile/hooks/useNotifications.ts | 6 ++- mobile/hooks/useRefresh.ts | 23 +++++++++--- mobile/hooks/useTransactions.ts | 6 ++- 10 files changed, 131 insertions(+), 45 deletions(-) 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], + ); } From f3cce540605332c94157ce5ead5eb301cc08babe Mon Sep 17 00:00:00 2001 From: MaryammAli Date: Fri, 29 May 2026 16:10:09 +0100 Subject: [PATCH 2/2] Schedule-Background-Sync-Jobs Schedule-Background-Sync-Jobs --- mobile/package.json | 2 + mobile/services/sync/backgroundSync.ts | 10 +- mobile/services/sync/scheduler.ts | 141 +++++++++++++++++++++++++ 3 files changed, 148 insertions(+), 5 deletions(-) create mode 100644 mobile/services/sync/scheduler.ts diff --git a/mobile/package.json b/mobile/package.json index dfb8bf2..aeae517 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -18,6 +18,7 @@ "@react-native-async-storage/async-storage": "^1.21.0", "@tanstack/react-query": "^5.0.0", "expo": "~51.0.0", + "expo-background-fetch": "~12.0.1", "expo-crypto": "^55.0.14", "expo-haptics": "~12.8.1", "expo-image": "~2.0.0", @@ -27,6 +28,7 @@ "expo-router": "~3.5.0", "expo-secure-store": "^55.0.13", "expo-status-bar": "~1.12.1", + "expo-task-manager": "~11.8.2", "i18next": "^24.0.0", "react": "18.2.0", "react-i18next": "^14.0.0", diff --git a/mobile/services/sync/backgroundSync.ts b/mobile/services/sync/backgroundSync.ts index 91f5414..02d62ad 100644 --- a/mobile/services/sync/backgroundSync.ts +++ b/mobile/services/sync/backgroundSync.ts @@ -84,7 +84,7 @@ class BackgroundSyncService { } const wallet = useAuthStore.getState().wallet; - if (!wallet?.address) { + if (!wallet?.publicKey) { console.log('[SyncService] No wallet connected, skipping sync'); return; } @@ -94,15 +94,15 @@ class BackgroundSyncService { try { if (options.syncGroups !== false) { - await this.syncGroups(wallet.address); + await this.syncGroups(wallet.publicKey); } if (options.syncTransactions !== false) { - await this.syncTransactions(wallet.address); + await this.syncTransactions(wallet.publicKey); } if (options.syncNotifications !== false) { - await this.syncNotifications(wallet.address); + await this.syncNotifications(wallet.publicKey); } this.lastSyncTime = Date.now(); @@ -175,4 +175,4 @@ export function useBackgroundSync(options?: SyncOptions) { forceSync: () => syncService.forceSync(options), getTimeSinceLastSync: () => syncService.getTimeSinceLastSync(), }; -} \ No newline at end of file +} diff --git a/mobile/services/sync/scheduler.ts b/mobile/services/sync/scheduler.ts new file mode 100644 index 0000000..b0851e9 --- /dev/null +++ b/mobile/services/sync/scheduler.ts @@ -0,0 +1,141 @@ +/** + * Background sync scheduler + * + * Expo SDK 51 supports background fetch through expo-background-fetch and + * expo-task-manager. The OS ultimately decides exact run timing, so this file + * keeps the requested cadence conservative and adds an app-level throttle. + */ + +import AsyncStorage from '@react-native-async-storage/async-storage'; +import * as BackgroundFetch from 'expo-background-fetch'; +import * as TaskManager from 'expo-task-manager'; +import { useAuthStore } from '../../store/authStore'; +import { syncService } from './backgroundSync'; + +export const BACKGROUND_SYNC_TASK_NAME = 'esustellar-background-sync'; + +const LAST_BACKGROUND_SYNC_KEY = 'esustellar:last-background-sync-at'; + +export interface BackgroundSyncSchedulerConfig { + /** + * Requested minimum interval in seconds. Expo/OS scheduling is advisory. + * Android enforces practical minimums, and iOS may run less frequently. + */ + minimumIntervalSeconds: number; + /** + * Local throttle to prevent clustered runs from draining battery. + */ + minimumElapsedBetweenRunsMs: number; + /** + * Android only. Keeps the job registered after app termination. + */ + stopOnTerminate: boolean; + /** + * Android only. Re-registers after device boot when the OS allows it. + */ + startOnBoot: boolean; +} + +export const DEFAULT_BACKGROUND_SYNC_SCHEDULER_CONFIG: BackgroundSyncSchedulerConfig = { + minimumIntervalSeconds: 30 * 60, + minimumElapsedBetweenRunsMs: 25 * 60 * 1000, + stopOnTerminate: false, + startOnBoot: true, +}; + +let activeConfig = DEFAULT_BACKGROUND_SYNC_SCHEDULER_CONFIG; +let taskRunning = false; + +async function getLastBackgroundSyncAt(): Promise { + const stored = await AsyncStorage.getItem(LAST_BACKGROUND_SYNC_KEY); + const parsed = stored ? Number(stored) : 0; + return Number.isFinite(parsed) ? parsed : 0; +} + +async function setLastBackgroundSyncAt(timestamp: number): Promise { + await AsyncStorage.setItem(LAST_BACKGROUND_SYNC_KEY, String(timestamp)); +} + +async function runScheduledSync(): Promise { + if (taskRunning) { + return BackgroundFetch.BackgroundFetchResult.NoData; + } + + const wallet = useAuthStore.getState().wallet; + if (!wallet?.publicKey) { + return BackgroundFetch.BackgroundFetchResult.NoData; + } + + const now = Date.now(); + const lastSyncAt = await getLastBackgroundSyncAt(); + if (now - lastSyncAt < activeConfig.minimumElapsedBetweenRunsMs) { + return BackgroundFetch.BackgroundFetchResult.NoData; + } + + taskRunning = true; + + try { + await syncService.sync(); + await setLastBackgroundSyncAt(Date.now()); + return BackgroundFetch.BackgroundFetchResult.NewData; + } catch (error) { + console.error('[BackgroundSyncScheduler] Background sync failed:', error); + return BackgroundFetch.BackgroundFetchResult.Failed; + } finally { + taskRunning = false; + } +} + +TaskManager.defineTask(BACKGROUND_SYNC_TASK_NAME, runScheduledSync); + +export async function getBackgroundSyncSchedulerStatus() { + const [status, isRegistered] = await Promise.all([ + BackgroundFetch.getStatusAsync(), + TaskManager.isTaskRegisteredAsync(BACKGROUND_SYNC_TASK_NAME), + ]); + + return { status, isRegistered }; +} + +export async function registerBackgroundSyncScheduler( + config: Partial = {}, +): Promise { + activeConfig = { + ...DEFAULT_BACKGROUND_SYNC_SCHEDULER_CONFIG, + ...config, + }; + + const status = await BackgroundFetch.getStatusAsync(); + if (status !== BackgroundFetch.BackgroundFetchStatus.Available) { + console.warn('[BackgroundSyncScheduler] Background fetch unavailable:', status); + return false; + } + + const isRegistered = await TaskManager.isTaskRegisteredAsync(BACKGROUND_SYNC_TASK_NAME); + if (isRegistered) { + return true; + } + + await BackgroundFetch.registerTaskAsync(BACKGROUND_SYNC_TASK_NAME, { + minimumInterval: activeConfig.minimumIntervalSeconds, + stopOnTerminate: activeConfig.stopOnTerminate, + startOnBoot: activeConfig.startOnBoot, + }); + + console.log('[BackgroundSyncScheduler] Registered background sync task'); + return true; +} + +export async function unregisterBackgroundSyncScheduler(): Promise { + const isRegistered = await TaskManager.isTaskRegisteredAsync(BACKGROUND_SYNC_TASK_NAME); + if (!isRegistered) { + return; + } + + await BackgroundFetch.unregisterTaskAsync(BACKGROUND_SYNC_TASK_NAME); + console.log('[BackgroundSyncScheduler] Unregistered background sync task'); +} + +export async function runBackgroundSyncNow(): Promise { + return runScheduledSync(); +}