615 lines
16 KiB
TypeScript
615 lines
16 KiB
TypeScript
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 (
|
||
<Text
|
||
className={className}
|
||
style={style}
|
||
numberOfLines={numberOfLines}
|
||
accessibilityLabel={accessibilityLabel}
|
||
accessibilityLiveRegion={accessibilityLiveRegion}
|
||
>
|
||
{children}
|
||
</Text>
|
||
);
|
||
}
|
||
|
||
export function Screen({
|
||
children,
|
||
scroll = true,
|
||
}: {
|
||
children: React.ReactNode;
|
||
scroll?: boolean;
|
||
}) {
|
||
if (!scroll) {
|
||
return <View style={styles.flex}>{children}</View>;
|
||
}
|
||
return (
|
||
<ScrollView
|
||
style={styles.flex}
|
||
contentContainerStyle={styles.screenContent}
|
||
keyboardShouldPersistTaps="handled"
|
||
>
|
||
{children}
|
||
</ScrollView>
|
||
);
|
||
}
|
||
|
||
export function Field({
|
||
label,
|
||
children,
|
||
accessibilityLabel,
|
||
}: {
|
||
label: string;
|
||
children: React.ReactNode;
|
||
accessibilityLabel?: string;
|
||
}) {
|
||
return (
|
||
<View style={styles.field}>
|
||
<AppText style={{ ...typeLabel, color: textMuted }}>{label}</AppText>
|
||
<View accessibilityLabel={accessibilityLabel}>{children}</View>
|
||
</View>
|
||
);
|
||
}
|
||
|
||
export function TextInputBox(props: TextInputProps) {
|
||
const [focused, setFocused] = React.useState(false);
|
||
const { style, onFocus, onBlur, ...rest } = props;
|
||
return (
|
||
<TextInput
|
||
placeholderTextColor={textFaint}
|
||
onFocus={e => {
|
||
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 = (
|
||
<Surface style={[styles.card, style]}>
|
||
<View accessibilityLabel={accessibilityLabel}>{children}</View>
|
||
</Surface>
|
||
);
|
||
if (onPress) {
|
||
return (
|
||
<Pressable
|
||
onPress={onPress}
|
||
accessibilityRole="button"
|
||
accessibilityLabel={accessibilityLabel}
|
||
>
|
||
{inner}
|
||
</Pressable>
|
||
);
|
||
}
|
||
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 (
|
||
<Pressable
|
||
onPress={disabled ? undefined : onPress}
|
||
disabled={disabled}
|
||
accessibilityRole="button"
|
||
accessibilityLabel={accessibilityLabel ?? label}
|
||
accessibilityState={{ disabled: !!disabled }}
|
||
style={[
|
||
styles.button,
|
||
{
|
||
height,
|
||
backgroundColor: palette.fill,
|
||
borderColor: palette.border,
|
||
borderWidth: palette.borderWidth,
|
||
},
|
||
// Disabled is opacity .4 — never a colour-only change (DESIGN.md).
|
||
disabled ? styles.disabled : null,
|
||
style,
|
||
]}
|
||
>
|
||
{Icon ? <Icon size={20} color={palette.fg} /> : null}
|
||
<AppText style={{ ...typeBodyStrong, color: palette.fg }}>{label}</AppText>
|
||
</Pressable>
|
||
);
|
||
}
|
||
|
||
export function EmptyState({
|
||
icon: Icon,
|
||
title,
|
||
subtitle,
|
||
action,
|
||
}: {
|
||
icon?: LucideIcon;
|
||
title: string;
|
||
subtitle?: string;
|
||
action?: React.ReactNode;
|
||
}) {
|
||
return (
|
||
<View style={styles.empty}>
|
||
{Icon ? <Icon size={36} color={textFaint} /> : null}
|
||
<AppText style={{ ...typeHeading, color: text, marginTop: space4 }}>{title}</AppText>
|
||
{subtitle ? (
|
||
<AppText
|
||
style={{ ...typeCaption, color: textSecondary, marginTop: space2, textAlign: 'center' }}
|
||
>
|
||
{subtitle}
|
||
</AppText>
|
||
) : null}
|
||
{action ? <View style={styles.emptyAction}>{action}</View> : null}
|
||
</View>
|
||
);
|
||
}
|
||
|
||
export function LoadingView({ label = 'Loading…' }: { label?: string }) {
|
||
return (
|
||
<View style={styles.loading}>
|
||
<Spinner />
|
||
<AppText style={{ ...typeCaption, color: textSecondary, marginTop: space3 }}>{label}</AppText>
|
||
</View>
|
||
);
|
||
}
|
||
|
||
export function ErrorBanner({ message, onRetry }: { message: string; onRetry?: () => void }) {
|
||
return (
|
||
<View style={styles.errorBanner} accessibilityLiveRegion="polite">
|
||
<View style={styles.errorRow}>
|
||
<CircleAlert size={18} color={danger} />
|
||
<AppText style={{ ...typeLabel, color: danger, flex: 1 }}>{message}</AppText>
|
||
</View>
|
||
{onRetry ? (
|
||
<Pressable
|
||
onPress={onRetry}
|
||
accessibilityRole="button"
|
||
accessibilityLabel="Try again"
|
||
style={styles.retry}
|
||
>
|
||
<AppText style={{ ...typeLabel, color: danger, textDecorationLine: 'underline' }}>
|
||
Try again
|
||
</AppText>
|
||
</Pressable>
|
||
) : null}
|
||
</View>
|
||
);
|
||
}
|
||
|
||
/** Persistent offline strip (Cross-screen rules) — never an ErrorBanner. */
|
||
export function OfflineStrip() {
|
||
return (
|
||
<View style={styles.offlineStrip}>
|
||
<WifiOff size={16} color={warn} />
|
||
<AppText style={{ ...typeCaption, color: warn, flex: 1 }}>
|
||
Offline — actions will be sent when you reconnect
|
||
</AppText>
|
||
</View>
|
||
);
|
||
}
|
||
|
||
export function Header({
|
||
title,
|
||
subtitle,
|
||
onBack,
|
||
right,
|
||
}: {
|
||
title: string;
|
||
subtitle?: string;
|
||
onBack?: () => void;
|
||
right?: React.ReactNode;
|
||
}) {
|
||
return (
|
||
<View style={styles.header}>
|
||
{onBack ? (
|
||
<Pressable
|
||
onPress={onBack}
|
||
accessibilityRole="button"
|
||
accessibilityLabel="Back"
|
||
style={styles.headerBack}
|
||
>
|
||
<AppText style={{ ...typeBodyStrong, color: textMuted }}>‹</AppText>
|
||
</Pressable>
|
||
) : null}
|
||
<View style={styles.flex}>
|
||
<AppText style={{ ...typeTitle, color: text }} numberOfLines={1}>
|
||
{title}
|
||
</AppText>
|
||
{subtitle ? (
|
||
<AppText style={{ ...typeCaption, color: textSecondary }} numberOfLines={1}>
|
||
{subtitle}
|
||
</AppText>
|
||
) : null}
|
||
</View>
|
||
{right}
|
||
</View>
|
||
);
|
||
}
|
||
|
||
// ── State components ─────────────────────────────────────────────────────────
|
||
|
||
const STATE_ICONS: Record<AlarmState, LucideIcon> = {
|
||
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 (
|
||
<View
|
||
style={[
|
||
styles.pill,
|
||
{
|
||
backgroundColor: meta.chipFill ?? 'transparent',
|
||
borderColor: meta.color,
|
||
borderWidth: filled ? 0 : 1,
|
||
},
|
||
style,
|
||
]}
|
||
accessibilityLabel={meta.label}
|
||
>
|
||
<Icon size={14} color={filled ? meta.chipText : meta.color} />
|
||
<AppText style={{ ...typeMicro, color: filled ? meta.chipText : meta.color }}>
|
||
{meta.label}
|
||
</AppText>
|
||
</View>
|
||
);
|
||
}
|
||
|
||
/** 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 (
|
||
<Animated.View style={{ transform: [{ scale }], width: size, height: size }}>
|
||
<View
|
||
style={[
|
||
styles.ring,
|
||
{
|
||
width: size,
|
||
height: size,
|
||
borderColor: meta.color,
|
||
borderWidth: meta.ringWidth,
|
||
borderStyle: meta.dashed ? 'dashed' : 'solid',
|
||
},
|
||
]}
|
||
>
|
||
<Icon size={48} color={meta.color} />
|
||
</View>
|
||
{state === 'triggered' ? (
|
||
<>
|
||
<View style={[styles.tickTopLeft, { borderTopColor: meta.color }]} />
|
||
<View style={[styles.tickTopRight, { borderTopColor: meta.color }]} />
|
||
<View style={[styles.tickBottomLeft, { borderBottomColor: meta.color }]} />
|
||
<View style={[styles.tickBottomRight, { borderBottomColor: meta.color }]} />
|
||
</>
|
||
) : null}
|
||
</Animated.View>
|
||
);
|
||
}
|
||
|
||
export function IconLine({
|
||
icon: Icon,
|
||
color,
|
||
children,
|
||
}: {
|
||
icon: LucideIcon;
|
||
color: string;
|
||
children: React.ReactNode;
|
||
}) {
|
||
return (
|
||
<View style={styles.iconLine}>
|
||
<Icon size={16} color={color} />
|
||
<AppText style={{ ...typeCaption, color, flex: 1 }}>{children}</AppText>
|
||
</View>
|
||
);
|
||
}
|
||
|
||
/** Trailing readiness icon: circle-check (ok) / triangle-alert / circle-alert. */
|
||
export function HealthIcon({ status }: { status: 'ok' | 'warn' | 'danger' }) {
|
||
if (status === 'ok') {
|
||
return (
|
||
<View style={styles.checkCircle}>
|
||
<AppText style={{ ...typeMicro, color: '#06240F' }}>✓</AppText>
|
||
</View>
|
||
);
|
||
}
|
||
if (status === 'warn') return <TriangleAlert size={20} color={warn} />;
|
||
return <CircleAlert size={20} color={danger} />;
|
||
}
|
||
|
||
export function Divider() {
|
||
return <View style={styles.divider} />;
|
||
}
|
||
|
||
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 },
|
||
});
|