/** * Wallet screen — dark minimalist. * * Сетка: * [TabHeader: profile-avatar | Wallet | refresh] * [Balance hero card — gradient-ish dark card, big number, address chip, action row] * [SectionLabel: Recent transactions] * [TX list card — tiles per tx, in/out coloring, relative time] * [Send modal: slide-up sheet с полями recipient/amount/fee + total preview] * * Все кнопки и инпуты — те же плоские стили, что на других экранах. * Никаких style-функций у Pressable'ов с layout-пропсами (избегаем web * layout-баги, которые мы уже ловили на ChatTile/MessageBubble). */ import React, { useState, useCallback, useEffect, useMemo } from 'react'; import { View, Text, ScrollView, Modal, Alert, RefreshControl, Pressable, TextInput, ActivityIndicator, } from 'react-native'; import * as Clipboard from 'expo-clipboard'; import { Ionicons } from '@expo/vector-icons'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useStore } from '@/lib/store'; import { useBalance } from '@/hooks/useBalance'; import { buildTransferTx, submitTx, getTxHistory, getBalance, humanizeTxError } from '@/lib/api'; import { shortAddr } from '@/lib/crypto'; import { formatAmount, relativeTime } from '@/lib/utils'; import type { TxRecord } from '@/lib/types'; import { TabHeader } from '@/components/TabHeader'; import { IconButton } from '@/components/IconButton'; // ─── TX meta (icon + label + tone) ───────────────────────────────── type IoniconName = React.ComponentProps['name']; interface TxMeta { label: string; icon: IoniconName; tone: 'in' | 'out' | 'neutral'; } const TX_META: Record = { TRANSFER: { label: 'Transfer', icon: 'swap-horizontal-outline', tone: 'neutral' }, CONTACT_REQUEST: { label: 'Contact request', icon: 'person-add-outline', tone: 'out' }, ACCEPT_CONTACT: { label: 'Contact accepted', icon: 'person-outline', tone: 'in' }, BLOCK_CONTACT: { label: 'Block', icon: 'ban-outline', tone: 'out' }, DEPLOY_CONTRACT: { label: 'Deploy', icon: 'document-text-outline', tone: 'out' }, CALL_CONTRACT: { label: 'Call contract', icon: 'flash-outline', tone: 'out' }, STAKE: { label: 'Stake', icon: 'lock-closed-outline', tone: 'out' }, UNSTAKE: { label: 'Unstake', icon: 'lock-open-outline', tone: 'in' }, REGISTER_KEY: { label: 'Register key', icon: 'key-outline', tone: 'neutral' }, BLOCK_REWARD: { label: 'Block reward', icon: 'diamond-outline', tone: 'in' }, }; function txMeta(type: string): TxMeta { return TX_META[type] ?? { label: type.replace(/_/g, ' '), icon: 'ellipse-outline', tone: 'neutral' }; } const toneColor = (tone: TxMeta['tone']): string => tone === 'in' ? '#3ba55d' : tone === 'out' ? '#f4212e' : '#e7e7e7'; // ─── Main ────────────────────────────────────────────────────────── export default function WalletScreen() { const insets = useSafeAreaInsets(); const keyFile = useStore(s => s.keyFile); const balance = useStore(s => s.balance); const setBalance = useStore(s => s.setBalance); useBalance(); const [txHistory, setTxHistory] = useState([]); const [refreshing, setRefreshing] = useState(false); const [copied, setCopied] = useState(false); const [showSend, setShowSend] = useState(false); const load = useCallback(async () => { if (!keyFile) return; setRefreshing(true); try { const [hist, bal] = await Promise.all([ getTxHistory(keyFile.pub_key), getBalance(keyFile.pub_key), ]); setTxHistory(hist); setBalance(bal); } catch { /* ignore — WS/HTTP retries sample */ } setRefreshing(false); }, [keyFile, setBalance]); useEffect(() => { load(); }, [load]); const copyAddress = async () => { if (!keyFile) return; await Clipboard.setStringAsync(keyFile.pub_key); setCopied(true); setTimeout(() => setCopied(false), 1800); }; const mine = keyFile?.pub_key ?? ''; return ( } /> } contentContainerStyle={{ paddingBottom: 120 }} showsVerticalScrollIndicator={false} > setShowSend(true)} /> Recent transactions {txHistory.length === 0 ? ( ) : ( {txHistory.map((tx, i) => ( ))} )} setShowSend(false)} balance={balance} keyFile={keyFile} onSent={() => { setShowSend(false); setTimeout(load, 1200); }} /> ); } // ─── Hero card ───────────────────────────────────────────────────── function BalanceHero({ balance, address, copied, onCopy, onSend, }: { balance: number; address: string; copied: boolean; onCopy: () => void; onSend: () => void; }) { return ( Balance {formatAmount(balance)} {/* Address chip */} {copied ? 'Copied!' : shortAddr(address, 10)} {/* Actions */} ); } function HeroButton({ icon, label, primary, onPress, }: { icon: IoniconName; label: string; primary?: boolean; onPress: () => void; }) { const base = { flex: 1, flexDirection: 'row', alignItems: 'center', justifyContent: 'center', paddingVertical: 11, borderRadius: 999, gap: 6, } as const; return ( {({ pressed }) => ( {label} )} ); } // ─── Section label ──────────────────────────────────────────────── function SectionLabel({ children }: { children: string }) { return ( {children} ); } // ─── Empty state ────────────────────────────────────────────────── function EmptyTx() { return ( No transactions yet Pull to refresh ); } // ─── TX tile ────────────────────────────────────────────────────── // // Pressable с ВНЕШНИМ плоским style (background через static object), // внутренняя View handles row-layout. Избегаем web-баг со style-функциями // Pressable'а. function TxTile({ tx, first, mine, }: { tx: TxRecord; first: boolean; mine: string; }) { const m = txMeta(tx.type); const isMineTx = tx.from === mine; const amt = tx.amount ?? 0; const sign = m.tone === 'in' ? '+' : m.tone === 'out' ? '−' : ''; const color = toneColor(m.tone); return ( {m.label} {tx.type === 'TRANSFER' ? (isMineTx ? `→ ${shortAddr(tx.to ?? '', 5)}` : `← ${shortAddr(tx.from, 5)}`) : shortAddr(tx.hash, 8)} {' · '} {relativeTime(tx.timestamp)} {amt > 0 && ( {sign}{formatAmount(amt)} )} ); } // ─── Send modal ─────────────────────────────────────────────────── function SendModal({ visible, onClose, balance, keyFile, onSent, }: { visible: boolean; onClose: () => void; balance: number; keyFile: { pub_key: string; priv_key: string } | null; onSent: () => void; }) { const insets = useSafeAreaInsets(); const [to, setTo] = useState(''); const [amount, setAmount] = useState(''); const [fee, setFee] = useState('1000'); const [sending, setSending] = useState(false); useEffect(() => { if (!visible) { // reset при закрытии setTo(''); setAmount(''); setFee('1000'); setSending(false); } }, [visible]); const amt = parseInt(amount || '0', 10) || 0; const f = parseInt(fee || '0', 10) || 0; const total = amt + f; const ok = !!to.trim() && amt > 0 && total <= balance; const send = async () => { if (!keyFile) return; if (!ok) { Alert.alert('Check inputs', total > balance ? `Need ${formatAmount(total)}, have ${formatAmount(balance)}.` : 'Recipient and amount are required.'); return; } setSending(true); try { const tx = buildTransferTx({ from: keyFile.pub_key, to: to.trim(), amount: amt, fee: f, privKey: keyFile.priv_key, }); await submitTx(tx); onSent(); } catch (e: any) { Alert.alert('Send failed', humanizeTxError(e)); } finally { setSending(false); } }; return ( { /* block bubble-close */ }} style={{ backgroundColor: '#0a0a0a', borderTopLeftRadius: 20, borderTopRightRadius: 20, paddingTop: 10, paddingBottom: Math.max(insets.bottom, 14) + 12, paddingHorizontal: 14, borderTopWidth: 1, borderColor: '#1f1f1f', }} > Send tokens {/* Summary */} balance ? '#f4212e' : '#ffffff'} /> {({ pressed }) => ( Cancel )} {({ pressed }) => ( {sending ? ( ) : ( Send )} )} ); } function Field({ label, children }: { label: string; children: React.ReactNode }) { return ( {label} {children} ); } function SummaryRow({ label, value, muted, accent, }: { label: string; value: string; muted?: boolean; accent?: string; }) { return ( {label} {value} ); }