Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions mobile/app/(tabs)/groups/[groupId].tsx
Original file line number Diff line number Diff line change
@@ -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 = {
Expand Down Expand Up @@ -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' }]
Expand Down Expand Up @@ -190,6 +195,14 @@ export default function GroupDetailPage() {
keyExtractor={(item) => item.key}
renderItem={renderItem}
contentContainerStyle={styles.content}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
tintColor="#0F172A"
colors={['#0F172A']}
/>
}
/>
</SafeAreaView>
);
Expand Down
28 changes: 17 additions & 11 deletions mobile/app/(tabs)/groups/page.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -83,15 +84,16 @@ function getFilteredGroups(filter: FilterKey) {
export default function GroupsPage() {
const router = useRouter();
const [activeFilter, setActiveFilter] = useState<FilterKey>('All');
const [refreshing, setRefreshing] = useState(false);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [groups, setGroups] = useState<Group[]>([]);
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
Expand All @@ -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(() => {
Expand All @@ -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 }) => (
Expand Down
10 changes: 7 additions & 3 deletions mobile/app/_layout.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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';

Expand Down Expand Up @@ -227,9 +229,11 @@ function RootLayoutContent() {

export default function RootLayout() {
return (
<ThemeProvider>
<RootLayoutContent />
</ThemeProvider>
<QueryClientProvider client={queryClient}>
<ThemeProvider>
<RootLayoutContent />
</ThemeProvider>
</QueryClientProvider>
);
}

Expand Down
22 changes: 20 additions & 2 deletions mobile/app/groups/[groupId].tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
'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';
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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -256,7 +263,18 @@ export default function GroupDetailScreen() {

return (
<SafeAreaView style={styles.container}>
<ScrollView style={styles.scrollView} showsVerticalScrollIndicator={false}>
<ScrollView
style={styles.scrollView}
showsVerticalScrollIndicator={false}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
tintColor="#6366F1"
colors={['#6366F1']}
/>
}
>
{/* Header */}
<View style={styles.header}>
<TouchableOpacity onPress={() => router.back()} style={styles.backButton}>
Expand Down
7 changes: 3 additions & 4 deletions mobile/app/transaction/history.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -84,7 +85,6 @@ const getItemLayout = (_: unknown, index: number) => ({
export default function TransactionHistoryScreen() {
const router = useRouter();
const [activeTab, setActiveTab] = useState<TabKey>('All');
const [refreshing, setRefreshing] = useState(false);
const [data, setData] = useState(MOCK_TRANSACTIONS);

const counts = useMemo<Record<TabKey, number>>(
Expand All @@ -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<Transaction>) => (
Expand Down
51 changes: 37 additions & 14 deletions mobile/app/transactions/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 (
<SafeAreaView style={styles.container}>
Expand Down Expand Up @@ -92,8 +109,14 @@ export default function TransactionHistory() {
transactions.length === 0 && styles.listEmpty,
]}
ItemSeparatorComponent={() => <View style={styles.separator} />}
onRefresh={handleRefresh}
refreshing={loading}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
tintColor="#6366F1"
colors={['#6366F1']}
/>
}
onEndReached={handleLoadMore}
onEndReachedThreshold={0.3}
ListEmptyComponent={
Expand Down
6 changes: 5 additions & 1 deletion mobile/hooks/useGroups.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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],
);
}
6 changes: 5 additions & 1 deletion mobile/hooks/useNotifications.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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],
);
}
23 changes: 17 additions & 6 deletions mobile/hooks/useRefresh.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useState, useCallback } from 'react';
import { useCallback, useRef, useState } from 'react';

/**
* Hook that manages pull-to-refresh state.
Expand All @@ -8,14 +8,25 @@ import { useState, useCallback } from 'react';
*/
export function useRefresh(fetchFn: () => Promise<void>) {
const [refreshing, setRefreshing] = useState(false);
const refreshPromiseRef = useRef<Promise<void> | 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 };
Expand Down
6 changes: 5 additions & 1 deletion mobile/hooks/useTransactions.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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],
);
}
Loading