import React, { useEffect, useRef } from 'react'; import { Animated, Easing, Pressable, ScrollView, StyleSheet, TextInput, View, type TextInputProps, } from 'react-native'; import { Text } from 'heroui-native/text'; import { Spinner } from 'heroui-native/spinner'; import { Surface } from 'heroui-native/surface'; import { BellOff, CircleAlert, CircleDashed, ShieldCheck, Siren, TriangleAlert, WifiOff, type LucideIcon, } from 'lucide-react-native'; import { border, danger, dangerDeep, focusRing, minTapTarget, ok, radiusFull, radiusLg, space1, space2, space3, space4, space6, stateAlarmDeep, surface, surfaceRaised, text, textFaint, textMuted, textSecondary, typeBody, typeBodyStrong, typeCaption, typeCode, typeHeading, typeLabel, typeMicro, typeTitle, warn, STATE_META, type AlarmState, } from '../theme/tokens'; // ── Primitives ─────────────────────────────────────────────────────────────── export function AppText({ children, style, className, numberOfLines, accessibilityLabel, accessibilityLiveRegion, }: { children: React.ReactNode; style?: object; className?: string; numberOfLines?: number; accessibilityLabel?: string; accessibilityLiveRegion?: 'none' | 'polite' | 'assertive'; }) { return ( {children} ); } export function Screen({ children, scroll = true, }: { children: React.ReactNode; scroll?: boolean; }) { if (!scroll) { return {children}; } return ( {children} ); } export function Field({ label, children, accessibilityLabel, }: { label: string; children: React.ReactNode; accessibilityLabel?: string; }) { return ( {label} {children} ); } export function TextInputBox(props: TextInputProps) { const [focused, setFocused] = React.useState(false); const { style, onFocus, onBlur, ...rest } = props; return ( { setFocused(true); onFocus?.(e); }} onBlur={e => { setFocused(false); onBlur?.(e); }} // The border token is decorative; a control boundary uses focusRing // (DESIGN.md §2.1). style={[styles.input, { borderColor: focused ? focusRing : border }, style]} {...rest} /> ); } export function Card({ children, onPress, style, accessibilityLabel, }: { children: React.ReactNode; onPress?: () => void; style?: object; accessibilityLabel?: string; }) { const inner = ( {children} ); if (onPress) { return ( {inner} ); } return inner; } export type ButtonVariant = | 'primary' | 'secondary' | 'ghost' | 'alarm' | 'danger' | 'warn' | 'outlineWhite' | 'white'; export interface ButtonProps { label: string; onPress?: () => void; variant?: ButtonVariant; height?: number; disabled?: boolean; icon?: LucideIcon; accessibilityLabel?: string; style?: object; } const BUTTON_VARIANTS: Record< ButtonVariant, { fill: string; fg: string; border: string; borderWidth: number } > = { primary: { fill: focusRing, fg: '#06121F', border: 'transparent', borderWidth: 0 }, secondary: { fill: surfaceRaised, fg: text, border: border, borderWidth: 1 }, ghost: { fill: 'transparent', fg: text, border: 'transparent', borderWidth: 0 }, alarm: { fill: stateAlarmDeep, fg: '#FFFFFF', border: 'transparent', borderWidth: 0 }, danger: { fill: dangerDeep, fg: '#FFFFFF', border: 'transparent', borderWidth: 0 }, warn: { fill: warn, fg: '#2A1A02', border: 'transparent', borderWidth: 0 }, outlineWhite: { fill: 'transparent', fg: '#FFFFFF', border: '#FFFFFF', borderWidth: 2 }, white: { fill: '#FFFFFF', fg: '#0B0D10', border: 'transparent', borderWidth: 0 }, }; export function Button({ label, onPress, variant = 'primary', height = 56, disabled, icon: Icon, accessibilityLabel, style, }: ButtonProps) { const palette = BUTTON_VARIANTS[variant]; return ( {Icon ? : null} {label} ); } export function EmptyState({ icon: Icon, title, subtitle, action, }: { icon?: LucideIcon; title: string; subtitle?: string; action?: React.ReactNode; }) { return ( {Icon ? : null} {title} {subtitle ? ( {subtitle} ) : null} {action ? {action} : null} ); } export function LoadingView({ label = 'Loading…' }: { label?: string }) { return ( {label} ); } export function ErrorBanner({ message, onRetry }: { message: string; onRetry?: () => void }) { return ( {message} {onRetry ? ( Try again ) : null} ); } /** Persistent offline strip (Cross-screen rules) — never an ErrorBanner. */ export function OfflineStrip() { return ( Offline — actions will be sent when you reconnect ); } export function Header({ title, subtitle, onBack, right, }: { title: string; subtitle?: string; onBack?: () => void; right?: React.ReactNode; }) { return ( {onBack ? ( ‹ ) : null} {title} {subtitle ? ( {subtitle} ) : null} {right} ); } // ── State components ───────────────────────────────────────────────────────── const STATE_ICONS: Record = { idle: CircleDashed, armed: ShieldCheck, triggered: Siren, disarmed: BellOff, }; export function stateIcon(state: AlarmState): LucideIcon { return STATE_ICONS[state]; } export function StatePill({ state, style }: { state: AlarmState; style?: object }) { const meta = STATE_META[state]; const Icon = STATE_ICONS[state]; const filled = meta.chipFill !== null; return ( {meta.label} ); } /** Breathe (armed, 1800ms) / pulse (triggered, 700ms) / static otherwise. */ export function useStateMotion(state: AlarmState, reducedMotion = false) { const anim = useRef(new Animated.Value(1)).current; useEffect(() => { if (reducedMotion || (state !== 'armed' && state !== 'triggered')) { anim.setValue(1); return; } const duration = state === 'armed' ? 1800 : 700; const loop = Animated.loop( Animated.sequence([ Animated.timing(anim, { toValue: state === 'armed' ? 1.04 : 1.06, duration: duration / 2, easing: Easing.inOut(Easing.quad), useNativeDriver: true, }), Animated.timing(anim, { toValue: 1, duration: duration / 2, easing: Easing.inOut(Easing.quad), useNativeDriver: true, }), ]), ); loop.start(); return () => loop.stop(); }, [state, reducedMotion, anim]); return anim; } /** * The state ring: width from the state meta, dashed for idle, plus corner ticks * for triggered. Shape + icon + label carry the state, so colour is never the * only signal. */ export function StateRing({ state, size = 160, reducedMotion = false, }: { state: AlarmState; size?: number; reducedMotion?: boolean; }) { const meta = STATE_META[state]; const Icon = STATE_ICONS[state]; const scale = useStateMotion(state, reducedMotion); return ( {state === 'triggered' ? ( <> ) : null} ); } export function IconLine({ icon: Icon, color, children, }: { icon: LucideIcon; color: string; children: React.ReactNode; }) { return ( {children} ); } /** Trailing readiness icon: circle-check (ok) / triangle-alert / circle-alert. */ export function HealthIcon({ status }: { status: 'ok' | 'warn' | 'danger' }) { if (status === 'ok') { return ( ✓ ); } if (status === 'warn') return ; return ; } export function Divider() { return ; } export const styles = StyleSheet.create({ flex: { flex: 1 }, screenContent: { padding: space4, paddingBottom: 40 }, field: { gap: space2 }, input: { borderRadius: radiusLg, borderWidth: 1, backgroundColor: surfaceRaised, paddingHorizontal: space4, paddingVertical: space3, color: text, ...typeBody, }, card: { backgroundColor: surface, borderRadius: radiusLg, padding: space4, elevation: 4 }, button: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: space2, borderRadius: radiusLg, paddingHorizontal: space4, minHeight: minTapTarget, }, disabled: { opacity: 0.4 }, empty: { alignItems: 'center', justifyContent: 'center', paddingHorizontal: space6, paddingVertical: 48, }, emptyAction: { marginTop: space6, alignSelf: 'stretch' }, loading: { alignItems: 'center', justifyContent: 'center', paddingVertical: 48 }, errorBanner: { borderWidth: 1, borderColor: 'rgba(248,113,113,0.3)', backgroundColor: 'rgba(248,113,113,0.1)', borderRadius: radiusLg, padding: space4, marginBottom: space4, }, errorRow: { flexDirection: 'row', alignItems: 'center', gap: space2 }, retry: { marginTop: space2, minHeight: 32, justifyContent: 'center' }, offlineStrip: { flexDirection: 'row', alignItems: 'center', gap: space2, backgroundColor: 'rgba(251,191,36,0.12)', paddingHorizontal: space4, paddingVertical: space2, }, header: { flexDirection: 'row', alignItems: 'center', gap: space2, paddingHorizontal: space4, paddingVertical: space3, borderBottomWidth: 1, borderBottomColor: border, }, headerBack: { width: minTapTarget, height: minTapTarget, alignItems: 'center', justifyContent: 'center', }, pill: { flexDirection: 'row', alignItems: 'center', gap: space1, borderRadius: radiusFull, paddingHorizontal: space3, paddingVertical: space1, alignSelf: 'flex-start', }, ring: { alignItems: 'center', justifyContent: 'center', borderRadius: radiusFull }, tickTopLeft: { position: 'absolute', top: 0, left: 0, width: 18, height: 18, borderTopWidth: 4 }, tickTopRight: { position: 'absolute', top: 0, right: 0, width: 18, height: 18, borderTopWidth: 4 }, tickBottomLeft: { position: 'absolute', bottom: 0, left: 0, width: 18, height: 18, borderBottomWidth: 4, }, tickBottomRight: { position: 'absolute', bottom: 0, right: 0, width: 18, height: 18, borderBottomWidth: 4, }, iconLine: { flexDirection: 'row', alignItems: 'center', gap: space2 }, checkCircle: { width: 20, height: 20, borderRadius: radiusFull, backgroundColor: ok, alignItems: 'center', justifyContent: 'center', }, divider: { height: 1, backgroundColor: border, marginVertical: space3 }, mono: { ...typeCode, color: text }, body: { ...typeBody, color: text }, caption: { ...typeCaption, color: textSecondary }, });