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