/** * Wallet screen — DChain explorer style. * Balance block inspired by Tinkoff/Gravity UI reference. * Icons: Ionicons from @expo/vector-icons. */ import React, { useState, useCallback, useEffect } from 'react'; import { View, Text, ScrollView, Modal, TouchableOpacity, Alert, RefreshControl, } 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 } from '@/lib/api'; import { shortAddr } from '@/lib/crypto'; import { formatAmount, relativeTime } from '@/lib/utils'; import { Input } from '@/components/ui/Input'; import type { TxRecord } from '@/lib/types'; // ─── Design tokens ──────────────────────────────────────────────────────────── const C = { bg: '#0b1220', surface: '#111a2b', surface2:'#162035', surface3:'#1a2640', line: '#1c2840', text: '#e6edf9', muted: '#98a7c2', accent: '#7db5ff', ok: '#41c98a', warn: '#f0b35a', err: '#ff7a87', } as const; // ─── TX metadata ────────────────────────────────────────────────────────────── const TX_META: Record = { TRANSFER: { label: 'Перевод', icon: 'paper-plane-outline', color: C.accent }, CONTACT_REQUEST: { label: 'Запрос контакта', icon: 'person-add-outline', color: C.ok }, ACCEPT_CONTACT: { label: 'Принят контакт', icon: 'person-outline', color: C.ok }, BLOCK_CONTACT: { label: 'Блокировка', icon: 'ban-outline', color: C.err }, DEPLOY_CONTRACT: { label: 'Деплой контракта',icon: 'document-text-outline', color: C.warn }, CALL_CONTRACT: { label: 'Вызов контракта', icon: 'flash-outline', color: C.warn }, STAKE: { label: 'Стейкинг', icon: 'lock-closed-outline', color: C.accent}, UNSTAKE: { label: 'Вывод стейка', icon: 'lock-open-outline', color: C.muted }, REGISTER_KEY: { label: 'Регистрация', icon: 'key-outline', color: C.muted }, BLOCK_REWARD: { label: 'Награда', icon: 'diamond-outline', color: C.ok }, }; function txMeta(type: string) { return TX_META[type] ?? { label: type.replace(/_/g, ' '), icon: 'ellipse-outline' as any, color: C.muted }; } // ─── Component ──────────────────────────────────────────────────────────────── export default function WalletScreen() { const keyFile = useStore(s => s.keyFile); const balance = useStore(s => s.balance); const setBalance = useStore(s => s.setBalance); const insets = useSafeAreaInsets(); useBalance(); const [txHistory, setTxHistory] = useState([]); const [refreshing, setRefreshing] = useState(false); const [showSend, setShowSend] = useState(false); const [selectedTx, setSelectedTx] = useState(null); const [toAddress, setToAddress] = useState(''); const [amount, setAmount] = useState(''); const [fee, setFee] = useState('1000'); const [sending, setSending] = useState(false); const [copied, setCopied] = 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 {} setRefreshing(false); }, [keyFile]); useEffect(() => { load(); }, [load]); const copyAddress = async () => { if (!keyFile) return; await Clipboard.setStringAsync(keyFile.pub_key); setCopied(true); setTimeout(() => setCopied(false), 2000); }; const send = async () => { if (!keyFile) return; const amt = parseInt(amount); const f = parseInt(fee); if (!toAddress.trim() || isNaN(amt) || amt <= 0) { Alert.alert('Неверные данные', 'Введите корректный адрес и сумму.'); return; } if (amt + f > balance) { Alert.alert('Недостаточно средств', `Нужно ${formatAmount(amt + f)}, доступно ${formatAmount(balance)}.`); return; } setSending(true); try { const tx = buildTransferTx({ from: keyFile.pub_key, to: toAddress.trim(), amount: amt, fee: f, privKey: keyFile.priv_key }); await submitTx(tx); setShowSend(false); setToAddress(''); setAmount(''); Alert.alert('Отправлено', 'Транзакция принята нодой.'); setTimeout(load, 1500); } catch (e: any) { Alert.alert('Ошибка', e.message); } finally { setSending(false); } }; return ( } contentContainerStyle={{ paddingBottom: 32 }} > {/* ── Balance hero ── */} setShowSend(true)} onReceive={copyAddress} onRefresh={load} onCopy={copyAddress} /> {/* ── Transaction list ── */} История транзакций {txHistory.length === 0 ? ( Нет транзакций Потяните вниз, чтобы обновить ) : ( {/* Table header */} ТИП АДРЕС СУММА {txHistory.map((tx, i) => ( setSelectedTx(tx)} /> ))} )} {/* Send modal */} setShowSend(false)}> setShowSend(false)} /> {/* TX Detail modal */} setSelectedTx(null)}> {selectedTx && ( setSelectedTx(null)} /> )} ); } // ─── Balance Hero ───────────────────────────────────────────────────────────── function BalanceHero({ balance, address, copied, topInset, onSend, onReceive, onRefresh, onCopy, }: { balance: number; address: string; copied: boolean; topInset: number; onSend: () => void; onReceive: () => void; onRefresh: () => void; onCopy: () => void; }) { return ( {/* Label */} Баланс {/* Main balance */} {formatAmount(balance)} {/* µT sub-label */} {(balance ?? 0).toLocaleString()} µT {/* Address chip */} {copied ? 'Скопировано' : (address ? shortAddr(address, 8) : '—')} {/* Action buttons */} ); } function ActionButton({ icon, label, color, onPress, }: { icon: keyof typeof Ionicons.glyphMap; label: string; color: string; onPress: () => void; }) { return ( {label} ); } // ─── Transaction Row ────────────────────────────────────────────────────────── // Column widths must match the header above: // col1 (type+icon): 140 col2 (address): flex:1 col3 (amount): 84 // tx types where "from" is always the owner but it's income, not a send const RECEIVED_TYPES = new Set(['BLOCK_REWARD', 'STAKE_REWARD']); function TxRow({ tx, myPubKey, isLast, onPress, }: { tx: TxRecord; myPubKey: string; isLast: boolean; onPress: () => void; }) { const meta = txMeta(tx.type); const isSynthetic = !tx.from; // block reward / mint const isSent = !isSynthetic && !RECEIVED_TYPES.has(tx.type) && tx.from === myPubKey; const amt = tx.amount ?? 0; const amtText = amt === 0 ? '' : `${isSent ? '−' : '+'}${formatAmount(amt)}`; const amtColor = isSent ? C.err : C.ok; // Counterpart label: for synthetic (empty from) rewards → "Сеть", // otherwise show the short address of the other side. const counterpart = isSynthetic ? 'Сеть' : isSent ? (tx.to ? shortAddr(tx.to, 6) : '—') : shortAddr(tx.from, 6); return ( {/* Col 1 — icon + type label, fixed 140px */} {meta.label} {/* Col 2 — address, flex */} {isSent ? `→ ${counterpart}` : `← ${counterpart}`} {/* Col 3 — amount + time, fixed 84px, right-aligned */} {!!amtText && ( {amtText} )} {relativeTime(tx.timestamp)} ); } // ─── Send Sheet ─────────────────────────────────────────────────────────────── function SendSheet({ balance, toAddress, setToAddress, amount, setAmount, fee, setFee, sending, onSend, onClose, }: { balance: number; toAddress: string; setToAddress: (v: string) => void; amount: string; setAmount: (v: string) => void; fee: string; setFee: (v: string) => void; sending: boolean; onSend: () => void; onClose: () => void; }) { return ( Отправить Доступно: {formatAmount(balance)} {!sending && } {sending ? 'Отправка…' : 'Подтвердить'} ); } // ─── TX Detail Sheet ────────────────────────────────────────────────────────── function TxDetailSheet({ tx, myPubKey, onClose, }: { tx: TxRecord; myPubKey: string; onClose: () => void; }) { const [copiedHash, setCopiedHash] = useState(false); const meta = txMeta(tx.type); const isSent = tx.from === myPubKey; const amtValue = tx.amount ?? 0; const copyHash = async () => { await Clipboard.setStringAsync(tx.hash); setCopiedHash(true); setTimeout(() => setCopiedHash(false), 2000); }; const amtColor = amtValue === 0 ? C.muted : isSent ? C.err : C.ok; const amtSign = amtValue === 0 ? '' : isSent ? '−' : '+'; return ( {/* Handle */} {/* Header */} Транзакция {/* Hero */} 0 ? 8 : 0 }}> {meta.label} {amtValue > 0 && ( {amtSign}{formatAmount(amtValue)} )} {tx.status === 'confirmed' ? '✓ Подтверждена' : '⏳ В обработке'} {/* Details table */} {tx.amount !== undefined && ( )} {tx.timestamp > 0 && ( )} {/* Addresses */} {/* "From" for synthetic txs (empty tx.from) reads "Сеть" rather than an empty row. */} {tx.to && ( )} {/* TX Hash */} TX ID / Hash {tx.hash} {copiedHash ? 'Скопировано' : 'Копировать хеш'} ); } // ─── Detail Row ─────────────────────────────────────────────────────────────── function DetailRow({ icon, label, value, mono, truncate, first, }: { icon: keyof typeof Ionicons.glyphMap; label: string; value: string; mono?: boolean; truncate?: boolean; first?: boolean; }) { return ( {label} {value} ); }