/** * Onboarding — 3-слайдовый pager перед auth-экранами. * * Slide 1 — "Why DChain": value-proposition, 3 пункта с иконками. * Slide 2 — "How it works": выбор релей-ноды (public paid vs свой node), * ссылка на Gitea, + node URL input с live ping. * Slide 3 — "Your keys": кнопки Create / Import. * * Если `keyFile` в store уже есть (bootstrap из RootLayout загрузил) — * делаем в (app), чтобы пользователь не видел вообще никакого * мелькания onboarding'а. До загрузки `booted === false` root показывает * чёрный экран. */ import React, { useEffect, useState, useCallback, useRef } from 'react'; import { View, Text, TextInput, Pressable, ScrollView, Alert, ActivityIndicator, Linking, Dimensions, useWindowDimensions, } from 'react-native'; import { router, Redirect } from 'expo-router'; import { Ionicons } from '@expo/vector-icons'; import { CameraView, useCameraPermissions } from 'expo-camera'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useStore } from '@/lib/store'; import { saveSettings } from '@/lib/storage'; import { setNodeUrl, getNetStats } from '@/lib/api'; const { width: SCREEN_W } = Dimensions.get('window'); const GITEA_URL = 'https://git.vsecoder.vodka/vsecoder/dchain'; export default function WelcomeScreen() { const insets = useSafeAreaInsets(); const { height: SCREEN_H } = useWindowDimensions(); const keyFile = useStore(s => s.keyFile); const booted = useStore(s => s.booted); const settings = useStore(s => s.settings); const setSettings = useStore(s => s.setSettings); const scrollRef = useRef(null); const [page, setPage] = useState(0); const [nodeInput, setNodeInput] = useState(''); const [scanning, setScanning] = useState(false); const [checking, setChecking] = useState(false); const [nodeOk, setNodeOk] = useState(null); const [permission, requestPermission] = useCameraPermissions(); useEffect(() => { setNodeInput(settings.nodeUrl); }, [settings.nodeUrl]); // ВСЕ hooks должны быть объявлены ДО любого early-return, иначе // React на следующем render'е посчитает разное число hooks и выкинет // "Rendered fewer hooks than expected". useCallback ниже — тоже hook. const applyNode = useCallback(async (url: string) => { const clean = url.trim().replace(/\/$/, ''); if (!clean) return; setChecking(true); setNodeOk(null); setNodeUrl(clean); try { await getNetStats(); setNodeOk(true); const next = { ...settings, nodeUrl: clean }; setSettings(next); await saveSettings(next); } catch { setNodeOk(false); } finally { setChecking(false); } }, [settings, setSettings]); const onQrScanned = useCallback(({ data }: { data: string }) => { setScanning(false); let url = data.trim(); try { const p = JSON.parse(url); if (p.nodeUrl) url = p.nodeUrl; } catch {} setNodeInput(url); applyNode(url); }, [applyNode]); // Bootstrap ещё не закончился — ничего не рендерим, RootLayout покажет // чёрный экран (single source of truth для splash-state'а). if (!booted) return null; // Ключи уже загружены — сразу в main app, без мелькания onboarding'а. if (keyFile) return ; const openScanner = async () => { if (!permission?.granted) { const { granted } = await requestPermission(); if (!granted) { Alert.alert('Camera permission required', 'Allow camera access to scan QR codes.'); return; } } setScanning(true); }; const goToPage = (p: number) => { scrollRef.current?.scrollTo({ x: p * SCREEN_W, animated: true }); setPage(p); }; if (scanning) { return ( Point at a DChain node QR code setScanning(false)} style={{ position: 'absolute', top: 56, left: 16, backgroundColor: 'rgba(0,0,0,0.6)', borderRadius: 20, paddingHorizontal: 16, paddingVertical: 8, }} > ✕ Cancel ); } const statusColor = nodeOk === true ? '#3ba55d' : nodeOk === false ? '#f4212e' : '#8b8b8b'; // Высота footer'а (dots + inset) — резервируем под неё снизу каждого // слайда, чтобы CTA-кнопки оказывались прямо над индикатором страниц, // а не залезали под него. const FOOTER_H = Math.max(insets.bottom, 20) + 8 + 12 + 7; // = padBottom + padTop + dot const PAGE_H = SCREEN_H - FOOTER_H; return ( { const p = Math.round(e.nativeEvent.contentOffset.x / SCREEN_W); setPage(p); }} style={{ flex: 1 }} keyboardShouldPersistTaps="handled" > {/* ───────── Slide 1: Why DChain ───────── */} DChain A messenger that belongs to you. {/* CTA — прижата к правому нижнему краю. */} goToPage(1)} /> {/* ───────── Slide 2: How it works ───────── */} How it works Messages travel through a relay node in encrypted form. Pick a public one or run your own. Node URL { setNodeInput(t); setNodeOk(null); }} onEndEditing={() => applyNode(nodeInput)} onSubmitEditing={() => applyNode(nodeInput)} placeholder="http://192.168.1.10:8080" placeholderTextColor="#5a5a5a" autoCapitalize="none" autoCorrect={false} keyboardType="url" returnKeyType="done" style={{ flex: 1, color: '#ffffff', fontSize: 14, paddingVertical: 12 }} /> {checking ? : nodeOk === true ? : nodeOk === false ? : null} ({ width: 48, alignItems: 'center', justifyContent: 'center', backgroundColor: pressed ? '#1a1a1a' : '#0a0a0a', borderWidth: 1, borderColor: '#1f1f1f', borderRadius: 12, })} > {nodeOk === false && ( Cannot reach node — check URL and that the node is running )} {/* CTA — прижата к правому нижнему краю. */} Linking.openURL(GITEA_URL).catch(() => {})} /> goToPage(2)} /> {/* ───────── Slide 3: Your keys ───────── */} Your account Generate a fresh keypair or import an existing one. Keys stay on this device only. {/* CTA — прижата к правому нижнему краю. */} router.push('/(auth)/import' as never)} /> router.push('/(auth)/create' as never)} /> {/* Footer: dots-only pager indicator. CTA-кнопки теперь inline на каждом слайде, чтобы выглядели как полноценные кнопки, а не мелкий "Далее" в углу. */} {[0, 1, 2].map(i => ( goToPage(i)} hitSlop={8} style={{ width: page === i ? 22 : 7, height: 7, borderRadius: 3.5, backgroundColor: page === i ? '#1d9bf0' : '#2a2a2a', }} /> ))} ); } // ───────── helper components ───────── /** * Primary CTA button — синий pill. Натуральная ширина (hugs content), * `numberOfLines={1}` на лейбле чтобы текст не переносился. Фон * применяется через inner View, а не напрямую на Pressable — это * обходит редкие RN-баги, когда backgroundColor на Pressable не * рендерится пока кнопка не нажата. */ function CTAPrimary({ label, onPress }: { label: string; onPress: () => void }) { return ( ({ opacity: pressed ? 0.85 : 1 })}> {label} ); } /** Secondary CTA — тёмный pill с border'ом, optional icon слева. */ function CTASecondary({ label, icon, onPress, }: { label: string; icon?: React.ComponentProps['name']; onPress: () => void; }) { return ( ({ opacity: pressed ? 0.6 : 1 })}> {icon && } {label} ); } function FeatureRow({ icon, title, text, }: { icon: React.ComponentProps['name']; title: string; text: string }) { return ( {title} {text} ); } function OptionCard({ icon, title, text, actionLabel, onAction, }: { icon: React.ComponentProps['name']; title: string; text: string; actionLabel?: string; onAction?: () => void; }) { return ( {title} {text} {actionLabel && onAction && ( ({ opacity: pressed ? 0.6 : 1, marginTop: 8 })}> {actionLabel} )} ); }