Files
health-app/src/components/ui.tsx
Marcus Rehbock 2a5c956c32 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>
2026-08-07 16:38:52 -07:00

256 lines
7.9 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 { 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 },
});