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
101 changes: 29 additions & 72 deletions app/(tabs)/index.tsx
Original file line number Diff line number Diff line change
@@ -1,29 +1,19 @@
import React, { useEffect, useMemo } from 'react';
import React, { useEffect, useMemo, useCallback } from 'react';
import { View, Text, StyleSheet, ScrollView, RefreshControl } from 'react-native';
import { useRouter } from 'expo-router';
import { useWalletStore } from '../../src/store/walletStore';
import { SIZES, RADIUS, ThemeColors } from '../../src/constants/theme';
import { useTheme } from '../../src/hooks/useTheme';
import { Button } from '../../src/components/Button';
import { FundButton } from '../../src/components/FundButton';
import { TransactionListItem } from '../../src/components/TransactionListItem';
import { NetworkStatusBanner } from '../../src/components/NetworkStatusBanner';
import { WalletEmptyState } from '../../src/components/WalletEmptyState';
import { BalanceDisplay } from '../../src/components/BalanceDisplay';
import { FundingStatusBanner } from '../../src/components/FundingStatusBanner';
import { useNetworkStatus } from '../../src/hooks/useNetworkStatus';
import { Clock, RefreshCw } from 'lucide-react-native';
import { formatAmount } from '../../src/utils/amount';
import { Clock } from 'lucide-react-native';
import { BackupReminderModal } from '../../src/components/BackupReminderModal';

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 default function HomeScreen() {
const router = useRouter();
const { colors } = useTheme();
Expand All @@ -37,8 +27,11 @@ export default function HomeScreen() {
isFunding,
fundError,
error,
balanceState,
fundingStatus,
refreshWalletData,
fundWallet,
checkFundingStatus,
showBackupReminder,
acknowledgeBackupReminder,
} = useWalletStore();
Expand All @@ -47,9 +40,16 @@ export default function HomeScreen() {

useEffect(() => {
refreshWalletData();
checkFundingStatus();
}, []);

const isFunded = balance !== '0.0000000';
const handleRetry = useCallback(() => {
if (publicKey) {
refreshWalletData();
checkFundingStatus();
}
}, [publicKey, refreshWalletData, checkFundingStatus]);

const recentTransactions = transactions.slice(0, 3); // Preview

if (!publicKey) {
Expand All @@ -71,7 +71,7 @@ export default function HomeScreen() {
refreshControl={
<RefreshControl
refreshing={isLoading}
onRefresh={refreshWalletData}
onRefresh={handleRetry}
tintColor={colors.primary}
/>
}
Expand All @@ -83,27 +83,22 @@ export default function HomeScreen() {
isRetrying={isLoading}
/>

<View style={styles.balanceCard}>
<Text style={styles.balanceLabel}>Total Balance (Testnet)</Text>
<Text style={styles.balanceValue}>{formatAmount(balance)} XLM</Text>
<Text style={styles.publicKey} numberOfLines={1} ellipsizeMode="middle">
{publicKey}
</Text>
{lastRefreshed !== null && (
<View style={styles.lastRefreshed}>
<RefreshCw color={colors.textMuted} size={12} />
<Text style={styles.lastRefreshedText}>
Updated {formatRelativeTime(lastRefreshed)}
</Text>
</View>
)}
</View>
{/* Issue #329: Balance display with all states */}
<BalanceDisplay
state={balanceState}
balance={balance}
publicKey={publicKey}
onRetry={handleRetry}
isRetrying={isLoading}
lastRefreshed={lastRefreshed}
/>

<FundButton
{/* Issue #330: Funding status banner */}
<FundingStatusBanner
status={fundingStatus}
onFund={fundWallet}
isFunding={isFunding}
fundError={fundError}
onFund={fundWallet}
isFunded={isFunded}
/>

<View style={styles.actionsContainer}>
Expand Down Expand Up @@ -162,44 +157,6 @@ const createStyles = (colors: ThemeColors) => StyleSheet.create({
backgroundColor: colors.background,
padding: SIZES.lg,
},
balanceCard: {
backgroundColor: colors.surface,
padding: SIZES.xl,
borderRadius: RADIUS.lg,
alignItems: 'center',
marginBottom: SIZES.xl,
borderWidth: 1,
borderColor: colors.border,
},
balanceLabel: {
color: colors.textSecondary,
fontSize: 14,
marginBottom: SIZES.xs,
},
balanceValue: {
color: colors.textPrimary,
fontSize: 36,
fontWeight: 'bold',
marginBottom: SIZES.sm,
},
publicKey: {
color: colors.textMuted,
fontSize: 12,
backgroundColor: colors.background,
paddingHorizontal: SIZES.md,
paddingVertical: SIZES.xs,
borderRadius: RADIUS.round,
},
lastRefreshed: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
marginTop: SIZES.sm,
},
lastRefreshedText: {
color: colors.textMuted,
fontSize: 11,
},
actionsContainer: {
flexDirection: 'row',
justifyContent: 'space-between',
Expand Down
42 changes: 37 additions & 5 deletions app/send.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
ScanLine,
ChevronDown,
User,
Info,
} from "lucide-react-native";
import { ScreenHeader } from "@/components";

Expand All @@ -50,10 +51,14 @@ export default function SendScreen() {
const router = useRouter();
const { colors } = useTheme();
const styles = useMemo(() => createStyles(colors), [colors]);
const { publicKey, getSecretKey, refreshWalletData, balance } =
const { publicKey, getSecretKey, refreshWalletData, balance, fundingStatus } =
useWalletStore();
const contacts = useAppStore((state) => state.contacts);

// Issue #330: Disable send when account is unfunded
const isUnfunded = fundingStatus === 'unfunded';
const sendDisabled = isUnfunded || !publicKey;

const [destination, setDestination] = useState("");
const [amount, setAmount] = useState("");
const [memo, setMemo] = useState("");
Expand Down Expand Up @@ -156,6 +161,16 @@ export default function SendScreen() {
subtitle={`Available Balance: ${formatAmount(balance)} XLM`}
/>

{/* Issue #330: Show unfunded account warning */}
{isUnfunded && (
<View style={styles.unfundedWarning}>
<Info color={colors.warning} size={18} style={{ marginRight: SIZES.sm }} />
<Text style={styles.unfundedWarningText}>
Your account has not been funded yet. Fund it with Friendbot on the home screen before sending.
</Text>
</View>
)}

<View style={styles.form}>
<FormField
label="Destination Address (Public Key)"
Expand Down Expand Up @@ -246,10 +261,11 @@ export default function SendScreen() {
</View>

<AsyncActionButton
title="Send Payment"
title={sendDisabled ? 'Funding Required' : 'Send Payment'}
onPress={handleSend}
isLoading={isLoading}
loadingText="Sending…"
disabled={sendDisabled}
style={styles.sendButton}
/>
</KeyboardAvoidingView>
Expand Down Expand Up @@ -334,7 +350,23 @@ const createStyles = (colors: ThemeColors) =>
fontSize: 13,
fontWeight: "500",
},
sendButton: {
marginBottom: SIZES.xxl,
},
unfundedWarning: {
flexDirection: 'row',
alignItems: 'flex-start',
backgroundColor: 'rgba(255, 196, 0, 0.1)',
padding: SIZES.md,
borderRadius: RADIUS.md,
borderWidth: 1,
borderColor: 'rgba(255, 196, 0, 0.25)',
marginBottom: SIZES.md,
},
unfundedWarningText: {
flex: 1,
color: colors.warning,
fontSize: 13,
lineHeight: 18,
},
sendButton: {
marginBottom: SIZES.xxl,
},
});
71 changes: 71 additions & 0 deletions docs/account-funding-states.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# Account Funding & Balance States

This document describes the mobile client's handling of account funding status and balance availability states.

## Account Funding States (Issue #330)

The app tracks whether the account exists on the Stellar network using four states:

| State | Meaning | UI Treatment |
|-------------|--------------------------------------------|---------------------------------------------|
| `unknown` | Haven't checked yet (network error) | Show info banner, no action |
| `checking` | In the process of checking | Loading spinner + message |
| `unfunded` | Account does not exist on the network | Show Friendbot funding card |
| `funded` | Account exists on the network | No banner (or minimal success indicator) |

### Detection

Funding status is determined by `walletStore.checkFundingStatus()` which calls `fetchAccountDetails()`:

- **404 / "Not Found"** → account doesn't exist → `unfunded`
- **Network error** → can't determine → `unknown` (user may retry)
- **Success** → account exists → `funded`

### Impact on Payment Actions

| State | Send Payment | Receive | Vault Actions |
|-------------|--------------|---------|---------------|
| `unknown` | Allowed | Allowed | Depends |
| `checking` | Allowed | Allowed | Depends |
| `unfunded` | **Blocked** | Allowed | Blocked* |
| `funded` | Allowed | Allowed | Allowed |

\* Vault deposit/withdraw/lock are also blocked by the vault capability gate when there is no wallet.

### Friendbot Testnet Funding

The `FundWallet` action calls `friendbot.stellar.org` to fund the testnet account. This is only available on Testnet.

## Balance States (Issue #329)

The app distinguishes between four balance states to avoid conflating "no data" with "zero XLM":

| State | Meaning | UI Treatment |
|----------------|-------------------------------------------|---------------------------------------------------|
| `idle` | No fetch attempted yet | Spinner + "No balance data yet" |
| `loading` | Fetch in progress | Spinner + "Loading your balance" |
| `available` | Balance fetched (zero or positive) | Show numeric balance (muted if zero) |
| `unavailable` | Network error, failed fetch | Warning card + Retry button |

### Balance Display Component

`BalanceDisplay` (`src/components/BalanceDisplay.tsx`) renders a card with:

- **idle/loading**: Activity spinner with descriptive text
- **available (positive)**: Bold numeric balance with public key and last-refreshed timestamp
- **available (zero)**: Same as positive but muted with a banner explaining zero balance
- **unavailable**: Warning-styled card with error message and retry button

### Recovery

When balance is unavailable, the user can:
1. Pull-to-refresh on the home screen
2. Tap the "Retry" button on the balance card
3. Navigate away and back to trigger a new fetch

## Related Documentation

- [Vault SDK Capability Assumptions](./vault-sdk-capability-assumptions.md)
- [Vault Integration Assumptions](./vault-integration-assumptions.md)
- [Vault UI Guidance](./vault-ui-guidance.md)
- [Mobile Security Checklist](./mobile-security-checklist.md)
Loading