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:
255
src/components/ui.tsx
Normal file
255
src/components/ui.tsx
Normal 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 },
|
||||
});
|
||||
Reference in New Issue
Block a user