init
This commit is contained in:
@@ -0,0 +1,627 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { Pressable, ScrollView, StyleSheet, View } from 'react-native';
|
||||
import { useNavigation, useRoute, type RouteProp } from '@react-navigation/native';
|
||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import {
|
||||
AlertTriangle,
|
||||
BellOff,
|
||||
Copy,
|
||||
QrCode,
|
||||
RefreshCw,
|
||||
ScanLine,
|
||||
ShieldCheck,
|
||||
Smartphone,
|
||||
Trash2,
|
||||
Wifi,
|
||||
WifiOff,
|
||||
} from 'lucide-react-native';
|
||||
import Clipboard from '@react-native-clipboard/clipboard';
|
||||
import { useAlarm, useSubscriptions } from '../state/AlarmContext';
|
||||
import { useConnection } from '../state/ConnectionContext';
|
||||
import * as api from '../services/alarmApi';
|
||||
import { formatAutoStop } from '../services/alarmApi';
|
||||
import { relativeSince } from '../lib/time';
|
||||
import { truncateToken } from '../lib/qrPayload';
|
||||
import { TriggerPad } from '../components/TriggerPad';
|
||||
import {
|
||||
AppText,
|
||||
Button,
|
||||
Card,
|
||||
Divider,
|
||||
EmptyState,
|
||||
ErrorBanner,
|
||||
Field,
|
||||
Header,
|
||||
IconLine,
|
||||
LoadingView,
|
||||
OfflineStrip,
|
||||
StatePill,
|
||||
StateRing,
|
||||
TextInputBox,
|
||||
} from '../components/ui';
|
||||
import type { RootStackParamList } from '../navigation';
|
||||
import {
|
||||
bg,
|
||||
border,
|
||||
danger,
|
||||
focusRing,
|
||||
minTapTarget,
|
||||
radiusLg,
|
||||
radiusMd,
|
||||
space1,
|
||||
space2,
|
||||
space3,
|
||||
space4,
|
||||
space6,
|
||||
stateArmed,
|
||||
stateDisarmed,
|
||||
surfaceRaised,
|
||||
text,
|
||||
textFaint,
|
||||
textMuted,
|
||||
textSecondary,
|
||||
typeBodyStrong,
|
||||
typeCaption,
|
||||
typeCode,
|
||||
typeHeading,
|
||||
typeMicro,
|
||||
warn,
|
||||
type AlarmState,
|
||||
} from '../theme/tokens';
|
||||
|
||||
type Nav = NativeStackNavigationProp<RootStackParamList>;
|
||||
type Rt = RouteProp<RootStackParamList, 'AlertDetail'>;
|
||||
|
||||
const NEW = 'new';
|
||||
|
||||
export default function AlertDetailScreen() {
|
||||
const navigation = useNavigation<Nav>();
|
||||
const { params } = useRoute<Rt>();
|
||||
const alertId = params?.alertId ?? NEW;
|
||||
|
||||
if (alertId === NEW) return <CreateAlertView onCreated={id => navigation.replace('AlertDetail', { alertId: id, role: 'admin' })} />;
|
||||
|
||||
return <AlertDetailView alertId={alertId} role={params?.role} />;
|
||||
}
|
||||
|
||||
// ── Create ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function CreateAlertView({ onCreated }: { onCreated: (alertId: string) => void }) {
|
||||
const navigation = useNavigation<Nav>();
|
||||
const { createAlert } = useAlarm();
|
||||
const [name, setName] = useState('');
|
||||
const [message, setMessage] = useState('');
|
||||
const [autoStop, setAutoStop] = useState('60');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const submit = async () => {
|
||||
if (!name.trim()) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const seconds = Number.parseInt(autoStop, 10);
|
||||
const created = await createAlert({
|
||||
name: name.trim(),
|
||||
message: message.trim() || undefined,
|
||||
autoStopSeconds: Number.isFinite(seconds) && seconds > 0 ? seconds : undefined,
|
||||
});
|
||||
onCreated(created.alertId);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Could not create the alert.');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.root}>
|
||||
<Header title="New alert" subtitle="Create the alert, then show its code" onBack={() => navigation.goBack()} />
|
||||
<ScrollView contentContainerStyle={styles.content}>
|
||||
<Card>
|
||||
<View style={{ gap: space3 }}>
|
||||
<AppText style={{ ...typeHeading, color: text }}>What is this alert for?</AppText>
|
||||
<Field label="Alert name">
|
||||
<TextInputBox
|
||||
value={name}
|
||||
onChangeText={setName}
|
||||
placeholder="Front door"
|
||||
accessibilityLabel="Alert name"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Message (optional)">
|
||||
<TextInputBox
|
||||
value={message}
|
||||
onChangeText={setMessage}
|
||||
placeholder="Come to the front door"
|
||||
accessibilityLabel="Message"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Auto-stop (seconds)">
|
||||
<TextInputBox
|
||||
value={autoStop}
|
||||
onChangeText={setAutoStop}
|
||||
keyboardType="number-pad"
|
||||
placeholder="60"
|
||||
accessibilityLabel="Auto-stop in seconds"
|
||||
/>
|
||||
</Field>
|
||||
<AppText style={{ ...typeCaption, color: textSecondary }}>
|
||||
{formatAutoStop(Number.parseInt(autoStop, 10) || undefined)}. The alarm stops on its own
|
||||
after this, on every device.
|
||||
</AppText>
|
||||
</View>
|
||||
</Card>
|
||||
|
||||
{error ? <ErrorBanner message={error} onRetry={submit} /> : null}
|
||||
|
||||
<Button
|
||||
label={busy ? 'Creating…' : 'Create alert'}
|
||||
icon={ShieldCheck}
|
||||
onPress={submit}
|
||||
disabled={busy || !name.trim()}
|
||||
/>
|
||||
<AppText style={styles.note}>
|
||||
The owner token is shown once, right after creation. Store it before you leave the next
|
||||
screen — it cannot be recovered.
|
||||
</AppText>
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Detail ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function AlertDetailView({ alertId, role }: { alertId: string; role?: 'admin' | 'subscriber' }) {
|
||||
const navigation = useNavigation<Nav>();
|
||||
const { online } = useConnection();
|
||||
const {
|
||||
adminAlerts,
|
||||
getAlert,
|
||||
adminTokenFor,
|
||||
updateAlertLocal,
|
||||
forgetAdminAlert,
|
||||
leaveAlert,
|
||||
liveEvent,
|
||||
refresh,
|
||||
setActiveAlertId,
|
||||
delivery,
|
||||
socketState,
|
||||
} = useAlarm();
|
||||
|
||||
const alert = getAlert(alertId);
|
||||
const isAdmin = role === 'admin' || !!adminAlerts.find(a => a.alertId === alertId);
|
||||
const adminToken = adminTokenFor(alertId);
|
||||
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const [revealToken, setRevealToken] = useState(false);
|
||||
const [serverAlert, setServerAlert] = useState<api.Alert | null>(null);
|
||||
|
||||
const { subs, loading: subsLoading, error: subsError, reload: reloadSubs } = useSubscriptions(
|
||||
isAdmin ? alertId : null,
|
||||
isAdmin,
|
||||
);
|
||||
|
||||
// Read the authoritative state on mount; the local copy is only a cache.
|
||||
const loadServerAlert = useCallback(async () => {
|
||||
try {
|
||||
const fresh = await api.getAlert(alertId);
|
||||
setServerAlert(fresh);
|
||||
updateAlertLocal(alertId, { status: fresh.status, name: fresh.name, message: fresh.message });
|
||||
} catch {
|
||||
// Offline is fine — the cached copy renders.
|
||||
}
|
||||
}, [alertId, updateAlertLocal]);
|
||||
|
||||
useEffect(() => {
|
||||
setActiveAlertId(isAdmin ? null : alertId);
|
||||
loadServerAlert();
|
||||
}, [alertId, isAdmin, loadServerAlert, setActiveAlertId]);
|
||||
|
||||
// `AlertStatus` IS the four-state ramp — no mapping needed.
|
||||
const state: AlarmState = serverAlert?.status ?? alert?.status ?? 'idle';
|
||||
// `armed`/`disarmed` are ringing-state history, not an enablement gate: there
|
||||
// is no arm control and no `armAlert` action, and the service accepts a
|
||||
// trigger from any state. So the copy labels the last event instead of
|
||||
// promising a re-arm that does not exist.
|
||||
const lastEventAt = serverAlert?.lastEvent?.serverTime ?? serverAlert?.triggeredAt ?? serverAlert?.disarmedAt;
|
||||
const lastEventAgo = relativeSince(lastEventAt, Date.now());
|
||||
const stateSentence =
|
||||
state === 'triggered'
|
||||
? 'Ringing now on every subscribed device.'
|
||||
: state === 'disarmed'
|
||||
? lastEventAgo
|
||||
? `Disarmed ${lastEventAgo} — it will ring again the moment the owner triggers it.`
|
||||
: 'Disarmed — it will ring again the moment the owner triggers it.'
|
||||
: state === 'armed'
|
||||
? lastEventAgo
|
||||
? `Armed ${lastEventAgo} — triggering rings every subscribed device.`
|
||||
: 'Armed — triggering rings every subscribed device.'
|
||||
: 'Idle — triggering rings every subscribed device.';
|
||||
const live = liveEvent?.alertId === alertId && liveEvent.action === 'trigger';
|
||||
const effectiveState = live ? 'triggered' : state;
|
||||
const deviceCount = subs.length;
|
||||
|
||||
const doTrigger = async () => {
|
||||
if (!adminToken) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await api.triggerAlert({ alertId, adminToken });
|
||||
updateAlertLocal(alertId, { status: 'triggered', lastTriggered: res.serverTime });
|
||||
setServerAlert(prev => (prev ? { ...prev, status: 'triggered' } : prev));
|
||||
setNotice(`Triggered on ${deviceCount} device${deviceCount === 1 ? '' : 's'}.`);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Could not trigger the alarm.');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const doDisarm = async () => {
|
||||
if (!adminToken) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.disarmAlert({ alertId, adminToken });
|
||||
updateAlertLocal(alertId, { status: 'disarmed' });
|
||||
setServerAlert(prev => (prev ? { ...prev, status: 'disarmed' } : prev));
|
||||
setNotice('Disarmed on every device.');
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Could not disarm the alarm.');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const doDelete = async () => {
|
||||
if (!adminToken) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.deleteAlert({ alertId, adminToken });
|
||||
await forgetAdminAlert(alertId);
|
||||
navigation.goBack();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Could not delete the alert.');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const doLeave = async () => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await leaveAlert(alertId);
|
||||
navigation.goBack();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Could not leave this alert.');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const copy = async (value: string, label: string) => {
|
||||
try {
|
||||
Clipboard.setString(value);
|
||||
setNotice(`${label} copied.`);
|
||||
} catch {
|
||||
setNotice('Could not copy — long-press the value to select it.');
|
||||
}
|
||||
};
|
||||
|
||||
if (!alert && !serverAlert) {
|
||||
return (
|
||||
<View style={styles.root}>
|
||||
<Header title="Alert" onBack={() => navigation.goBack()} />
|
||||
<EmptyState
|
||||
icon={AlertTriangle}
|
||||
title="Alert not found on this device"
|
||||
subtitle="This device has no token for that alert. Scan its QR code again to rejoin."
|
||||
action={<Button label="Scan a QR code" icon={ScanLine} onPress={() => navigation.navigate('Scanner')} />}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.root}>
|
||||
{!online ? <OfflineStrip /> : null}
|
||||
<Header
|
||||
title={alert?.name ?? serverAlert?.name ?? alertId}
|
||||
subtitle={isAdmin ? 'Owner controls' : 'Subscribed device'}
|
||||
onBack={() => navigation.goBack()}
|
||||
right={
|
||||
<Pressable
|
||||
onPress={refresh}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Refresh status"
|
||||
style={styles.iconButton}
|
||||
>
|
||||
<RefreshCw size={18} color={textMuted} />
|
||||
</Pressable>
|
||||
}
|
||||
/>
|
||||
<ScrollView contentContainerStyle={styles.content}>
|
||||
{error ? <ErrorBanner message={error} onRetry={loadServerAlert} /> : null}
|
||||
{notice ? (
|
||||
<View style={styles.notice} accessibilityLiveRegion="polite">
|
||||
<AppText style={{ ...typeCaption, color: stateDisarmed }}>{notice}</AppText>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<View style={styles.ringWrap}>
|
||||
<StateRing state={effectiveState} />
|
||||
<StatePill state={effectiveState} style={{ marginTop: space4 }} />
|
||||
<AppText style={styles.stateLine}>{stateSentence}</AppText>
|
||||
</View>
|
||||
|
||||
{isAdmin ? (
|
||||
<AdminControls
|
||||
adminToken={adminToken}
|
||||
revealToken={revealToken}
|
||||
onRevealToken={() => setRevealToken(true)}
|
||||
onCopy={copy}
|
||||
deviceCount={deviceCount}
|
||||
busy={busy}
|
||||
ringing={effectiveState === 'triggered'}
|
||||
onTrigger={doTrigger}
|
||||
onDisarm={doDisarm}
|
||||
onShowQr={() => navigation.navigate('QrDisplay', { alertId })}
|
||||
autoStopSeconds={serverAlert?.autoStopSeconds ?? alert?.autoStopSeconds}
|
||||
/>
|
||||
) : (
|
||||
<SubscriberControls
|
||||
alertId={alertId}
|
||||
alert={alert}
|
||||
delivery={delivery}
|
||||
socketState={socketState}
|
||||
busy={busy}
|
||||
onLeave={doLeave}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isAdmin ? (
|
||||
<View style={{ gap: space3 }}>
|
||||
<View style={styles.sectionHead}>
|
||||
<AppText style={{ ...typeHeading, color: text }}>Subscribed devices</AppText>
|
||||
<AppText style={{ ...typeMicro, color: textFaint }}>{deviceCount}</AppText>
|
||||
</View>
|
||||
{subsError ? <ErrorBanner message={subsError} onRetry={reloadSubs} /> : null}
|
||||
{subsLoading && !subs.length ? (
|
||||
<LoadingView label="Loading devices…" />
|
||||
) : subs.length === 0 ? (
|
||||
<Card>
|
||||
<IconLine icon={Smartphone} color={textMuted}>
|
||||
No device has scanned this alert yet. Show the QR code to subscribe one.
|
||||
</IconLine>
|
||||
</Card>
|
||||
) : (
|
||||
subs.map(sub => (
|
||||
<Card key={sub.subscriptionId}>
|
||||
<View style={styles.subRow}>
|
||||
<View style={{ flex: 1, gap: space1 }}>
|
||||
<AppText style={{ ...typeBodyStrong, color: text }}>
|
||||
{sub.deviceLabel || sub.deviceId}
|
||||
</AppText>
|
||||
<AppText style={{ ...typeMicro, color: textFaint }}>
|
||||
{sub.platform ?? 'android'} · joined {sub.joined ?? 'recently'}
|
||||
</AppText>
|
||||
</View>
|
||||
<Pressable
|
||||
onPress={async () => {
|
||||
try {
|
||||
await api.removeSubscription({ alertId, subscriptionId: sub.subscriptionId });
|
||||
reloadSubs();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Could not revoke that device.');
|
||||
}
|
||||
}}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Revoke ${sub.deviceLabel || sub.deviceId}`}
|
||||
style={styles.iconButton}
|
||||
>
|
||||
<Trash2 size={18} color={danger} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Destructive ramp is separate from the alarm ramp (DESIGN.md §2.3). */}
|
||||
{isAdmin ? (
|
||||
<View style={{ gap: space2 }}>
|
||||
<Button
|
||||
label="Delete alert"
|
||||
variant="danger"
|
||||
icon={Trash2}
|
||||
disabled={busy}
|
||||
onPress={doDelete}
|
||||
/>
|
||||
<AppText style={styles.note}>
|
||||
Deleting removes the alert and its tokens. Every subscribed device stops receiving it.
|
||||
</AppText>
|
||||
</View>
|
||||
) : (
|
||||
<View style={{ gap: space2 }}>
|
||||
<Button label="Leave this alert" variant="danger" icon={BellOff} disabled={busy} onPress={doLeave} />
|
||||
<AppText style={styles.note}>
|
||||
Leaving removes this device from the alert. You can rejoin with the QR code.
|
||||
</AppText>
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function AdminControls({
|
||||
adminToken,
|
||||
revealToken,
|
||||
onRevealToken,
|
||||
onCopy,
|
||||
deviceCount,
|
||||
busy,
|
||||
ringing,
|
||||
onTrigger,
|
||||
onDisarm,
|
||||
onShowQr,
|
||||
autoStopSeconds,
|
||||
}: {
|
||||
adminToken: string | null;
|
||||
revealToken: boolean;
|
||||
onRevealToken: () => void;
|
||||
onCopy: (value: string, label: string) => void;
|
||||
deviceCount: number;
|
||||
busy: boolean;
|
||||
/** Does the alarm ring on other devices right now (drives the pad's copy). */
|
||||
ringing: boolean;
|
||||
onTrigger: () => void;
|
||||
onDisarm: () => void;
|
||||
onShowQr: () => void;
|
||||
autoStopSeconds?: number;
|
||||
}) {
|
||||
return (
|
||||
<View style={{ gap: space4 }}>
|
||||
<Card>
|
||||
<View style={{ gap: space3 }}>
|
||||
<AppText style={{ ...typeHeading, color: text }}>Owner token</AppText>
|
||||
{adminToken ? (
|
||||
<>
|
||||
<Pressable
|
||||
onPress={() => (revealToken ? onCopy(adminToken, 'Owner token') : onRevealToken())}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={revealToken ? 'Copy owner token' : 'Reveal owner token'}
|
||||
style={styles.tokenBox}
|
||||
>
|
||||
<AppText style={{ ...typeCode, color: text }}>
|
||||
{revealToken ? truncateToken(adminToken, 24) : truncateToken(adminToken)}
|
||||
</AppText>
|
||||
<Copy size={16} color={textFaint} />
|
||||
</Pressable>
|
||||
<AppText style={{ ...typeMicro, color: textFaint }}>
|
||||
{revealToken ? 'Tap to copy' : 'Tap to reveal the first 24 characters'}
|
||||
</AppText>
|
||||
</>
|
||||
) : (
|
||||
<IconLine icon={AlertTriangle} color={warn}>
|
||||
No owner token is stored on this device, so this alert cannot be triggered from here.
|
||||
</IconLine>
|
||||
)}
|
||||
</View>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<View style={{ gap: space3 }}>
|
||||
<AppText style={{ ...typeHeading, color: text }}>Control</AppText>
|
||||
<TriggerPad
|
||||
ringing={ringing}
|
||||
onTrigger={onTrigger}
|
||||
onDisarm={onDisarm}
|
||||
disabled={busy || !adminToken}
|
||||
disabledReason={
|
||||
!adminToken
|
||||
? 'This device has no owner token, so it cannot trigger the alarm.'
|
||||
: busy
|
||||
? 'Working…'
|
||||
: undefined
|
||||
}
|
||||
deviceCount={deviceCount}
|
||||
/>
|
||||
{autoStopSeconds ? (
|
||||
<AppText style={{ ...typeMicro, color: textFaint }}>
|
||||
Auto-stops after {formatAutoStop(autoStopSeconds)} on every device.
|
||||
</AppText>
|
||||
) : null}
|
||||
</View>
|
||||
</Card>
|
||||
|
||||
<Card onPress={onShowQr} accessibilityLabel="Show the QR code to subscribe a device">
|
||||
<View style={styles.qrRow}>
|
||||
<QrCode size={22} color={focusRing} />
|
||||
<View style={{ flex: 1 }}>
|
||||
<AppText style={{ ...typeBodyStrong, color: text }}>Show QR code</AppText>
|
||||
<AppText style={{ ...typeCaption, color: textSecondary }}>
|
||||
Subscribes a device in one scan
|
||||
</AppText>
|
||||
</View>
|
||||
</View>
|
||||
</Card>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function SubscriberControls({
|
||||
alert,
|
||||
delivery,
|
||||
socketState,
|
||||
busy,
|
||||
onLeave,
|
||||
}: {
|
||||
alertId: string;
|
||||
alert: api.Alert | undefined;
|
||||
delivery: string;
|
||||
socketState: string;
|
||||
busy: boolean;
|
||||
onLeave: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Card>
|
||||
<View style={{ gap: space3 }}>
|
||||
<AppText style={{ ...typeHeading, color: text }}>This device</AppText>
|
||||
<IconLine icon={delivery === 'socket' ? Wifi : WifiOff} color={delivery === 'socket' ? stateDisarmed : warn}>
|
||||
{delivery === 'socket'
|
||||
? `Connected for live delivery (${socketState}).`
|
||||
: 'Live delivery unavailable in proxy mode — status is polled every 5 seconds.'}
|
||||
</IconLine>
|
||||
<IconLine icon={ShieldCheck} color={stateArmed}>
|
||||
{alert?.deviceLabel ?? 'This device'} rings at maximum volume when the owner triggers the alert.
|
||||
</IconLine>
|
||||
<Button label="Leave" variant="secondary" disabled={busy} onPress={onLeave} />
|
||||
</View>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
root: { flex: 1, backgroundColor: bg },
|
||||
content: { padding: space4, paddingBottom: 48, gap: space4 },
|
||||
iconButton: {
|
||||
width: minTapTarget,
|
||||
height: minTapTarget,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
notice: {
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(95,224,200,0.3)',
|
||||
backgroundColor: 'rgba(95,224,200,0.1)',
|
||||
borderRadius: radiusLg,
|
||||
padding: space3,
|
||||
},
|
||||
ringWrap: { alignItems: 'center', paddingVertical: space6 },
|
||||
stateLine: { ...typeCaption, color: textSecondary, textAlign: 'center', marginTop: space3 },
|
||||
sectionHead: { flexDirection: 'row', alignItems: 'baseline', justifyContent: 'space-between' },
|
||||
subRow: { flexDirection: 'row', alignItems: 'center', gap: space3 },
|
||||
tokenBox: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: space3,
|
||||
backgroundColor: surfaceRaised,
|
||||
borderRadius: radiusMd,
|
||||
borderWidth: 1,
|
||||
borderColor: border,
|
||||
paddingHorizontal: space4,
|
||||
minHeight: minTapTarget,
|
||||
},
|
||||
qrRow: { flexDirection: 'row', alignItems: 'center', gap: space3 },
|
||||
note: { ...typeCaption, color: textSecondary },
|
||||
});
|
||||
Reference in New Issue
Block a user