import React, { useState, useEffect, useRef, useMemo, createContext, useContext } from 'react';
import { View, Text, Pressable, ScrollView, StyleSheet, Animated, Easing, Modal, Dimensions, useColorScheme, TextInput } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import Svg, { Circle, Path, Rect, Line, Polyline, Defs, LinearGradient as SvgGradient, Stop } from 'react-native-svg';
import * as Lucide from 'lucide-react-native';
import { LinearGradient } from 'expo-linear-gradient';
// ─── colour helpers ─────────────────────────────────────────────────────────
export function alpha(hex, a) {
const h = hex.replace('#', '');
const n = parseInt(h.length === 3 ? h.split('').map((c) => c + c).join('') : h, 16);
const r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255;
return 'rgba(' + r + ',' + g + ',' + b + ',' + a + ')';
}
export function mix(hexA, hexB, p) {
const pa = parseInt(hexA.replace('#', ''), 16), pb = parseInt(hexB.replace('#', ''), 16);
const ch = (sh) => Math.round(((pa >> sh) & 255) * (1 - p) + ((pb >> sh) & 255) * p);
const to = (v) => ('0' + v.toString(16)).slice(-2);
return '#' + to(ch(16)) + to(ch(8)) + to(ch(0));
}
function luminance(hex) {
const n = parseInt(hex.replace('#', ''), 16);
const r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255;
return (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
}
// ─── theme ──────────────────────────────────────────────────────────────────
const DARK = { bg: '#000000', card: '#1C1C1E', card2: '#2C2C2E', text: '#F2F2F7', text2: '#9A9AA3', text3: '#636369', line: '#2A2A2E' };
const LIGHT = { bg: '#F2F2F7', card: '#FFFFFF', card2: '#EDEDF2', text: '#111114', text2: '#6E6E76', text3: '#A6A6AE', line: '#E3E3E8' };
const SEMANTIC = { success: '#30D158', warning: '#FFB340', danger: '#FF453A', info: '#4DA3FF' };
const ThemeCtx = createContext(null);
export function makeTheme(accent, isDark, overrides) {
const base = isDark ? DARK : LIGHT;
const o = (overrides && (isDark ? overrides.dark : overrides.light)) || {};
const t = Object.assign({}, base, SEMANTIC, o, { accent: accent, isDark: isDark });
t.onAccent = luminance(accent) > 0.6 ? '#0B0B10' : '#FFFFFF';
t.accentSoft = alpha(accent, isDark ? 0.22 : 0.14);
t.accentFaint = alpha(accent, isDark ? 0.12 : 0.08);
return t;
}
export function ThemeProvider({ accent, overrides, children }) {
const scheme = useColorScheme();
const t = useMemo(() => makeTheme(accent, scheme !== 'light', overrides), [accent, scheme, overrides]);
return {children};
}
export function useT() {
const t = useContext(ThemeCtx);
return t || makeTheme('#7C5CFF', true, null);
}
export const useInsets = useSafeAreaInsets;
export const win = () => Dimensions.get('window');
// ─── type scale (iOS) ───────────────────────────────────────────────────────
export const T = StyleSheet.create({
largeTitle: { fontSize: 34, fontWeight: '700', letterSpacing: -0.7, lineHeight: 40 },
title1: { fontSize: 28, fontWeight: '700', letterSpacing: -0.5, lineHeight: 34 },
title2: { fontSize: 22, fontWeight: '700', letterSpacing: -0.35, lineHeight: 28 },
title3: { fontSize: 20, fontWeight: '600', letterSpacing: -0.25, lineHeight: 25 },
headline: { fontSize: 17, fontWeight: '600', letterSpacing: -0.2, lineHeight: 22 },
body: { fontSize: 17, fontWeight: '400', letterSpacing: -0.2, lineHeight: 22 },
callout: { fontSize: 16, fontWeight: '400', lineHeight: 21 },
subhead: { fontSize: 15, fontWeight: '400', lineHeight: 20 },
footnote: { fontSize: 13, fontWeight: '400', lineHeight: 18 },
caption: { fontSize: 12, fontWeight: '500', lineHeight: 16 },
caption2: { fontSize: 11, fontWeight: '600', lineHeight: 13, letterSpacing: 0.3 },
num: { fontVariant: ['tabular-nums'] },
});
// ─── motion ─────────────────────────────────────────────────────────────────
export function useTween(target, ms) {
const dur = ms || 650;
const [v, setV] = useState(target);
const ref = useRef({ raf: 0 });
useEffect(() => {
const from = v, to = target, t0 = Date.now();
if (from === to) return;
cancelAnimationFrame(ref.current.raf);
const tick = () => {
const p = Math.min(1, (Date.now() - t0) / dur);
const e = 1 - Math.pow(1 - p, 3);
setV(from + (to - from) * e);
if (p < 1) ref.current.raf = requestAnimationFrame(tick);
};
ref.current.raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(ref.current.raf);
}, [target]);
return v;
}
export function usePressScale(to) {
const scale = useRef(new Animated.Value(1)).current;
const onPressIn = () => Animated.spring(scale, { toValue: to || 0.96, useNativeDriver: false, speed: 40, bounciness: 2 }).start();
const onPressOut = () => Animated.spring(scale, { toValue: 1, useNativeDriver: false, speed: 20, bounciness: 8 }).start();
return { scale, onPressIn, onPressOut };
}
// ─── icons ──────────────────────────────────────────────────────────────────
export function Icon({ name, size, color, strokeWidth, fill, style }) {
const t = useT();
const Cmp = Lucide[name] || Lucide.Circle;
return ;
}
// ─── layout primitives ──────────────────────────────────────────────────────
export function Screen({ children, style, bg }) {
const t = useT();
return {children};
}
export function Scroll({ children, style, contentStyle, pad, horizontal, bottom }) {
const p = pad == null ? 20 : pad;
return (
{children}
);
}
export function Spacer({ h, w }) { return ; }
export function Divider({ inset, style }) {
const t = useT();
return ;
}
/** Large-title header for a tab/root screen: kicker, 34pt title, optional trailing slot. */
export function Header({ kicker, title, subtitle, right, style, kickerColor }) {
const t = useT();
const ins = useInsets();
return (
{kicker ? {kicker} : null}
{title}
{subtitle ? {subtitle} : null}
{right ? {right} : null}
);
}
/** Inline navigation bar for a pushed screen: back chevron, centred title, trailing slot. */
export function NavBar({ title, onBack, right, transparent, tint }) {
const t = useT();
const ins = useInsets();
const color = tint || t.text;
return (
[{ width: 44, height: 40, alignItems: 'center', justifyContent: 'center', borderRadius: 20 }, pressed && { opacity: 0.5 }]}>
{title}
{right || null}
);
}
/** Section label (iOS grouped header) with optional trailing action. */
export function SectionTitle({ title, action, onAction, style }) {
const t = useT();
return (
{title}
{action ? (
{action}
) : null}
);
}
export function Card({ children, style, onPress, pad, bg }) {
const t = useT();
const base = [{ backgroundColor: bg || t.card, borderRadius: 20, padding: pad == null ? 16 : pad }, style];
if (!onPress) return {children};
return (
[base, pressed && { opacity: 0.86, transform: [{ scale: 0.985 }] }]}>
{children}
);
}
/** Inset-grouped list: wraps rows in a card, drawing hairline separators. */
export function Group({ children, style }) {
const t = useT();
const kids = React.Children.toArray(children).filter(Boolean);
return (
{kids.map((k, i) => (
{k}
{i < kids.length - 1 ? : null}
))}
);
}
/** A list row: leading icon tile (lucide or emoji/custom), title, subtitle, trailing value, chevron. */
export function Row({ icon, emoji, iconColor, iconBg, leading, title, subtitle, value, valueColor, sub2, chevron, onPress, right, pad, titleStyle }) {
const t = useT();
const body = (
{leading ? leading : (icon || emoji) ? (
{emoji ? {emoji} : }
) : null}
{title}
{subtitle ? {subtitle} : null}
{value != null ? (
{value}
{sub2 ? {sub2} : null}
) : null}
{right ? {right} : null}
{chevron ? : null}
);
if (!onPress) return body;
return [pressed && { backgroundColor: t.card2 }]}>{body};
}
// ─── controls ───────────────────────────────────────────────────────────────
export function Button({ label, onPress, kind, icon, small, full, color, style, disabled }) {
const t = useT();
const { scale, onPressIn, onPressOut } = usePressScale(0.96);
const k = kind || 'primary';
const accent = color || t.accent;
const bg = k === 'primary' ? accent : k === 'secondary' ? t.card2 : k === 'soft' ? alpha(accent, t.isDark ? 0.22 : 0.14) : 'transparent';
const fg = k === 'primary' ? (luminance(accent) > 0.6 ? '#0B0B10' : '#FFFFFF') : k === 'secondary' ? t.text : accent;
return (
{icon ? : null}
{label}
);
}
export function IconButton({ name, onPress, size, bg, color, style }) {
const t = useT();
const s = size || 40;
return (
[{ width: s, height: s, borderRadius: s / 2, backgroundColor: bg || t.card2, alignItems: 'center', justifyContent: 'center' }, pressed && { opacity: 0.6 }, style]}>
);
}
export function Chip({ label, active, onPress, icon, color, style }) {
const t = useT();
const c = color || t.accent;
return (
[{ paddingHorizontal: 14, paddingVertical: 8, borderRadius: 999, backgroundColor: active ? c : t.card, flexDirection: 'row', alignItems: 'center', gap: 6 }, pressed && { opacity: 0.7 }, style]}>
{icon ? 0.6 ? '#0B0B10' : '#fff') : t.text2} strokeWidth={2.4} /> : null}
0.6 ? '#0B0B10' : '#fff') : t.text, fontWeight: '600' }]}>{label}
);
}
export function Pill({ label, color, style, small }) {
const t = useT();
const c = color || t.accent;
return (
{label}
);
}
/** iOS segmented control with a sliding thumb. */
export function Segmented({ options, value, onChange, style }) {
const t = useT();
const idx = Math.max(0, options.findIndex((o) => (o.value == null ? o : o.value) === value));
const x = useRef(new Animated.Value(idx)).current;
const [w, setW] = useState(0);
useEffect(() => { Animated.spring(x, { toValue: idx, useNativeDriver: false, speed: 24, bounciness: 4 }).start(); }, [idx]);
const segW = options.length ? (w - 4) / options.length : 0;
return (
setW(e.nativeEvent.layout.width)} style={[{ height: 36, borderRadius: 11, backgroundColor: t.isDark ? t.card2 : '#E2E2E8', padding: 2, flexDirection: 'row' }, style]}>
{w > 0 ? (
) : null}
{options.map((o, i) => {
const v = o.value == null ? o : o.value;
const label = o.label == null ? o : o.label;
return (
onChange(v)} style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
{label}
);
})}
);
}
/** iOS toggle (own implementation so both appearances look native). */
export function Toggle({ value, onChange, color }) {
const t = useT();
const x = useRef(new Animated.Value(value ? 1 : 0)).current;
useEffect(() => { Animated.spring(x, { toValue: value ? 1 : 0, useNativeDriver: false, speed: 30, bounciness: 3 }).start(); }, [value]);
const bg = x.interpolate({ inputRange: [0, 1], outputRange: [t.isDark ? '#39393D' : '#E9E9EB', color || t.success] });
return (
onChange(!value)} hitSlop={8}>
);
}
export function Checkbox({ checked, onChange, color, size }) {
const t = useT();
const s = size || 26;
const c = color || t.accent;
return (
onChange && onChange(!checked)} hitSlop={8} style={{ width: s, height: s, borderRadius: s / 2, borderWidth: 2, borderColor: checked ? c : t.text3, backgroundColor: checked ? c : 'transparent', alignItems: 'center', justifyContent: 'center' }}>
{checked ? 0.6 ? '#0B0B10' : '#fff'} strokeWidth={3.2} /> : null}
);
}
export function SearchBar({ placeholder, value, onChange, style }) {
const t = useT();
return (
);
}
// ─── data display ───────────────────────────────────────────────────────────
export function Stat({ label, value, delta, up, style, color, big }) {
const t = useT();
return (
{label}
{value}
{delta ? {delta} : null}
);
}
export function Avatar({ name, size, color, style, uri }) {
const t = useT();
const s = size || 40;
const c = color || t.accent;
const initials = (name || '?').split(' ').map((p) => p[0]).join('').slice(0, 2).toUpperCase();
return (
{initials}
);
}
export function Progress({ value, color, height, track, style }) {
const t = useT();
const v = useTween(Math.max(0, Math.min(1, value)));
const h = height || 8;
return (
);
}
/** Circular progress ring (SVG). progress 0..1. Children render in the middle. */
export function Ring({ progress, size, stroke, color, track, children, style }) {
const t = useT();
const s = size || 160;
const sw = stroke || 14;
const r = (s - sw) / 2;
const c = 2 * Math.PI * r;
const p = useTween(Math.max(0, Math.min(1, progress)));
return (
{children}
);
}
/** Bar chart (SVG). values: numbers; labels optional; highlight index. */
export function Bars({ values, labels, height, color, dim, highlight, gap, radius, style, max }) {
const t = useT();
const h = height || 120;
const [w, setW] = useState(0);
const m = max || Math.max(1, ...values);
const n = values.length;
const g = gap == null ? 8 : gap;
const bw = n ? (w - g * (n - 1)) / n : 0;
const rad = radius == null ? Math.min(6, bw / 2) : radius;
const lh = labels ? 18 : 0;
return (
setW(e.nativeEvent.layout.width)} style={[{ height: h + lh }, style]}>
{w > 0 ? (
) : null}
{labels ? (
{labels.map((l, i) => {l})}
) : null}
);
}
/** Sparkline / area line (SVG). */
export function Spark({ values, height, color, fill, stroke, style, dot }) {
const t = useT();
const h = height || 80;
const [w, setW] = useState(0);
const c = color || t.accent;
const min = Math.min(...values), max = Math.max(...values);
const span = max - min || 1;
const pts = values.map((v, i) => [values.length > 1 ? (i / (values.length - 1)) * w : 0, 6 + (1 - (v - min) / span) * (h - 12)]);
const d = pts.map((p, i) => (i ? 'L' : 'M') + p[0].toFixed(1) + ' ' + p[1].toFixed(1)).join(' ');
const area = d + ' L' + w + ' ' + h + ' L0 ' + h + ' Z';
const last = pts[pts.length - 1] || [0, 0];
return (
setW(e.nativeEvent.layout.width)} style={[{ height: h }, style]}>
{w > 0 ? (
) : null}
);
}
// ─── navigation ─────────────────────────────────────────────────────────────
/** Bottom tab bar. tabs: [{ key, label, icon }]. */
export function TabBar({ tabs, active, onChange, style, accent }) {
const t = useT();
const ins = useInsets();
const c = accent || t.accent;
return (
{tabs.map((tab) => {
const on = tab.key === active;
return (
onChange(tab.key)} style={({ pressed }) => [{ flex: 1, alignItems: 'center', gap: 3, paddingVertical: 2 }, pressed && { opacity: 0.6 }]}>
{tab.label}
);
})}
);
}
/** Tab screens: every tab stays mounted (state + scroll position survive a switch); only the active one is displayed. */
export function Tabs({ active, screens }) {
return (
{Object.keys(screens).map((k) => (
{screens[k]}
))}
);
}
/**
* Minimal navigation stack: screens = { name: Component }, each rendered with
* { nav: { push, pop, canPop }, params }. Pushed screens slide in from the right
* while the screen beneath parallaxes left (iOS push transition).
*/
export function Stack({ screens, initial, params }) {
const t = useT();
const [stack, setStack] = useState([{ key: 'root', name: initial, params: params || {} }]);
const [leavingKey, setLeavingKey] = useState(null);
const nav = useMemo(() => ({
push: (name, p) => setStack((s) => [...s, { key: name + '-' + Date.now(), name: name, params: p || {} }]),
pop: () => setStack((s) => { if (s.length > 1) setLeavingKey(s[s.length - 1].key); return s; }),
reset: (name, p) => setStack([{ key: 'root-' + Date.now(), name: name, params: p || {} }]),
canPop: stack.length > 1,
}), [stack.length]);
const width = win().width;
const finishPop = (key) => { setStack((s) => s.filter((e) => e.key !== key)); setLeavingKey(null); };
return (
{stack.map((entry, i) => {
const Cmp = screens[entry.name];
const isTop = i === stack.length - 1;
const under = i === stack.length - 2 && stack.length > 1;
return (
finishPop(entry.key)}>
{Cmp ? : null}
);
})}
);
}
function StackLayer({ children, width, index, isTop, under, leaving, onLeft }) {
const prog = useRef(new Animated.Value(index === 0 ? 1 : 0)).current;
const t = useT();
useEffect(() => {
if (index === 0) return;
Animated.timing(prog, { toValue: 1, duration: 320, easing: Easing.out(Easing.cubic), useNativeDriver: false }).start();
}, []);
useEffect(() => {
if (!leaving) return;
Animated.timing(prog, { toValue: 0, duration: 280, easing: Easing.inOut(Easing.cubic), useNativeDriver: false }).start(() => onLeft());
}, [leaving]);
const tx = index === 0 ? 0 : prog.interpolate({ inputRange: [0, 1], outputRange: [width, 0] });
return (
{children}
{under ? : null}
);
}
/** Bottom sheet over a dimmed backdrop. */
export function Sheet({ visible, onClose, title, children, height }) {
const t = useT();
const ins = useInsets();
const y = useRef(new Animated.Value(1)).current;
const [mounted, setMounted] = useState(visible);
useEffect(() => {
if (visible) { setMounted(true); Animated.spring(y, { toValue: 0, useNativeDriver: false, speed: 18, bounciness: 2 }).start(); }
else Animated.timing(y, { toValue: 1, duration: 220, easing: Easing.in(Easing.cubic), useNativeDriver: false }).start(() => setMounted(false));
}, [visible]);
if (!mounted) return null;
const h = height || 420;
return (
{title ? {title} : null}
{children}
);
}
/** Linear gradient surface (expo-linear-gradient, vendored). colors: 2+ hex. */
export function Gradient({ colors, style, children, start, end, angle }) {
const a = angle == null ? 135 : angle;
const rad = (a * Math.PI) / 180;
const s = start || { x: 0.5 - Math.sin(rad) / 2, y: 0.5 + Math.cos(rad) / 2 };
const e = end || { x: 0.5 + Math.sin(rad) / 2, y: 0.5 - Math.cos(rad) / 2 };
return {children};
}
/** Soft radial-ish glow block for hero areas (two stacked translucent circles). */
export function Glow({ color, size, style, opacity }) {
const s = size || 260;
return (
);
}