Skip to content
Open
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
139 changes: 137 additions & 2 deletions app/(tabs)/history.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,75 @@ const ListFooter = ({
return null;
};

/**
* Non-blocking banner shown when a refresh fails but cached transactions are
* still available. Keeps the list visible and offers an inline retry.
*/
const RefreshErrorBanner = ({
message,
onRetry,
isRetrying,
colors,
styles,
}: {
message: string;
onRetry: () => void;
isRetrying: boolean;
colors: ThemeColors;
styles: ReturnType<typeof createStyles>;
}) => (
<View style={styles.refreshErrorBanner} testID="refresh-error-banner">
<Text style={styles.refreshErrorText}>{message}</Text>
<TouchableOpacity
style={styles.refreshErrorRetryButton}
onPress={onRetry}
disabled={isRetrying}
accessibilityRole="button"
>
{isRetrying ? (
<ActivityIndicator size="small" color={colors.background} />
) : (
<Text style={styles.refreshErrorRetryText}>Retry</Text>
)}
</TouchableOpacity>
</View>
);

/**
* Full-screen error shown when a refresh fails and there is no cached activity
* to display.
*/
const ActivityErrorState = ({
onRetry,
isRetrying,
colors,
styles,
}: {
onRetry: () => void;
isRetrying: boolean;
colors: ThemeColors;
styles: ReturnType<typeof createStyles>;
}) => (
<View style={styles.fullScreenError} testID="activity-error-state">
<Text style={styles.fullScreenErrorTitle}>Couldn't load activity</Text>
<Text style={styles.fullScreenErrorMessage}>
We couldn't refresh your activity right now. Please try again.
</Text>
<TouchableOpacity
style={styles.fullScreenErrorRetryButton}
onPress={onRetry}
disabled={isRetrying}
accessibilityRole="button"
>
{isRetrying ? (
<ActivityIndicator size="small" color={colors.background} />
) : (
<Text style={styles.fullScreenErrorRetryText}>Retry</Text>
)}
</TouchableOpacity>
</View>
);

/**
* Shown when there are no transactions and the screen is not loading.
*/
Expand Down Expand Up @@ -248,6 +317,15 @@ export default function HistoryScreen() {
onRetry={() => { refreshWalletData(); retry(); }}
isRetrying={isLoading}
/>
{error && transactions.length > 0 && (
<RefreshErrorBanner
message="Couldn't refresh activity. Showing cached transactions."
onRetry={() => { refreshWalletData(); retry(); }}
isRetrying={isLoading}
colors={colors}
styles={styles}
/>
)}
<View style={styles.filterContainer}>
<ScrollView
horizontal
Expand Down Expand Up @@ -278,7 +356,14 @@ export default function HistoryScreen() {
}
ListFooterComponent={renderFooter}
ListEmptyComponent={
!isLoading ? (
error && transactions.length === 0 ? (
<ActivityErrorState
onRetry={() => { refreshWalletData(); retry(); }}
isRetrying={isLoading}
colors={colors}
styles={styles}
/>
) : !isLoading ? (
<ActivityEmptyState
colors={colors}
styles={styles}
Expand All @@ -287,7 +372,14 @@ export default function HistoryScreen() {
) : null
}
// Avoid stale closures while also keeping rendering performant.
extraData={{ isLoadingMore, hasMoreTransactions, colors, styles }}
extraData={{
isLoadingMore,
hasMoreTransactions,
colors,
styles,
error,
hasTransactions: transactions.length > 0,
}}
/>
</View>
);
Expand Down Expand Up @@ -332,6 +424,49 @@ const createStyles = (colors: ThemeColors) => StyleSheet.create({
color: colors.textMuted,
fontSize: 13,
},
refreshErrorBanner: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
backgroundColor: colors.surface,
borderLeftWidth: 3,
borderLeftColor: colors.error,
borderRadius: RADIUS.md,
paddingHorizontal: SIZES.md,
paddingVertical: SIZES.sm,
marginBottom: SIZES.md,
},
refreshErrorText: {
flex: 1,
color: colors.textSecondary,
fontSize: 14,
marginRight: SIZES.sm,
},
refreshErrorRetryButton: {
backgroundColor: colors.error,
borderRadius: RADIUS.md,
paddingHorizontal: SIZES.md,
paddingVertical: SIZES.xs,
minWidth: 64,
alignItems: 'center',
},
refreshErrorRetryText: {
color: colors.background,
fontSize: 14,
fontWeight: '600',
},
fullScreenError: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
padding: SIZES.xl,
},
fullScreenErrorTitle: {
color: colors.textSecondary,
fontSize: 18,
fontWeight: '600',
marginBottom: SIZES.sm,

sectionHeader: {
paddingTop: SIZES.sm,
paddingBottom: SIZES.xs,
Expand Down
43 changes: 15 additions & 28 deletions src/components/ErrorState.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export interface ErrorStateProps {
action?: ErrorStateAction;
style?: StyleProp<ViewStyle>;
testID?: string;
variant?: 'fullscreen' | 'banner';
}

export const ErrorState: React.FC<ErrorStateProps> = ({
Expand All @@ -27,9 +28,10 @@ export const ErrorState: React.FC<ErrorStateProps> = ({
action,
style,
testID = 'error-state',
variant = 'fullscreen',
}) => {
const { colors } = useTheme();
const styles = useMemo(() => createStyles(colors), [colors]);
const styles = useMemo(() => createStyles(colors, variant), [colors, variant]);

return (
<View
Expand All @@ -39,10 +41,12 @@ export const ErrorState: React.FC<ErrorStateProps> = ({
testID={testID}
>
<View style={styles.iconWrapper}>
{icon ?? <AlertTriangle color={colors.error} size={48} />}
{icon ?? <AlertTriangle color={colors.error} size={variant === 'banner' ? 20 : 48} />}
</View>
<View style={styles.contentWrapper}>
<Text style={styles.title}>{title}</Text>
{message ? <Text style={styles.message}>{message}</Text> : null}
</View>
<Text style={styles.title}>{title}</Text>
{message ? <Text style={styles.message}>{message}</Text> : null}
{action ? (
<Button
title={action.label}
Expand All @@ -55,31 +59,14 @@ export const ErrorState: React.FC<ErrorStateProps> = ({
);
};

const createStyles = (colors: ThemeColors) =>
const createStyles = (colors: ThemeColors, variant: 'fullscreen' | 'banner') =>
StyleSheet.create({
container: {
alignItems: 'center',
justifyContent: 'center',
padding: SIZES.xl,
},
iconWrapper: {
marginBottom: SIZES.md,
},
title: {
color: colors.textPrimary,
fontSize: 18,
fontWeight: 'bold',
textAlign: 'center',
marginBottom: SIZES.xs,
},
message: {
color: colors.textSecondary,
fontSize: 14,
textAlign: 'center',
lineHeight: 20,
},
action: {
marginTop: SIZES.lg,
minWidth: 140,
...(variant === 'fullscreen' ? { alignItems: 'center', justifyContent: 'center', padding: SIZES.xl } : { flexDirection: 'row', alignItems: 'center', padding: SIZES.md, borderWidth: 1, borderColor: colors.error, borderRadius: SIZES.sm, backgroundColor: colors.background }),
},
contentWrapper: { flex: 1, marginRight: SIZES.sm },
iconWrapper: { ...(variant === 'fullscreen' ? { marginBottom: SIZES.md } : { marginRight: SIZES.sm }), },
title: { color: colors.textPrimary, fontSize: variant === 'banner' ? 14 : 18, fontWeight: 'bold', textAlign: variant === 'banner' ? 'left' : 'center', marginBottom: SIZES.xs },
message: { color: colors.textSecondary, fontSize: variant === 'banner' ? 12 : 14, textAlign: variant === 'banner' ? 'left' : 'center', lineHeight: variant === 'banner' ? 16 : 20 },
action: { ...(variant === 'fullscreen' ? { marginTop: SIZES.lg, minWidth: 140 } : { marginLeft: SIZES.sm, minWidth: 80 }), },
});
4 changes: 4 additions & 0 deletions src/components/NetworkStateBanner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,22 @@ interface NetworkStateBannerProps {
onRetry?: () => void;
/** True while a re-check or refresh is in flight. */
isRetrying?: boolean;
/** True when there is existing activity data to display. */
hasData?: boolean;
}

export const NetworkStateBanner: React.FC<NetworkStateBannerProps> = ({
state,
onRetry,
isRetrying = false,
hasData = false,
}: NetworkStateBannerProps) => {
return (
<NetworkStatusBanner
state={state}
onRetry={onRetry}
isRetrying={isRetrying}
variant={hasData ? 'banner' : 'fullscreen' }
testID="network-state-banner"
retryTestID="network-state-retry"
/>
Expand Down
28 changes: 26 additions & 2 deletions src/features/transactions/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ export function formatTransactionAmount(
const prefix = isSent ? '-' : '+';
const asset = tx.asset || 'XLM';

return `${prefix}${amount} ${asset}`;
return `${prefix}${amount} {asset}`;
}

/**
Expand Down Expand Up @@ -87,7 +87,7 @@ export function getTransactionMemo(tx: TransactionDetail): {

return {
text: tx.memo,
type: tx.memo_type || '',
type: tx.memo_type || ''
};
}

Expand Down Expand Up @@ -119,3 +119,27 @@ export function getDirectionLabel(
const isSent = isSentTransaction(tx, currentPublicKey);
return isSent ? 'Sent' : 'Received';
}

/**
* Error states for transaction activity refresh
*/
export type RefreshErrorState = 'none' | 'banner' | 'fullscreen';

/**
* Determines how a refresh error should be presented.
* If there's existing transaction data, show a non-blocking banner.
* Otherwise, show a full-screen error.
*/
export function getRefreshErrorState(
hasExistingData: boolean,
hasError: boolean
): RefreshErrorState {
if (!hasError) return 'none';
return hasExistingData ? 'banner' : 'fullscreen';
}

/**
* Non-sensitive error message for activity refresh failures.
*/
export const REFRESH_ERROR_MESSAGE =
'Unable to refresh activity. Please check your connection and try again.';
11 changes: 10 additions & 1 deletion src/features/transactions/types.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/**
* Transaction feature types and interfaces
*/
*/

export interface TransactionDetail {
id: string;
Expand Down Expand Up @@ -40,3 +40,12 @@ export interface TransactionMetadata {
status: TransactionStatus;
counterparty: string | null;
}

// Activity state types for refresh error handling
export interface ActivityState {
transactions: TransactionDetail[];
isLoading: boolean;
isRefreshing: boolean;
fullScreenError: string | null;
refreshError: string | null;
}
11 changes: 8 additions & 3 deletions src/store/walletStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ interface WalletState {
isFunding: boolean;
fundError: string | null;
error: string | null;
refreshError: string | null;
showBackupReminder: boolean;
/** True once the initial `loadWalletFromStorage` call has resolved (success or failure). */
walletChecked: boolean;
Expand Down Expand Up @@ -92,6 +93,7 @@ const resetWalletState = () => ({
nextCursor: null,
balanceState: 'idle' as BalanceState,
fundingStatus: 'unknown' as FundingStatus,
refreshError: null,
});

const parseStoredSecret = (storedValue: string): string | null => {
Expand Down Expand Up @@ -137,6 +139,7 @@ export const useWalletStore = create<WalletState>((set, get) => ({
isFunding: false,
fundError: null,
error: null,
refreshError: null,
balanceState: 'idle' as BalanceState,
fundingStatus: 'unknown' as FundingStatus,
isLoadingMore: false,
Expand Down Expand Up @@ -223,7 +226,7 @@ export const useWalletStore = create<WalletState>((set, get) => ({
const { publicKey } = get();
if (!publicKey) return;

set({ isLoading: true, error: null, balanceState: 'loading', isLoadingMore: false, nextCursor: null, hasMoreTransactions: false });
set({ isLoading: true, error: null, refreshError: null, balanceState: 'loading', isLoadingMore: false, nextCursor: null, hasMoreTransactions: false });
try {
const [balance, page] = await Promise.all([
fetchXlmBalance(publicKey),
Expand Down Expand Up @@ -258,10 +261,12 @@ export const useWalletStore = create<WalletState>((set, get) => ({
});
} catch (err: any) {
console.error('Failed to refresh wallet data');
const hasExistingData = get().transactions.length > 0;
set({
isLoading: false,
balanceState: 'unavailable',
error: err.message || 'Failed to sync data',
balanceState: hasExistingData ? 'available' : 'unavailable',
error: hasExistingData ? null : (err.message || 'Failed to sync data'),
refreshError: hasExistingData ? (err.message || 'Failed to sync data') : null,
});
}
},
Expand Down
Loading