Users can now tap any row in the wallet history and see the full
transaction detail, matching what the block explorer shows for the
same tx. Covers every visible activity — transfers, contact
requests, likes, posts, follows, relay proofs, contract calls.
Components
lib/api.ts
- New TxDetail interface mirroring node/api_explorer.go's
txDetail JSON (id, type, from/to + their DC addresses, µT
amount + display string, fee, block coords, gas, payload,
signature hex).
- getTxDetail(txID) with 404→null handling.
app/(app)/tx/[id].tsx — new screen
- Hero row: icon + type label + local-time timestamp
- Big amount pill (only for txs that move tokens) — signed by
the viewer's perspective (+ when you received, − when you
paid, neutral when it's someone else's tx or a non-transfer)
- Info card rows with tap-to-copy on hashes and addresses:
Tx ID, From (highlighted "you" when it's the signed-in user),
To (same), Block, Fee, Gas used (when > 0), Memo (when set)
- Collapsible Payload section — renders JSON with 2-space
indent if the node could decode it, otherwise the raw hex
- Signature copy row at the bottom (useful for debugging / audits)
- txMeta() covers all EventTypes from blockchain/types.go
(TRANSFER, CONTACT_REQUEST/ACCEPT/BLOCK, REGISTER_KEY/RELAY,
BIND_WALLET, RELAY_PROOF, BLOCK_REWARD, HEARTBEAT, CREATE_POST,
DELETE_POST, LIKE_POST/UNLIKE_POST, FOLLOW/UNFOLLOW,
CALL_CONTRACT, DEPLOY_CONTRACT, STAKE/UNSTAKE) with
distinct icons + in/out/neutral tone.
- Nested Stack layout so router.back() pops to the caller;
safeBack() fallback when entered via deep link.
app/(app)/wallet.tsx
- TxTile's outer Pressable was a no-op onPress handler; now
router.push(`/(app)/tx/${tx.hash}`). Entire row is the
touch target (icon + type + addr + time + amount).
app/(app)/_layout.tsx
- /tx/* added to hideNav regex so the detail screen is
full-screen without the 5-icon bar at the bottom.
Translation quirk
The screen is English to match the rest of the UI (what the user
just asked for in the previous commit). Handles copying via
expo-clipboard — tapping an address/hash shows "Copied" for 1.5s
with a green check, then reverts.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
654 lines
20 KiB
TypeScript
654 lines
20 KiB
TypeScript
/**
|
||
* 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 { router } from 'expo-router';
|
||
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<typeof Ionicons>['name'];
|
||
|
||
interface TxMeta {
|
||
label: string;
|
||
icon: IoniconName;
|
||
tone: 'in' | 'out' | 'neutral';
|
||
}
|
||
|
||
const TX_META: Record<string, TxMeta> = {
|
||
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<TxRecord[]>([]);
|
||
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 (
|
||
<View style={{ flex: 1, backgroundColor: '#000000', paddingTop: insets.top }}>
|
||
<TabHeader
|
||
title="Wallet"
|
||
right={<IconButton icon="refresh-outline" size={36} onPress={load} />}
|
||
/>
|
||
|
||
<ScrollView
|
||
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={load} tintColor="#1d9bf0" />}
|
||
contentContainerStyle={{ paddingBottom: 120 }}
|
||
showsVerticalScrollIndicator={false}
|
||
>
|
||
<BalanceHero
|
||
balance={balance}
|
||
address={mine}
|
||
copied={copied}
|
||
onCopy={copyAddress}
|
||
onSend={() => setShowSend(true)}
|
||
/>
|
||
|
||
<SectionLabel>Recent transactions</SectionLabel>
|
||
|
||
{txHistory.length === 0 ? (
|
||
<EmptyTx />
|
||
) : (
|
||
<View
|
||
style={{
|
||
marginHorizontal: 14,
|
||
borderRadius: 14,
|
||
backgroundColor: '#0a0a0a',
|
||
borderWidth: 1, borderColor: '#1f1f1f',
|
||
overflow: 'hidden',
|
||
}}
|
||
>
|
||
{txHistory.map((tx, i) => (
|
||
<TxTile
|
||
key={tx.hash + i}
|
||
tx={tx}
|
||
first={i === 0}
|
||
mine={mine}
|
||
/>
|
||
))}
|
||
</View>
|
||
)}
|
||
</ScrollView>
|
||
|
||
<SendModal
|
||
visible={showSend}
|
||
onClose={() => setShowSend(false)}
|
||
balance={balance}
|
||
keyFile={keyFile}
|
||
onSent={() => {
|
||
setShowSend(false);
|
||
setTimeout(load, 1200);
|
||
}}
|
||
/>
|
||
</View>
|
||
);
|
||
}
|
||
|
||
// ─── Hero card ─────────────────────────────────────────────────────
|
||
|
||
function BalanceHero({
|
||
balance, address, copied, onCopy, onSend,
|
||
}: {
|
||
balance: number;
|
||
address: string;
|
||
copied: boolean;
|
||
onCopy: () => void;
|
||
onSend: () => void;
|
||
}) {
|
||
return (
|
||
<View
|
||
style={{
|
||
marginHorizontal: 14,
|
||
marginTop: 10,
|
||
padding: 20,
|
||
borderRadius: 18,
|
||
backgroundColor: '#0a0a0a',
|
||
borderWidth: 1,
|
||
borderColor: '#1f1f1f',
|
||
}}
|
||
>
|
||
<Text style={{ color: '#8b8b8b', fontSize: 12, letterSpacing: 0.3 }}>
|
||
Balance
|
||
</Text>
|
||
<Text
|
||
style={{
|
||
color: '#ffffff',
|
||
fontSize: 36,
|
||
fontWeight: '800',
|
||
letterSpacing: -0.8,
|
||
marginTop: 4,
|
||
}}
|
||
>
|
||
{formatAmount(balance)}
|
||
</Text>
|
||
|
||
{/* Address chip */}
|
||
<Pressable onPress={onCopy} style={{ marginTop: 14 }}>
|
||
<View
|
||
style={{
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
backgroundColor: '#111111',
|
||
borderRadius: 10,
|
||
paddingHorizontal: 12,
|
||
paddingVertical: 9,
|
||
}}
|
||
>
|
||
<Ionicons
|
||
name={copied ? 'checkmark-outline' : 'copy-outline'}
|
||
size={14}
|
||
color={copied ? '#3ba55d' : '#8b8b8b'}
|
||
/>
|
||
<Text
|
||
style={{
|
||
color: copied ? '#3ba55d' : '#8b8b8b',
|
||
fontSize: 12,
|
||
marginLeft: 6,
|
||
fontFamily: 'monospace',
|
||
flex: 1,
|
||
}}
|
||
numberOfLines={1}
|
||
>
|
||
{copied ? 'Copied!' : shortAddr(address, 10)}
|
||
</Text>
|
||
</View>
|
||
</Pressable>
|
||
|
||
{/* Actions */}
|
||
<View style={{ flexDirection: 'row', gap: 10, marginTop: 14 }}>
|
||
<HeroButton icon="paper-plane-outline" label="Send" primary onPress={onSend} />
|
||
<HeroButton icon="download-outline" label="Receive" onPress={onCopy} />
|
||
</View>
|
||
</View>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<Pressable onPress={onPress} style={{ flex: 1 }}>
|
||
{({ pressed }) => (
|
||
<View
|
||
style={{
|
||
...base,
|
||
backgroundColor: primary
|
||
? (pressed ? '#1a8cd8' : '#1d9bf0')
|
||
: (pressed ? '#202020' : '#111111'),
|
||
borderWidth: primary ? 0 : 1,
|
||
borderColor: '#1f1f1f',
|
||
}}
|
||
>
|
||
<Ionicons name={icon} size={15} color="#ffffff" />
|
||
<Text style={{ color: '#ffffff', fontWeight: '700', fontSize: 14 }}>
|
||
{label}
|
||
</Text>
|
||
</View>
|
||
)}
|
||
</Pressable>
|
||
);
|
||
}
|
||
|
||
// ─── Section label ────────────────────────────────────────────────
|
||
|
||
function SectionLabel({ children }: { children: string }) {
|
||
return (
|
||
<Text
|
||
style={{
|
||
color: '#5a5a5a',
|
||
fontSize: 11,
|
||
fontWeight: '700',
|
||
letterSpacing: 1.2,
|
||
textTransform: 'uppercase',
|
||
marginTop: 22,
|
||
marginBottom: 8,
|
||
paddingHorizontal: 14,
|
||
}}
|
||
>
|
||
{children}
|
||
</Text>
|
||
);
|
||
}
|
||
|
||
// ─── Empty state ──────────────────────────────────────────────────
|
||
|
||
function EmptyTx() {
|
||
return (
|
||
<View
|
||
style={{
|
||
marginHorizontal: 14,
|
||
borderRadius: 14,
|
||
backgroundColor: '#0a0a0a',
|
||
borderWidth: 1,
|
||
borderColor: '#1f1f1f',
|
||
paddingVertical: 36,
|
||
alignItems: 'center',
|
||
}}
|
||
>
|
||
<Ionicons name="receipt-outline" size={32} color="#5a5a5a" />
|
||
<Text style={{ color: '#8b8b8b', fontSize: 13, marginTop: 8 }}>
|
||
No transactions yet
|
||
</Text>
|
||
<Text style={{ color: '#5a5a5a', fontSize: 11, marginTop: 2 }}>
|
||
Pull to refresh
|
||
</Text>
|
||
</View>
|
||
);
|
||
}
|
||
|
||
// ─── 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 (
|
||
<Pressable onPress={() => router.push(`/(app)/tx/${tx.hash}` as never)}>
|
||
<View
|
||
style={{
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
paddingHorizontal: 14,
|
||
paddingVertical: 12,
|
||
borderTopWidth: first ? 0 : 1,
|
||
borderTopColor: '#1f1f1f',
|
||
}}
|
||
>
|
||
<View
|
||
style={{
|
||
width: 36,
|
||
height: 36,
|
||
borderRadius: 10,
|
||
backgroundColor: '#111111',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
marginRight: 12,
|
||
}}
|
||
>
|
||
<Ionicons name={m.icon} size={16} color="#e7e7e7" />
|
||
</View>
|
||
<View style={{ flex: 1, minWidth: 0 }}>
|
||
<Text style={{ color: '#ffffff', fontSize: 14, fontWeight: '600' }}>
|
||
{m.label}
|
||
</Text>
|
||
<Text
|
||
style={{
|
||
color: '#8b8b8b',
|
||
fontSize: 11,
|
||
marginTop: 1,
|
||
fontFamily: 'monospace',
|
||
}}
|
||
numberOfLines={1}
|
||
>
|
||
{tx.type === 'TRANSFER'
|
||
? (isMineTx ? `→ ${shortAddr(tx.to ?? '', 5)}` : `← ${shortAddr(tx.from, 5)}`)
|
||
: shortAddr(tx.hash, 8)}
|
||
{' · '}
|
||
{relativeTime(tx.timestamp)}
|
||
</Text>
|
||
</View>
|
||
{amt > 0 && (
|
||
<Text style={{ color, fontWeight: '700', fontSize: 14 }}>
|
||
{sign}{formatAmount(amt)}
|
||
</Text>
|
||
)}
|
||
</View>
|
||
</Pressable>
|
||
);
|
||
}
|
||
|
||
// ─── 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 (
|
||
<Modal visible={visible} animationType="slide" transparent onRequestClose={onClose}>
|
||
<Pressable
|
||
onPress={onClose}
|
||
style={{ flex: 1, backgroundColor: 'rgba(0,0,0,0.82)', justifyContent: 'flex-end' }}
|
||
>
|
||
<Pressable
|
||
onPress={() => { /* 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',
|
||
}}
|
||
>
|
||
<View
|
||
style={{
|
||
alignSelf: 'center',
|
||
width: 40, height: 4, borderRadius: 2,
|
||
backgroundColor: '#2a2a2a',
|
||
marginBottom: 14,
|
||
}}
|
||
/>
|
||
<Text style={{ color: '#ffffff', fontSize: 18, fontWeight: '700', marginBottom: 14 }}>
|
||
Send tokens
|
||
</Text>
|
||
|
||
<Field label="Recipient address">
|
||
<TextInput
|
||
value={to}
|
||
onChangeText={setTo}
|
||
placeholder="DC… or pub_key hex"
|
||
placeholderTextColor="#5a5a5a"
|
||
autoCapitalize="none"
|
||
autoCorrect={false}
|
||
style={{
|
||
color: '#ffffff',
|
||
fontSize: 13,
|
||
fontFamily: 'monospace',
|
||
paddingVertical: 0,
|
||
}}
|
||
/>
|
||
</Field>
|
||
|
||
<View style={{ flexDirection: 'row', gap: 10, marginTop: 10 }}>
|
||
<View style={{ flex: 2 }}>
|
||
<Field label="Amount (µT)">
|
||
<TextInput
|
||
value={amount}
|
||
onChangeText={setAmount}
|
||
placeholder="1000"
|
||
placeholderTextColor="#5a5a5a"
|
||
keyboardType="numeric"
|
||
style={{ color: '#ffffff', fontSize: 14, paddingVertical: 0 }}
|
||
/>
|
||
</Field>
|
||
</View>
|
||
<View style={{ flex: 1 }}>
|
||
<Field label="Fee (µT)">
|
||
<TextInput
|
||
value={fee}
|
||
onChangeText={setFee}
|
||
placeholder="1000"
|
||
placeholderTextColor="#5a5a5a"
|
||
keyboardType="numeric"
|
||
style={{ color: '#ffffff', fontSize: 14, paddingVertical: 0 }}
|
||
/>
|
||
</Field>
|
||
</View>
|
||
</View>
|
||
|
||
{/* Summary */}
|
||
<View
|
||
style={{
|
||
marginTop: 12,
|
||
padding: 12,
|
||
borderRadius: 10,
|
||
backgroundColor: '#111111',
|
||
borderWidth: 1,
|
||
borderColor: '#1f1f1f',
|
||
}}
|
||
>
|
||
<SummaryRow label="Amount" value={formatAmount(amt)} />
|
||
<SummaryRow label="Fee" value={formatAmount(f)} muted />
|
||
<View style={{ height: 1, backgroundColor: '#1f1f1f', marginVertical: 6 }} />
|
||
<SummaryRow
|
||
label="Total"
|
||
value={formatAmount(total)}
|
||
accent={total > balance ? '#f4212e' : '#ffffff'}
|
||
/>
|
||
</View>
|
||
|
||
<View style={{ flexDirection: 'row', gap: 10, marginTop: 16 }}>
|
||
<Pressable onPress={onClose} style={{ flex: 1 }}>
|
||
{({ pressed }) => (
|
||
<View
|
||
style={{
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
paddingVertical: 12,
|
||
borderRadius: 999,
|
||
backgroundColor: pressed ? '#1a1a1a' : '#111111',
|
||
borderWidth: 1,
|
||
borderColor: '#1f1f1f',
|
||
}}
|
||
>
|
||
<Text style={{ color: '#ffffff', fontWeight: '600', fontSize: 14 }}>
|
||
Cancel
|
||
</Text>
|
||
</View>
|
||
)}
|
||
</Pressable>
|
||
<Pressable onPress={send} disabled={!ok || sending} style={{ flex: 2 }}>
|
||
{({ pressed }) => (
|
||
<View
|
||
style={{
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
paddingVertical: 12,
|
||
borderRadius: 999,
|
||
backgroundColor: !ok || sending
|
||
? '#1a1a1a'
|
||
: pressed ? '#1a8cd8' : '#1d9bf0',
|
||
}}
|
||
>
|
||
{sending ? (
|
||
<ActivityIndicator color="#ffffff" size="small" />
|
||
) : (
|
||
<Text style={{ color: '#ffffff', fontWeight: '700', fontSize: 14 }}>
|
||
Send
|
||
</Text>
|
||
)}
|
||
</View>
|
||
)}
|
||
</Pressable>
|
||
</View>
|
||
</Pressable>
|
||
</Pressable>
|
||
</Modal>
|
||
);
|
||
}
|
||
|
||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||
return (
|
||
<View>
|
||
<Text style={{ color: '#8b8b8b', fontSize: 12, marginBottom: 6 }}>{label}</Text>
|
||
<View
|
||
style={{
|
||
backgroundColor: '#000000',
|
||
borderRadius: 10,
|
||
paddingHorizontal: 12,
|
||
paddingVertical: 10,
|
||
borderWidth: 1,
|
||
borderColor: '#1f1f1f',
|
||
}}
|
||
>
|
||
{children}
|
||
</View>
|
||
</View>
|
||
);
|
||
}
|
||
|
||
function SummaryRow({
|
||
label, value, muted, accent,
|
||
}: {
|
||
label: string;
|
||
value: string;
|
||
muted?: boolean;
|
||
accent?: string;
|
||
}) {
|
||
return (
|
||
<View
|
||
style={{
|
||
flexDirection: 'row',
|
||
justifyContent: 'space-between',
|
||
paddingVertical: 3,
|
||
}}
|
||
>
|
||
<Text style={{ color: '#8b8b8b', fontSize: 12 }}>{label}</Text>
|
||
<Text
|
||
style={{
|
||
color: accent ?? (muted ? '#8b8b8b' : '#ffffff'),
|
||
fontSize: 13,
|
||
fontWeight: muted ? '500' : '700',
|
||
}}
|
||
>
|
||
{value}
|
||
</Text>
|
||
</View>
|
||
);
|
||
}
|