/** * Settings screen — sub-route, открывается по tap'у на profile-avatar в * TabHeader. Использует обычный `
` с back-кнопкой. * * Секции: * 1. Профиль — avatar, @username, short-address, Copy row. * 2. Username — регистрация в native:username_registry (если не куплено). * 3. Node — URL + contract ID + Save + Status. * 4. Account — Export key, Delete account. * * Весь Pressable'овый layout живёт на ВНЕШНЕМ View с static style — * Pressable handle-ит только background change (через вложенный View * в ({pressed}) callback'е), никаких layout props в callback-style. * Это лечит web-баг, где Pressable style-функция не применяет * percentage/padding layout надёжно. */ import React, { useState, useEffect } from 'react'; import { View, Text, ScrollView, TextInput, Alert, Pressable, ActivityIndicator, Share, } from 'react-native'; import * as Clipboard from 'expo-clipboard'; import { router } from 'expo-router'; import { Ionicons } from '@expo/vector-icons'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useStore } from '@/lib/store'; import { saveSettings, deleteKeyFile } from '@/lib/storage'; import { setNodeUrl, getNetStats, resolveUsername, reverseResolve, buildCallContractTx, submitTx, USERNAME_REGISTRATION_FEE, MIN_USERNAME_LENGTH, MAX_USERNAME_LENGTH, humanizeTxError, } from '@/lib/api'; import { shortAddr } from '@/lib/crypto'; import { formatAmount, safeBack } from '@/lib/utils'; import { Avatar } from '@/components/Avatar'; import { Header } from '@/components/Header'; import { IconButton } from '@/components/IconButton'; type NodeStatus = 'idle' | 'checking' | 'ok' | 'error'; type IoniconName = React.ComponentProps['name']; // ─── Shared layout primitives ───────────────────────────────────── function SectionLabel({ children }: { children: string }) { return ( {children} ); } function Card({ children }: { children: React.ReactNode }) { return ( {children} ); } /** * Row — clickable / non-clickable list item внутри Card'а. * * Layout живёт на ВНЕШНЕМ контейнере (View если read-only, Pressable * если tappable). Для pressed-стейта используется вложенный `` * с background-color, чтобы не полагаться на style-функцию Pressable'а * (web-баг). */ function Row({ icon, label, value, onPress, right, danger, first, }: { icon: IoniconName; label: string; value?: string; onPress?: () => void; right?: React.ReactNode; danger?: boolean; first?: boolean; }) { const body = (pressed: boolean) => ( {label} {value !== undefined && ( {value} )} {right} {onPress && !right && ( )} ); if (!onPress) return {body(false)}; return ( {({ pressed }) => body(pressed)} ); } // ─── Screen ─────────────────────────────────────────────────────── export default function SettingsScreen() { const insets = useSafeAreaInsets(); const keyFile = useStore(s => s.keyFile); const setKeyFile = useStore(s => s.setKeyFile); const settings = useStore(s => s.settings); const setSettings = useStore(s => s.setSettings); const username = useStore(s => s.username); const setUsername = useStore(s => s.setUsername); const balance = useStore(s => s.balance); const [nodeUrl, setNodeUrlInput] = useState(settings.nodeUrl); const [contractId, setContractId] = useState(settings.contractId); const [nodeStatus, setNodeStatus] = useState('idle'); const [peerCount, setPeerCount] = useState(null); const [blockCount, setBlockCount] = useState(null); const [copied, setCopied] = useState(false); const [savingNode, setSavingNode] = useState(false); // Username registration state const [nameInput, setNameInput] = useState(''); const [nameError, setNameError] = useState(null); const [registering, setRegistering] = useState(false); useEffect(() => { checkNode(); }, []); useEffect(() => { setContractId(settings.contractId); }, [settings.contractId]); useEffect(() => { if (!settings.contractId || !keyFile) { setUsername(null); return; } (async () => { const name = await reverseResolve(settings.contractId, keyFile.pub_key); setUsername(name); })(); }, [settings.contractId, keyFile, setUsername]); async function checkNode() { setNodeStatus('checking'); try { const stats = await getNetStats(); setNodeStatus('ok'); setPeerCount(stats.peer_count); setBlockCount(stats.total_blocks); } catch { setNodeStatus('error'); } } async function saveNode() { setSavingNode(true); const url = nodeUrl.trim().replace(/\/$/, ''); setNodeUrl(url); const next = { nodeUrl: url, contractId: contractId.trim() }; setSettings(next); await saveSettings(next); await checkNode(); setSavingNode(false); Alert.alert('Saved', 'Node settings updated.'); } async function copyAddress() { if (!keyFile) return; await Clipboard.setStringAsync(keyFile.pub_key); setCopied(true); setTimeout(() => setCopied(false), 1800); } async function exportKey() { if (!keyFile) return; try { await Share.share({ message: JSON.stringify(keyFile, null, 2), title: 'DChain key file', }); } catch (e: any) { Alert.alert('Export failed', e?.message ?? 'Unknown error'); } } function logout() { Alert.alert( 'Delete account', 'Your key will be removed from this device. Make sure you have a backup!', [ { text: 'Cancel', style: 'cancel' }, { text: 'Delete', style: 'destructive', onPress: async () => { await deleteKeyFile(); setKeyFile(null); router.replace('/'); }, }, ], ); } const onNameChange = (v: string) => { const cleaned = v.toLowerCase().replace(/[^a-z0-9_\-]/g, '').slice(0, MAX_USERNAME_LENGTH); setNameInput(cleaned); setNameError(null); }; const nameIsValid = nameInput.length >= MIN_USERNAME_LENGTH && /^[a-z]/.test(nameInput); async function registerUsername() { if (!keyFile) return; const name = nameInput.trim(); if (!nameIsValid) { setNameError(`Min ${MIN_USERNAME_LENGTH} chars, starts with a-z`); return; } if (!settings.contractId) { setNameError('No registry contract in node settings'); return; } const total = USERNAME_REGISTRATION_FEE + 1000 + 2000; if (balance < total) { setNameError(`Need ${formatAmount(total)}, have ${formatAmount(balance)}`); return; } try { const existing = await resolveUsername(settings.contractId, name); if (existing) { setNameError(`@${name} already taken`); return; } } catch { /* ignore */ } Alert.alert( `Buy @${name}?`, `Cost: ${formatAmount(USERNAME_REGISTRATION_FEE)} + fee ${formatAmount(1000)}.\nBinds to your address until released.`, [ { text: 'Cancel', style: 'cancel' }, { text: 'Buy', onPress: async () => { setRegistering(true); setNameError(null); try { const tx = buildCallContractTx({ from: keyFile.pub_key, contractId: settings.contractId, method: 'register', args: [name], amount: USERNAME_REGISTRATION_FEE, privKey: keyFile.priv_key, }); await submitTx(tx); setNameInput(''); Alert.alert('Submitted', 'Registration tx accepted. Name appears in a few seconds.'); let attempts = 0; const iv = setInterval(async () => { attempts++; const got = keyFile ? await reverseResolve(settings.contractId, keyFile.pub_key) : null; if (got) { setUsername(got); clearInterval(iv); } else if (attempts >= 10) clearInterval(iv); }, 2000); } catch (e: any) { setNameError(humanizeTxError(e)); } finally { setRegistering(false); } }, }, ], ); } const statusColor = nodeStatus === 'ok' ? '#3ba55d' : nodeStatus === 'error' ? '#f4212e' : '#f0b35a'; const statusLabel = nodeStatus === 'ok' ? 'Connected' : nodeStatus === 'error' ? 'Unreachable' : 'Checking…'; return (
safeBack()} />} /> {/* ── Profile ── */} Profile {username ? ( @{username} ) : ( No username yet )} {keyFile ? shortAddr(keyFile.pub_key, 10) : '—'} } /> {/* ── Username (только если ещё нет) ── */} {!username && ( <> Username Buy a username Flat {formatAmount(USERNAME_REGISTRATION_FEE)} fee + {formatAmount(1000)} network. Only a-z, 0-9, _, -. Starts with a letter. @ {nameError && ( {nameError} )} )} {/* ── Node ── */} Node } /> {/* ── Account ── */} Account ); } // ─── Form primitives ────────────────────────────────────────────── function LabeledInput({ label, value, onChangeText, placeholder, monospace, }: { label: string; value: string; onChangeText: (v: string) => void; placeholder?: string; monospace?: boolean; }) { return ( {label} ); } function PrimaryButton({ label, onPress, disabled, loading, style, }: { label: string; onPress: () => void; disabled?: boolean; loading?: boolean; style?: object; }) { return ( {({ pressed }) => ( {loading ? ( ) : ( {label} )} )} ); }