init
This commit is contained in:
@@ -0,0 +1,614 @@
|
||||
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 },
|
||||
});
|
||||
Reference in New Issue
Block a user