diff --git a/src/components/BalanceDisplay.tsx b/src/components/BalanceDisplay.tsx new file mode 100644 index 0000000..96b1678 --- /dev/null +++ b/src/components/BalanceDisplay.tsx @@ -0,0 +1,266 @@ +/** + * BalanceDisplay + * + * Renders the wallet balance with support for all balance states: + * - loading: shimmer / spinner while the balance is being fetched + * - available: shows the numeric balance (may be zero or positive) + * - unavailable: shows a friendly error with a retry button + * - idle: no fetch attempted yet + * + * Accessibility: uses accessibilityRole="header" for the balance value and + * accessibilityRole="alert" for the unavailable state. + */ + +import React, { useMemo } from 'react'; +import { View, Text, StyleSheet, ActivityIndicator, TouchableOpacity } from 'react-native'; +import { RefreshCw, AlertTriangle, EyeOff } from 'lucide-react-native'; +import { SIZES, RADIUS, ThemeColors } from '../constants/theme'; +import { useTheme } from '../hooks/useTheme'; +import type { BalanceState } from '../types/balance'; +import { describeBalanceState } from '../types/balance'; +import { formatAmount } from '../utils/amount'; + +export interface BalanceDisplayProps { + /** The current balance state. */ + state: BalanceState; + /** The numeric balance, only meaningful when state is 'available'. */ + balance: string; + /** The wallet public key for display. */ + publicKey?: string | null; + /** Called when the user taps the retry / refresh action. */ + onRetry?: () => void; + /** True while a retry is in flight. */ + isRetrying?: boolean; + /** Optional label above the balance (e.g. "Total Balance (Testnet)"). */ + label?: string; + /** Optional last-refreshed timestamp (epoch ms). */ + lastRefreshed?: number | null | undefined; +} + +function formatRelativeTime(timestamp: number): string { + const seconds = Math.floor((Date.now() - timestamp) / 1000); + if (seconds < 60) return 'just now'; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + return `${Math.floor(hours / 24)}d ago`; +} + +export const BalanceDisplay: React.FC = ({ + state, + balance, + publicKey, + onRetry, + isRetrying = false, + label = 'Total Balance (Testnet)', + lastRefreshed, +}) => { + const { colors } = useTheme(); + const styles = useMemo(() => createStyles(colors), [colors]); + const copy = useMemo(() => describeBalanceState(state), [state]); + + // ── Loading state ───────────────────────────────────────────────── + if (state === 'idle' || state === 'loading') { + return ( + + {label} + + + {copy.title} + + {publicKey ? ( + + {publicKey} + + ) : null} + + ); + } + + // ── Unavailable state ──────────────────────────────────────────── + if (state === 'unavailable') { + return ( + + + + {copy.title} + + {copy.message} + {publicKey ? ( + + {publicKey} + + ) : null} + {onRetry && copy.retryLabel ? ( + + + + {isRetrying ? 'Refreshing…' : copy.retryLabel} + + + ) : null} + + ); + } + + // ── Available state (zero or positive) ─────────────────────────── + const isZero = balance === '0.0000000' || parseFloat(balance) === 0; + + return ( + + {label} + + {formatAmount(balance)} XLM + + {publicKey ? ( + + {publicKey} + + ) : null} + {lastRefreshed != null && ( + + + + Updated {formatRelativeTime(lastRefreshed)} + + + )} + {isZero && ( + + + + This account has no XLM balance. Use "Fund with Friendbot" below to get testnet XLM. + + + )} + + ); +}; + +const createStyles = (colors: ThemeColors) => + StyleSheet.create({ + card: { + backgroundColor: colors.surface, + padding: SIZES.xl, + borderRadius: RADIUS.lg, + alignItems: 'center', + marginBottom: SIZES.xl, + borderWidth: 1, + borderColor: colors.border, + }, + cardUnavailable: { + borderColor: colors.warning, + borderStyle: 'dashed', + }, + label: { + color: colors.textSecondary, + fontSize: 14, + marginBottom: SIZES.xs, + }, + balanceValue: { + color: colors.textPrimary, + fontSize: 36, + fontWeight: 'bold', + marginBottom: SIZES.sm, + }, + balanceZero: { + color: colors.textMuted, + }, + publicKey: { + color: colors.textMuted, + fontSize: 12, + backgroundColor: colors.background, + paddingHorizontal: SIZES.md, + paddingVertical: SIZES.xs, + borderRadius: RADIUS.round, + overflow: 'hidden', + maxWidth: '100%', + }, + lastRefreshedRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 4, + marginTop: SIZES.sm, + }, + lastRefreshedText: { + color: colors.textMuted, + fontSize: 11, + }, + loadingRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + marginBottom: SIZES.sm, + }, + loadingText: { + color: colors.textSecondary, + fontSize: 16, + }, + unavailableHeader: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: SIZES.sm, + }, + unavailableTitle: { + color: colors.warning, + fontSize: 16, + fontWeight: '600', + }, + unavailableMessage: { + color: colors.textSecondary, + fontSize: 13, + textAlign: 'center', + lineHeight: 18, + marginBottom: SIZES.sm, + }, + retryButton: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: SIZES.md, + paddingVertical: SIZES.sm, + borderRadius: RADIUS.sm, + borderWidth: 1, + borderColor: colors.primary, + marginTop: SIZES.xs, + }, + retryText: { + color: colors.primary, + fontSize: 13, + fontWeight: '600', + }, + retryTextDisabled: { + color: colors.textMuted, + }, + zeroBanner: { + flexDirection: 'row', + alignItems: 'flex-start', + backgroundColor: 'rgba(160, 170, 191, 0.08)', + paddingHorizontal: SIZES.sm, + paddingVertical: SIZES.sm, + borderRadius: RADIUS.sm, + marginTop: SIZES.md, + width: '100%', + }, + zeroText: { + flex: 1, + color: colors.textSecondary, + fontSize: 12, + lineHeight: 16, + }, + }); diff --git a/src/store/walletStore.ts b/src/store/walletStore.ts index 18c96e1..b1221bd 100644 --- a/src/store/walletStore.ts +++ b/src/store/walletStore.ts @@ -85,6 +85,8 @@ const resetWalletState = () => ({ isLoadingMore: false, hasMoreTransactions: false, nextCursor: null, + balanceState: 'idle' as BalanceState, + fundingStatus: 'unknown' as FundingStatus, }); const parseStoredSecret = (storedValue: string): string | null => {