Files
alarm-client/src/components/ui.tsx
T
2026-10-18 22:42:00 +01:00

615 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 },
});