Expo app: native front end for health.rehbock.xyz

Login (HTTP Basic, token in SecureStore) + Today, Blood, Sleep, Train,
Body, Mind, DNA screens ported from the PWA at /app/. All data is
fetched live from health.rehbock.xyz; nothing is stored on device
beyond the auth token.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-07 16:38:52 -07:00
parent 36bd13ff59
commit 2a5c956c32
38 changed files with 2060 additions and 1180 deletions

255
src/components/ui.tsx Normal file
View File

@@ -0,0 +1,255 @@
import { router } from 'expo-router';
import React, { useState } from 'react';
import {
ActivityIndicator, Pressable, RefreshControl, ScrollView, StyleSheet, Text,
TextInput, View,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { clearCache } from '../lib/api';
import { C } from '../lib/theme';
import type { Priority } from '../lib/types';
/* Scrollable screen shell with pull-to-refresh (clears the API cache) */
export function Screen({
children, onRefresh,
}: {
children: React.ReactNode;
onRefresh?: () => void;
}) {
const insets = useSafeAreaInsets();
const [refreshing, setRefreshing] = useState(false);
return (
<ScrollView
style={{ flex: 1, backgroundColor: C.bg }}
contentContainerStyle={{ padding: 16, paddingBottom: insets.bottom + 48 }}
refreshControl={
onRefresh ? (
<RefreshControl
refreshing={refreshing}
tintColor={C.muted}
colors={[C.accent]}
progressBackgroundColor={C.surface2}
onRefresh={() => {
setRefreshing(true);
clearCache();
onRefresh();
setTimeout(() => setRefreshing(false), 400);
}}
/>
) : undefined
}>
{children}
</ScrollView>
);
}
export function Loading() {
return (
<View style={{ padding: 24, alignItems: 'center' }}>
<ActivityIndicator color={C.muted} />
</View>
);
}
export function ErrBox({ msg }: { msg: string }) {
return (
<View style={s.errBox}>
<Text style={{ color: C.red, fontSize: 13 }}>
Could not load data {msg}. Pull down to retry.
</Text>
</View>
);
}
export function Sec({ title, count }: { title: string; count?: number | string }) {
return (
<Text style={s.sec}>
{title.toUpperCase()}
{count != null && <Text style={{ color: C.dim }}> {count}</Text>}
</Text>
);
}
export function TileGrid({ children }: { children: React.ReactNode }) {
return <View style={s.tiles}>{children}</View>;
}
export function Tile({
label, value, unit, sub, delta, deltaTone,
}: {
label: string;
value: string;
unit?: string;
sub?: string;
delta?: string;
deltaTone?: 'good' | 'bad' | 'neutral';
}) {
const toneColor = deltaTone === 'good' ? C.green : deltaTone === 'bad' ? C.red : C.muted;
return (
<View style={s.tile}>
<Text style={s.tileLbl}>{label.toUpperCase()}</Text>
<Text style={s.tileVal}>
{value}
{unit ? <Text style={s.tileUnit}> {unit}</Text> : null}
</Text>
{delta ? <Text style={{ fontSize: 11.5, marginTop: 5, color: toneColor }}>{delta}</Text> : null}
{sub ? <Text style={s.tileSub}>{sub}</Text> : null}
</View>
);
}
/* Today-screen navigation card */
export function NavCard({
title, big, unit, note, flagline, okline, href,
}: {
title: string;
big: string;
unit?: string;
note?: string;
flagline?: string;
okline?: string;
href: string;
}) {
return (
<Pressable
style={({ pressed }) => [s.navcard, pressed && { borderColor: C.accent }]}
onPress={() => router.push(href as any)}>
<View style={s.navHead}>
<Text style={s.navT}>{title.toUpperCase()}</Text>
<Text style={{ color: C.dim, fontSize: 13 }}></Text>
</View>
<Text style={s.navBig}>
{big}
{unit ? <Text style={s.tileUnit}> {unit}</Text> : null}
</Text>
{flagline ? <Text style={{ fontSize: 11.5, marginTop: 4, color: C.red }}>{flagline}</Text> : null}
{okline ? <Text style={{ fontSize: 11.5, marginTop: 4, color: C.green }}>{okline}</Text> : null}
{note ? (
<Text numberOfLines={2} style={s.navNote}>
{note}
</Text>
) : null}
</Pressable>
);
}
export function Panel({
title, sub, children,
}: {
title?: string;
sub?: string;
children: React.ReactNode;
}) {
return (
<View style={s.panel}>
{title ? (
<View style={s.panelHead}>
<Text style={{ color: C.text, fontSize: 13, fontWeight: '600' }}>{title}</Text>
{sub ? <Text style={{ color: C.muted, fontSize: 11 }}>{sub}</Text> : null}
</View>
) : null}
{children}
</View>
);
}
export function PriorityCard({ p, rank }: { p: Priority; rank: number }) {
const color = p.severity === 'flag' ? C.red : C.yellow;
const border = p.severity === 'flag' ? 'rgba(255,69,58,0.35)' : 'rgba(245,197,66,0.35)';
return (
<View style={[s.priority, { borderColor: border }]}>
<Text style={{ color, fontSize: 10.5, fontWeight: '700', marginBottom: 4 }}>
#{rank} · {String(p.severity).toUpperCase()}
</Text>
<Text style={{ color: C.text, fontSize: 13.5, fontWeight: '600', marginBottom: 4 }}>
{p.title}
</Text>
<Text style={{ color: C.muted, fontSize: 12, lineHeight: 17 }}>{p.detail}</Text>
</View>
);
}
export function SearchInput({
value, onChange, placeholder,
}: {
value: string;
onChange: (v: string) => void;
placeholder: string;
}) {
return (
<TextInput
style={s.search}
value={value}
onChangeText={onChange}
placeholder={placeholder}
placeholderTextColor={C.dim}
autoCapitalize="none"
autoCorrect={false}
clearButtonMode="while-editing"
/>
);
}
export function SrcNote({ children }: { children: React.ReactNode }) {
return <Text style={s.srcNote}>{children}</Text>;
}
export function LegendRow({ items }: { items: [string, string][] }) {
return (
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 14, marginTop: 10 }}>
{items.map(([label, color]) => (
<View key={label} style={{ flexDirection: 'row', alignItems: 'center', gap: 5 }}>
<View style={{ width: 8, height: 8, borderRadius: 3, backgroundColor: color }} />
<Text style={{ color: C.muted, fontSize: 11 }}>{label}</Text>
</View>
))}
</View>
);
}
const s = StyleSheet.create({
errBox: {
borderWidth: 1, borderColor: 'rgba(255,69,58,0.3)', backgroundColor: C.redDim,
borderRadius: 12, padding: 14, marginVertical: 12,
},
sec: {
fontSize: 11, fontWeight: '600', letterSpacing: 0.8, color: C.muted,
marginTop: 26, marginBottom: 10,
},
tiles: { flexDirection: 'row', flexWrap: 'wrap', gap: 10 },
tile: {
backgroundColor: C.surface, borderWidth: 1, borderColor: C.border, borderRadius: 14,
padding: 14, flexBasis: '47%', flexGrow: 1,
},
tileLbl: { fontSize: 11, color: C.muted, letterSpacing: 0.5, marginBottom: 6 },
tileVal: { fontSize: 24, fontWeight: '300', letterSpacing: -0.6, color: C.text },
tileUnit: { fontSize: 12, color: C.muted, fontWeight: '400', letterSpacing: 0 },
tileSub: { fontSize: 11.5, color: C.muted, marginTop: 5 },
navcard: {
backgroundColor: C.surface, borderWidth: 1, borderColor: C.border, borderRadius: 14,
padding: 14, flexBasis: '47%', flexGrow: 1,
},
navHead: {
flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8,
},
navT: { fontSize: 12, fontWeight: '600', color: C.muted, letterSpacing: 0.6 },
navBig: { fontSize: 22, fontWeight: '300', letterSpacing: -0.5, color: C.text },
navNote: { fontSize: 11.5, color: C.muted, marginTop: 4 },
panel: {
backgroundColor: C.surface, borderWidth: 1, borderColor: C.border, borderRadius: 14,
padding: 14, marginBottom: 10,
},
panelHead: {
flexDirection: 'row', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 10,
},
priority: {
borderWidth: 1, borderRadius: 12, paddingVertical: 13, paddingHorizontal: 14,
backgroundColor: C.surface, marginBottom: 8,
},
search: {
paddingVertical: 10, paddingHorizontal: 14, borderRadius: 12, borderWidth: 1,
borderColor: C.border, backgroundColor: C.surface, color: C.text, fontSize: 14,
marginTop: 4, marginBottom: 12,
},
srcNote: { fontSize: 11, color: C.dim, marginTop: 12, lineHeight: 16 },
});