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