1762 lines
50 KiB
TypeScript
1762 lines
50 KiB
TypeScript
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||
import { LinearGradient } from "expo-linear-gradient";
|
||
import { StatusBar } from "expo-status-bar";
|
||
import React, { useEffect, useMemo, useState } from "react";
|
||
import {
|
||
ActivityIndicator,
|
||
Alert,
|
||
Pressable,
|
||
SafeAreaView,
|
||
ScrollView,
|
||
Share,
|
||
StyleSheet,
|
||
Switch,
|
||
Text,
|
||
TextInput,
|
||
View,
|
||
useWindowDimensions,
|
||
} from "react-native";
|
||
import {
|
||
Activity,
|
||
Apple,
|
||
Bell,
|
||
Bot,
|
||
Camera,
|
||
Check,
|
||
ChevronRight,
|
||
CircleUserRound,
|
||
Download,
|
||
Droplets,
|
||
Footprints,
|
||
HeartPulse,
|
||
Home,
|
||
Lightbulb,
|
||
Minus,
|
||
Moon,
|
||
Plus,
|
||
Scale,
|
||
Send,
|
||
ShieldCheck,
|
||
Sparkles,
|
||
Sprout,
|
||
TrendingUp,
|
||
UserRound,
|
||
Utensils,
|
||
} from "lucide-react-native";
|
||
|
||
type LucideIcon = any;
|
||
|
||
type Tab = "today" | "journal" | "progress" | "coach" | "profile";
|
||
type Entry = {
|
||
date: string;
|
||
iso?: string;
|
||
weight: number;
|
||
water: number;
|
||
steps: number;
|
||
glucose?: number;
|
||
sleep?: number;
|
||
meals?: number;
|
||
mood?: string;
|
||
};
|
||
type ProfileData = {
|
||
name: string;
|
||
goal: string;
|
||
journey: string;
|
||
glucoseUnit: "mg/dL" | "mmol/L";
|
||
reminders: boolean;
|
||
complete: boolean;
|
||
};
|
||
type ChatMessage = { id: string; role: "user" | "assistant"; content: string };
|
||
const C = {
|
||
ink: "#15211c",
|
||
muted: "#637169",
|
||
cream: "#f6f7f2",
|
||
white: "#fff",
|
||
green: "#1d6b50",
|
||
mint: "#dcefe5",
|
||
coral: "#ef835f",
|
||
yellow: "#f4d58d",
|
||
line: "#e5e9e3",
|
||
};
|
||
const seed: Entry[] = [
|
||
{ date: "Lun", weight: 91.4, water: 1500, steps: 6100, glucose: 112 },
|
||
{ date: "Mar", weight: 91.2, water: 1800, steps: 7400, glucose: 108 },
|
||
{ date: "Mer", weight: 91.1, water: 1600, steps: 6900, glucose: 110 },
|
||
{ date: "Jeu", weight: 90.9, water: 2000, steps: 8200, glucose: 105 },
|
||
{ date: "Ven", weight: 90.8, water: 2100, steps: 8600, glucose: 104 },
|
||
{ date: "Sam", weight: 90.7, water: 1750, steps: 7200, glucose: 107 },
|
||
{ date: "Auj.", weight: 90.6, water: 1250, steps: 6240, glucose: 103 },
|
||
];
|
||
|
||
function Card({ children, style }: any) {
|
||
return <View style={[s.card, style]}>{children}</View>;
|
||
}
|
||
function Pill({ children, tone = "green" }: any) {
|
||
return (
|
||
<View
|
||
style={[
|
||
s.pill,
|
||
{ backgroundColor: tone === "coral" ? "#fff0eb" : C.mint },
|
||
]}
|
||
>
|
||
<Text
|
||
style={[s.pillText, { color: tone === "coral" ? "#9b432a" : C.green }]}
|
||
>
|
||
{children}
|
||
</Text>
|
||
</View>
|
||
);
|
||
}
|
||
function IconTile({
|
||
icon: Icon,
|
||
color = C.green,
|
||
bg = C.mint,
|
||
size = 22,
|
||
}: any) {
|
||
return (
|
||
<View style={[s.iconTile, { backgroundColor: bg }]}>
|
||
<Icon size={size} color={color} strokeWidth={2.2} />
|
||
</View>
|
||
);
|
||
}
|
||
function Metric({ icon, label, value, sub, onPress }: any) {
|
||
const content = (
|
||
<>
|
||
<IconTile icon={icon} />
|
||
<Text style={s.metricLabel}>{label}</Text>
|
||
<Text style={s.metricValue}>{value}</Text>
|
||
<Text style={s.metricSub}>{sub}</Text>
|
||
</>
|
||
);
|
||
return onPress ? (
|
||
<Pressable
|
||
accessibilityRole="button"
|
||
accessibilityLabel={`${label}, ${value}. Ajouter`}
|
||
onPress={onPress}
|
||
style={({ pressed }) => [s.metric, pressed && s.pressed]}
|
||
>
|
||
{content}
|
||
</Pressable>
|
||
) : (
|
||
<View accessibilityLabel={`${label}, ${value}, ${sub}`} style={s.metric}>
|
||
{content}
|
||
</View>
|
||
);
|
||
}
|
||
function ProgressBar({ value, color = C.green }: any) {
|
||
return (
|
||
<View style={s.track}>
|
||
<View
|
||
style={[
|
||
s.fill,
|
||
{ width: `${Math.min(value, 100)}%`, backgroundColor: color },
|
||
]}
|
||
/>
|
||
</View>
|
||
);
|
||
}
|
||
function Nav({ tab, setTab }: any) {
|
||
const items: any[] = [
|
||
["today", Home, "Aujourd’hui"],
|
||
["journal", Plus, "Journal"],
|
||
["progress", TrendingUp, "Progrès"],
|
||
["coach", Sparkles, "Coach"],
|
||
["profile", CircleUserRound, "Profil"],
|
||
];
|
||
return (
|
||
<View accessibilityRole="tablist" style={s.nav}>
|
||
{items.map(([id, Icon, label]) => {
|
||
const active = tab === id;
|
||
return (
|
||
<Pressable
|
||
accessibilityLabel={label}
|
||
accessibilityRole="tab"
|
||
accessibilityState={{ selected: active }}
|
||
key={id}
|
||
onPress={() => setTab(id)}
|
||
style={({ pressed }) => [
|
||
s.navItem,
|
||
active && s.navItemActive,
|
||
pressed && s.pressed,
|
||
]}
|
||
>
|
||
<Icon
|
||
size={22}
|
||
color={active ? C.green : "#829087"}
|
||
strokeWidth={active ? 2.6 : 2}
|
||
/>
|
||
<Text style={[s.navLabel, active && s.active]}>{label}</Text>
|
||
</Pressable>
|
||
);
|
||
})}
|
||
</View>
|
||
);
|
||
}
|
||
|
||
export default function App() {
|
||
const [tab, setTab] = useState<Tab>("today");
|
||
const [entries, setEntries] = useState(seed);
|
||
const [water, setWater] = useState(1250);
|
||
const [steps, setSteps] = useState(6240);
|
||
const [weight, setWeight] = useState("90.6");
|
||
const [glucose, setGlucose] = useState("103");
|
||
const [sleep, setSleep] = useState("7.2");
|
||
const [meals, setMeals] = useState(2);
|
||
const [mood, setMood] = useState("🙂");
|
||
const [saved, setSaved] = useState(false);
|
||
const [loaded, setLoaded] = useState(false);
|
||
const [profile, setProfile] = useState<ProfileData>({
|
||
name: "",
|
||
goal: "Retrouver mon équilibre",
|
||
journey: "Prévention",
|
||
glucoseUnit: "mg/dL",
|
||
reminders: false,
|
||
complete: false,
|
||
});
|
||
const { width } = useWindowDimensions();
|
||
useEffect(() => {
|
||
AsyncStorage.getItem("metabloom-v2")
|
||
.then((v) => {
|
||
if (v) {
|
||
const d = JSON.parse(v);
|
||
setEntries(
|
||
Array.isArray(d.entries) && d.entries.length ? d.entries : seed,
|
||
);
|
||
setWater(typeof d.water === "number" ? d.water : 1250);
|
||
setSteps(typeof d.steps === "number" ? d.steps : 6240);
|
||
setProfile(d.profile || profile);
|
||
}
|
||
})
|
||
.catch(() => {})
|
||
.finally(() => setLoaded(true));
|
||
}, []);
|
||
useEffect(() => {
|
||
if (loaded)
|
||
AsyncStorage.setItem(
|
||
"metabloom-v2",
|
||
JSON.stringify({ entries, water, steps, profile }),
|
||
).catch(() => {});
|
||
}, [entries, water, steps, profile, loaded]);
|
||
const score = Math.round(
|
||
(Math.min(water / 2000, 1) + Math.min(steps / 8000, 1) + 0.82 + 0.75) * 25,
|
||
);
|
||
const save = () => {
|
||
const w = parseFloat(weight.replace(",", "."));
|
||
const g = parseFloat(glucose.replace(",", "."));
|
||
const sl = parseFloat(sleep.replace(",", "."));
|
||
if (!Number.isFinite(w) || w < 30 || w > 300) {
|
||
Alert.alert(
|
||
"Valeur à vérifier",
|
||
"Saisis un poids compris entre 30 et 300 kg.",
|
||
);
|
||
return;
|
||
}
|
||
const glucoseBounds =
|
||
profile.glucoseUnit === "mmol/L" ? [1.1, 33.3] : [20, 600];
|
||
if (
|
||
glucose.trim() &&
|
||
(!Number.isFinite(g) || g < glucoseBounds[0] || g > glucoseBounds[1])
|
||
) {
|
||
Alert.alert(
|
||
"Valeur à vérifier",
|
||
"La glycémie saisie semble hors de la plage de saisie autorisée. Vérifie l’unité et la valeur.",
|
||
);
|
||
return;
|
||
}
|
||
if (!Number.isFinite(sl) || sl < 0 || sl > 24) {
|
||
Alert.alert(
|
||
"Valeur à vérifier",
|
||
"Le sommeil doit être compris entre 0 et 24 heures.",
|
||
);
|
||
return;
|
||
}
|
||
const now = new Date();
|
||
setEntries((x) => [
|
||
...x.slice(-29),
|
||
{
|
||
date: "Auj.",
|
||
iso: now.toISOString(),
|
||
weight: w,
|
||
water,
|
||
steps,
|
||
glucose: Number.isFinite(g) ? g : undefined,
|
||
sleep: sl,
|
||
meals,
|
||
mood,
|
||
},
|
||
]);
|
||
setSaved(true);
|
||
setTimeout(() => setSaved(false), 2200);
|
||
};
|
||
const reset = () => {
|
||
setEntries(seed);
|
||
setWater(1250);
|
||
setSteps(6240);
|
||
setProfile({ ...profile, complete: false });
|
||
AsyncStorage.removeItem("metabloom-v2");
|
||
};
|
||
const content = useMemo(
|
||
() =>
|
||
tab === "today" ? (
|
||
<Today
|
||
name={profile.name}
|
||
score={score}
|
||
water={water}
|
||
setWater={setWater}
|
||
steps={steps}
|
||
setSteps={setSteps}
|
||
go={setTab}
|
||
/>
|
||
) : tab === "journal" ? (
|
||
<Journal
|
||
weight={weight}
|
||
setWeight={setWeight}
|
||
glucose={glucose}
|
||
setGlucose={setGlucose}
|
||
glucoseUnit={profile.glucoseUnit}
|
||
water={water}
|
||
setWater={setWater}
|
||
steps={steps}
|
||
setSteps={setSteps}
|
||
sleep={sleep}
|
||
setSleep={setSleep}
|
||
meals={meals}
|
||
setMeals={setMeals}
|
||
mood={mood}
|
||
setMood={setMood}
|
||
save={save}
|
||
saved={saved}
|
||
/>
|
||
) : tab === "progress" ? (
|
||
<Progress entries={entries} />
|
||
) : tab === "coach" ? (
|
||
<Coach journey={profile.journey} />
|
||
) : (
|
||
<Profile
|
||
profile={profile}
|
||
setProfile={setProfile}
|
||
entries={entries}
|
||
reset={reset}
|
||
/>
|
||
),
|
||
[
|
||
tab,
|
||
score,
|
||
water,
|
||
steps,
|
||
weight,
|
||
glucose,
|
||
sleep,
|
||
meals,
|
||
mood,
|
||
saved,
|
||
entries,
|
||
profile,
|
||
],
|
||
);
|
||
if (!loaded) return <SafeAreaView style={s.safe} />;
|
||
if (!profile.complete)
|
||
return <Onboarding profile={profile} setProfile={setProfile} />;
|
||
return (
|
||
<SafeAreaView style={s.safe}>
|
||
<StatusBar style="dark" />
|
||
<View style={[s.shell, { maxWidth: width > 900 ? 1120 : 680 }]}>
|
||
{content}
|
||
<Nav tab={tab} setTab={setTab} />
|
||
</View>
|
||
</SafeAreaView>
|
||
);
|
||
}
|
||
|
||
function Onboarding({ profile, setProfile }: any) {
|
||
const [step, setStep] = useState(0);
|
||
const journeys: [[string, LucideIcon]] | any = [
|
||
["Prévention", Sprout],
|
||
["Perte de poids", Scale],
|
||
["Diabète type 2", HeartPulse],
|
||
["Traitement GLP-1", Sparkles],
|
||
];
|
||
return (
|
||
<SafeAreaView style={s.safe}>
|
||
<StatusBar style="dark" />
|
||
<ScrollView contentContainerStyle={s.onboard}>
|
||
<View style={s.logo}>
|
||
<Sprout size={28} color={C.white} />
|
||
</View>
|
||
<Text style={s.eyebrow}>BIENVENUE SUR METABLOOM</Text>
|
||
<Text style={s.onboardTitle}>
|
||
{step === 0
|
||
? "Prenons soin de toi, simplement."
|
||
: "Quel parcours te ressemble ?"}
|
||
</Text>
|
||
<Text style={s.lead}>
|
||
{step === 0
|
||
? "Quelques informations suffisent pour personnaliser ton expérience. Tu gardes le contrôle de tes données."
|
||
: "Tu pourras modifier ce choix à tout moment. MetaBloom ne pose aucun diagnostic."}
|
||
</Text>
|
||
{step === 0 ? (
|
||
<>
|
||
<Text style={s.inputLabel}>
|
||
Comment souhaites-tu être appelé·e ?
|
||
</Text>
|
||
<View style={s.inputRow}>
|
||
<TextInput
|
||
accessibilityLabel="Prénom ou pseudonyme"
|
||
placeholder="Prénom ou pseudonyme"
|
||
value={profile.name}
|
||
onChangeText={(name) => setProfile({ ...profile, name })}
|
||
style={s.input}
|
||
/>
|
||
</View>
|
||
{!profile.name.trim() && (
|
||
<Text style={s.helper}>
|
||
Ce nom permettra de personnaliser ton accueil.
|
||
</Text>
|
||
)}
|
||
<Text style={s.inputLabel}>Ton objectif principal</Text>
|
||
<View style={s.inputRow}>
|
||
<TextInput
|
||
accessibilityLabel="Objectif principal"
|
||
value={profile.goal}
|
||
onChangeText={(goal) => setProfile({ ...profile, goal })}
|
||
style={s.input}
|
||
/>
|
||
</View>
|
||
</>
|
||
) : (
|
||
<View style={{ gap: 10 }}>
|
||
{journeys.map(([j, Icon]: [string, LucideIcon]) => (
|
||
<Pressable
|
||
key={j}
|
||
accessibilityRole="radio"
|
||
accessibilityState={{ selected: profile.journey === j }}
|
||
onPress={() => setProfile({ ...profile, journey: j })}
|
||
style={({ pressed }) => [
|
||
s.choice,
|
||
profile.journey === j && s.choiceActive,
|
||
pressed && s.pressed,
|
||
]}
|
||
>
|
||
<IconTile icon={Icon} />
|
||
<View style={{ flex: 1 }}>
|
||
<Text style={s.tipTitle}>{j}</Text>
|
||
<Text style={s.body}>
|
||
Conseils éducatifs et habitudes adaptés
|
||
</Text>
|
||
</View>
|
||
{profile.journey === j ? (
|
||
<Check size={22} color={C.green} />
|
||
) : (
|
||
<View style={{ width: 22 }} />
|
||
)}
|
||
</Pressable>
|
||
))}
|
||
</View>
|
||
)}
|
||
<Card style={s.safety}>
|
||
<Text style={s.tipTag}>À SAVOIR</Text>
|
||
<Text style={s.body}>
|
||
MetaBloom est un outil de bien-être. Il ne remplace pas un
|
||
professionnel de santé et ne modifie jamais un traitement.
|
||
</Text>
|
||
</Card>
|
||
<Pressable
|
||
disabled={step === 0 && !profile.name.trim()}
|
||
onPress={() =>
|
||
step === 0 ? setStep(1) : setProfile({ ...profile, complete: true })
|
||
}
|
||
style={({ pressed }) => [
|
||
s.primary,
|
||
step === 0 && !profile.name.trim() && { opacity: 0.4 },
|
||
pressed && s.pressed,
|
||
]}
|
||
>
|
||
<Text style={s.primaryText}>
|
||
{step === 0 ? "Continuer" : "Accepter et commencer"}
|
||
</Text>
|
||
</Pressable>
|
||
{step === 1 && (
|
||
<Pressable onPress={() => setStep(0)} style={s.secondary}>
|
||
<Text style={s.secondaryText}>Retour</Text>
|
||
</Pressable>
|
||
)}
|
||
</ScrollView>
|
||
</SafeAreaView>
|
||
);
|
||
}
|
||
|
||
function Header({ eyebrow, title, right }: any) {
|
||
return (
|
||
<View style={s.header}>
|
||
<View style={s.headerCopy}>
|
||
<Text style={s.eyebrow}>{eyebrow}</Text>
|
||
<Text style={s.title}>{title}</Text>
|
||
</View>
|
||
{right && <View style={s.headerAction}>{right}</View>}
|
||
</View>
|
||
);
|
||
}
|
||
function Today({ name, score, water, setWater, steps, setSteps, go }: any) {
|
||
const date = new Intl.DateTimeFormat("fr-FR", {
|
||
weekday: "long",
|
||
day: "numeric",
|
||
month: "long",
|
||
})
|
||
.format(new Date())
|
||
.toUpperCase();
|
||
return (
|
||
<ScrollView
|
||
contentContainerStyle={s.page}
|
||
showsVerticalScrollIndicator={false}
|
||
>
|
||
<Header
|
||
eyebrow={date}
|
||
title={`Bonjour ${name}`}
|
||
right={
|
||
<View style={s.avatar}>
|
||
<Text style={s.avatarText}>{name[0]?.toUpperCase()}</Text>
|
||
</View>
|
||
}
|
||
/>
|
||
<LinearGradient colors={["#1d6b50", "#2d8063"]} style={s.hero}>
|
||
<View style={{ flex: 1 }}>
|
||
<Pill>Aujourd’hui</Pill>
|
||
<Text style={s.heroTitle}>Chaque petit pas compte.</Text>
|
||
<Text style={s.heroText}>
|
||
Tu avances à ton rythme, sans pression. Encore une action douce pour
|
||
compléter ta journée.
|
||
</Text>
|
||
<Pressable onPress={() => go("journal")} style={s.heroButton}>
|
||
<Text style={s.heroButtonText}>Ajouter une donnée →</Text>
|
||
</Pressable>
|
||
</View>
|
||
<View style={s.ring}>
|
||
<Text style={s.ringBig}>{score}</Text>
|
||
<Text style={s.ringSmall}>/ 100</Text>
|
||
</View>
|
||
</LinearGradient>
|
||
<View style={s.sectionHead}>
|
||
<Text style={s.h2}>Mes essentiels</Text>
|
||
<Text style={s.caption}>Objectifs ajustables</Text>
|
||
</View>
|
||
<View style={s.grid}>
|
||
<Metric
|
||
icon={Droplets}
|
||
label="Hydratation"
|
||
value={`${water / 1000} L`}
|
||
sub="sur 2 L"
|
||
onPress={() => setWater((x: number) => Math.min(x + 250, 2500))}
|
||
/>
|
||
<Metric
|
||
icon={Footprints}
|
||
label="Mouvement"
|
||
value={steps.toLocaleString("fr-FR")}
|
||
sub="sur 8 000 pas"
|
||
onPress={() => setSteps((x: number) => x + 500)}
|
||
/>
|
||
<Metric
|
||
icon={Utensils}
|
||
label="Repas équilibrés"
|
||
value="2 / 3"
|
||
sub="très bien"
|
||
/>
|
||
<Metric icon={Moon} label="Sommeil" value="7 h 12" sub="objectif 8 h" />
|
||
</View>
|
||
<Card style={s.tip}>
|
||
<IconTile icon={Lightbulb} bg={C.yellow} />
|
||
<View style={{ flex: 1 }}>
|
||
<Text style={s.tipTag}>PETIT CONSEIL DU JOUR</Text>
|
||
<Text style={s.tipTitle}>
|
||
Une marche de 10 minutes après le repas ?
|
||
</Text>
|
||
<Text style={s.body}>
|
||
Un peu de mouvement aide à se sentir mieux. Adapte toujours l’effort
|
||
à ta situation.
|
||
</Text>
|
||
</View>
|
||
</Card>
|
||
<Text style={s.disclaimer}>
|
||
MetaBloom accompagne vos habitudes. Il ne remplace ni un diagnostic, ni
|
||
un professionnel de santé.
|
||
</Text>
|
||
</ScrollView>
|
||
);
|
||
}
|
||
|
||
function Journal({
|
||
weight,
|
||
setWeight,
|
||
glucose,
|
||
setGlucose,
|
||
glucoseUnit,
|
||
water,
|
||
setWater,
|
||
steps,
|
||
setSteps,
|
||
sleep,
|
||
setSleep,
|
||
meals,
|
||
setMeals,
|
||
mood,
|
||
setMood,
|
||
save,
|
||
saved,
|
||
}: any) {
|
||
return (
|
||
<ScrollView
|
||
contentContainerStyle={s.page}
|
||
keyboardShouldPersistTaps="handled"
|
||
>
|
||
<Header
|
||
eyebrow="SUIVI COMPLET"
|
||
title="Mon journal"
|
||
right={<Pill>Hors ligne</Pill>}
|
||
/>
|
||
<Text style={s.lead}>
|
||
Le poids et le sommeil sont nécessaires pour enregistrer la journée. Le
|
||
reste est facultatif.
|
||
</Text>
|
||
<Card>
|
||
<Text style={s.h2}>Mesures du jour</Text>
|
||
<Text style={s.inputLabel}>Poids</Text>
|
||
<View style={s.inputRow}>
|
||
<TextInput
|
||
accessibilityLabel="Poids en kilogrammes, requis"
|
||
value={weight}
|
||
onChangeText={setWeight}
|
||
keyboardType="decimal-pad"
|
||
style={s.input}
|
||
/>
|
||
<Text style={s.unit}>kg</Text>
|
||
</View>
|
||
<Text style={s.inputLabel}>
|
||
Glycémie <Text style={s.optional}>facultatif · saisie manuelle</Text>
|
||
</Text>
|
||
<View style={s.inputRow}>
|
||
<TextInput
|
||
accessibilityLabel="Glycémie facultative"
|
||
value={glucose}
|
||
onChangeText={setGlucose}
|
||
keyboardType="decimal-pad"
|
||
style={s.input}
|
||
/>
|
||
<Text style={s.unit}>{glucoseUnit}</Text>
|
||
</View>
|
||
<Text style={s.helper}>
|
||
Ces valeurs servent uniquement à ton suivi personnel. Aucune
|
||
interprétation médicale n’est fournie.
|
||
</Text>
|
||
</Card>
|
||
<Card>
|
||
<Text style={s.h2}>Habitudes</Text>
|
||
<Stepper
|
||
icon="💧"
|
||
label="Eau"
|
||
value={`${water} ml`}
|
||
minus={() => setWater(Math.max(0, water - 250))}
|
||
plus={() => setWater(water + 250)}
|
||
/>
|
||
<Stepper
|
||
icon="👟"
|
||
label="Pas"
|
||
value={steps.toLocaleString("fr-FR")}
|
||
minus={() => setSteps(Math.max(0, steps - 500))}
|
||
plus={() => setSteps(steps + 500)}
|
||
/>
|
||
</Card>
|
||
<Card>
|
||
<Text style={s.h2}>Bien-être</Text>
|
||
<Text style={s.inputLabel}>Sommeil</Text>
|
||
<View style={s.inputRow}>
|
||
<TextInput
|
||
accessibilityLabel="Durée de sommeil"
|
||
value={sleep}
|
||
onChangeText={setSleep}
|
||
keyboardType="decimal-pad"
|
||
style={s.input}
|
||
/>
|
||
<Text style={s.unit}>heures</Text>
|
||
</View>
|
||
<Text style={s.inputLabel}>Repas équilibrés</Text>
|
||
<View style={s.segment}>
|
||
{[0, 1, 2, 3].map((n) => (
|
||
<Pressable
|
||
accessibilityLabel={`${n} repas équilibrés`}
|
||
key={n}
|
||
onPress={() => setMeals(n)}
|
||
style={[s.segmentItem, meals === n && s.segmentActive]}
|
||
>
|
||
<Text style={[s.segmentText, meals === n && { color: C.white }]}>
|
||
{n}
|
||
</Text>
|
||
</Pressable>
|
||
))}
|
||
</View>
|
||
<Text style={s.inputLabel}>Humeur</Text>
|
||
<View style={s.segment}>
|
||
{["😔", "😐", "🙂", "😄"].map((x) => (
|
||
<Pressable
|
||
accessibilityLabel={`Humeur ${x}`}
|
||
key={x}
|
||
onPress={() => setMood(x)}
|
||
style={[s.segmentItem, mood === x && s.segmentActive]}
|
||
>
|
||
<Text style={{ fontSize: 21 }}>{x}</Text>
|
||
</Pressable>
|
||
))}
|
||
</View>
|
||
</Card>
|
||
<Card style={s.scan}>
|
||
<IconTile icon={Camera} bg="#fff0d7" color="#9b6327" />
|
||
<Text style={s.tipTag}>REPAS</Text>
|
||
<Text style={s.tipTitle}>Scanner ou photographier un repas</Text>
|
||
<Text style={s.body}>
|
||
La confirmation des aliments restera toujours nécessaire. L’analyse
|
||
automatique arrivera avec une base nutritionnelle validée.
|
||
</Text>
|
||
</Card>
|
||
<Pressable
|
||
accessibilityRole="button"
|
||
accessibilityLabel={
|
||
saved ? "Journée enregistrée" : "Enregistrer la journée"
|
||
}
|
||
onPress={save}
|
||
style={({ pressed }) => [s.primary, pressed && s.pressed]}
|
||
>
|
||
{saved && <Check size={20} color={C.white} />}
|
||
<Text style={s.primaryText}>
|
||
{saved ? "Journée enregistrée" : "Enregistrer la journée"}
|
||
</Text>
|
||
</Pressable>
|
||
</ScrollView>
|
||
);
|
||
}
|
||
function Stepper({ icon, label, value, minus, plus }: any) {
|
||
const Icon = icon === "💧" ? Droplets : Footprints;
|
||
return (
|
||
<View style={s.stepper}>
|
||
<IconTile icon={Icon} />
|
||
<View style={{ flexGrow: 1, flexShrink: 1, minWidth: 70 }}>
|
||
<Text style={s.metricLabel}>{label}</Text>
|
||
<Text style={s.stepValue}>{value}</Text>
|
||
</View>
|
||
<Pressable
|
||
accessibilityRole="button"
|
||
accessibilityLabel={`Diminuer ${label}`}
|
||
onPress={minus}
|
||
style={({ pressed }) => [s.stepBtn, pressed && s.pressed]}
|
||
>
|
||
<Minus size={20} color={C.green} />
|
||
</Pressable>
|
||
<Pressable
|
||
accessibilityRole="button"
|
||
accessibilityLabel={`Augmenter ${label}`}
|
||
onPress={plus}
|
||
style={({ pressed }) => [s.stepBtn, pressed && s.pressed]}
|
||
>
|
||
<Plus size={20} color={C.green} />
|
||
</Pressable>
|
||
</View>
|
||
);
|
||
}
|
||
|
||
function Progress({ entries }: any) {
|
||
const recent: Entry[] = entries.slice(-7);
|
||
if (recent.length === 0)
|
||
return (
|
||
<ScrollView contentContainerStyle={s.page}>
|
||
<Header eyebrow="MES TENDANCES" title="Mes progrès" />
|
||
<Card style={s.empty}>
|
||
<IconTile icon={TrendingUp} />
|
||
<Text style={s.tipTitle}>Tes tendances apparaîtront ici</Text>
|
||
<Text style={s.body}>
|
||
Ajoute au moins deux journées pour suivre ton évolution, sans
|
||
pression.
|
||
</Text>
|
||
</Card>
|
||
</ScrollView>
|
||
);
|
||
const weights = recent.map((e) => e.weight).filter(Number.isFinite);
|
||
const min = Math.min(...weights);
|
||
const max = Math.max(...weights);
|
||
const avg = (key: "water" | "steps") =>
|
||
Math.round(
|
||
recent.reduce((a, e) => a + (Number(e[key]) || 0), 0) / recent.length,
|
||
);
|
||
const rawDelta =
|
||
recent.length > 1 ? recent.at(-1)!.weight - recent[0].weight : 0;
|
||
return (
|
||
<ScrollView contentContainerStyle={s.page}>
|
||
<Header
|
||
eyebrow="7 DERNIERS JOURS"
|
||
title="Mes progrès"
|
||
right={
|
||
<Pill tone="coral">
|
||
{rawDelta <= 0 ? "Baisse" : "Hausse"}{" "}
|
||
{Math.abs(rawDelta).toFixed(1).replace(".", ",")} kg
|
||
</Pill>
|
||
}
|
||
/>
|
||
<Text style={s.lead}>Observe les tendances, pas la perfection.</Text>
|
||
<Card>
|
||
<View style={s.sectionHead}>
|
||
<View style={{ flex: 1 }}>
|
||
<Text style={s.metricLabel}>Tendance de poids</Text>
|
||
<Text style={s.bigNumber}>
|
||
{recent.at(-1)!.weight} <Text style={s.bigUnit}>kg</Text>
|
||
</Text>
|
||
</View>
|
||
<Pill>Données personnelles</Pill>
|
||
</View>
|
||
<View style={s.chart}>
|
||
{recent.map((e, i) => (
|
||
<View key={`${e.date}-${i}`} style={s.barCol}>
|
||
<Text style={s.barValue}>{e.weight}</Text>
|
||
<View
|
||
style={[
|
||
s.bar,
|
||
{ height: 58 + ((max - e.weight) / (max - min || 1)) * 55 },
|
||
]}
|
||
/>
|
||
<Text style={s.barLabel}>{e.date}</Text>
|
||
</View>
|
||
))}
|
||
</View>
|
||
</Card>
|
||
<View style={s.grid}>
|
||
<Card style={s.stat}>
|
||
<IconTile icon={Droplets} />
|
||
<Text style={s.bigNumber}>
|
||
{(avg("water") / 1000).toFixed(1).replace(".", ",")} L
|
||
</Text>
|
||
<Text style={s.body}>moyenne d’eau</Text>
|
||
</Card>
|
||
<Card style={s.stat}>
|
||
<IconTile icon={Footprints} />
|
||
<Text style={s.bigNumber}>
|
||
{avg("steps").toLocaleString("fr-FR")}
|
||
</Text>
|
||
<Text style={s.body}>pas en moyenne</Text>
|
||
</Card>
|
||
</View>
|
||
<Card style={s.insight}>
|
||
<Text style={s.tipTag}>À RETENIR</Text>
|
||
<Text style={s.tipTitle}>Une semaine régulière 🌱</Text>
|
||
<Text style={s.body}>
|
||
Tu as bougé au moins 6 000 pas chaque jour. Cette régularité compte
|
||
davantage qu’une journée parfaite.
|
||
</Text>
|
||
</Card>
|
||
</ScrollView>
|
||
);
|
||
}
|
||
|
||
function Coach({ journey }: any) {
|
||
const lessons: [[LucideIcon, string, string]] | any = [
|
||
[Utensils, "Composer une assiette rassasiante", "4 min"],
|
||
[Footprints, "Bouger après un repas, à ton rythme", "3 min"],
|
||
[Lightbulb, "Sortir du tout ou rien", "5 min"],
|
||
];
|
||
return (
|
||
<ScrollView
|
||
contentContainerStyle={s.page}
|
||
keyboardShouldPersistTaps="handled"
|
||
>
|
||
<Header
|
||
eyebrow="COACHING PERSONNALISÉ"
|
||
title="Peg, ton coach IA"
|
||
right={<Pill>{journey}</Pill>}
|
||
/>
|
||
<PegChat journey={journey} />
|
||
<Text style={s.h2}>Pour aller plus loin</Text>
|
||
{lessons.map(([Icon, title, duration]: [LucideIcon, string, string]) => (
|
||
<Pressable
|
||
key={title}
|
||
accessibilityRole="button"
|
||
accessibilityLabel={`${title}, ${duration}`}
|
||
style={({ pressed }) => [s.card, s.lesson, pressed && s.pressed]}
|
||
>
|
||
<IconTile icon={Icon} />
|
||
<View style={s.flexCopy}>
|
||
<Text style={s.tipTitle}>{title}</Text>
|
||
<Text style={s.body}>{duration} · contenu éducatif</Text>
|
||
</View>
|
||
<ChevronRight size={22} color={C.muted} />
|
||
</Pressable>
|
||
))}
|
||
<Card>
|
||
<IconTile icon={Apple} />
|
||
<Text style={s.tipTag}>CONNEXIONS SANTÉ</Text>
|
||
<Text style={s.tipTitle}>Apple Santé & Health Connect</Text>
|
||
<Text style={s.body}>
|
||
Disponible prochainement après activation des autorisations natives.
|
||
</Text>
|
||
</Card>
|
||
</ScrollView>
|
||
);
|
||
}
|
||
|
||
function PegChat({ journey }: { journey: string }) {
|
||
const apiBase = (
|
||
process.env.EXPO_PUBLIC_API_URL || "http://localhost:8000"
|
||
).replace(/\/$/, "");
|
||
const [messages, setMessages] = useState<ChatMessage[]>([
|
||
{
|
||
id: "welcome",
|
||
role: "assistant",
|
||
content: `Bonjour, je suis Peg. Je peux t’aider à trouver de petites actions adaptées à ton parcours ${journey}, sans jugement. De quoi aimerais-tu parler aujourd’hui ?`,
|
||
},
|
||
]);
|
||
const [input, setInput] = useState("");
|
||
const [loading, setLoading] = useState(false);
|
||
const suggestions = [
|
||
"Une idée de petit-déjeuner",
|
||
"Comment rester motivé·e ?",
|
||
"Une action simple aujourd’hui",
|
||
];
|
||
const send = async (text = input) => {
|
||
const clean = text.trim();
|
||
if (!clean || loading) return;
|
||
const userMessage: ChatMessage = {
|
||
id: `u-${Date.now()}`,
|
||
role: "user",
|
||
content: clean,
|
||
};
|
||
const next = [...messages, userMessage];
|
||
setMessages(next);
|
||
setInput("");
|
||
setLoading(true);
|
||
try {
|
||
const createGuestToken = async () => {
|
||
const guestResponse = await fetch(`${apiBase}/auth/guest`, {
|
||
method: "POST",
|
||
});
|
||
if (!guestResponse.ok) throw new Error("session indisponible");
|
||
const guest = await guestResponse.json();
|
||
const guestToken = String(guest.access_token || "");
|
||
if (!guestToken) throw new Error("jeton absent");
|
||
await AsyncStorage.setItem("metabloom-auth-token", guestToken);
|
||
return guestToken;
|
||
};
|
||
let token = await AsyncStorage.getItem("metabloom-auth-token");
|
||
if (!token) {
|
||
token = await createGuestToken();
|
||
}
|
||
const requestPeg = (accessToken: string) => fetch(`${apiBase}/coach/chat`, {
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
Authorization: `Bearer ${accessToken}`,
|
||
},
|
||
body: JSON.stringify({
|
||
message: clean,
|
||
journey,
|
||
history: next
|
||
.slice(0, -1)
|
||
.slice(-8)
|
||
.map(({ role, content }) => ({ role, content })),
|
||
})
|
||
});
|
||
let response = await requestPeg(token);
|
||
if (response.status === 401) {
|
||
await AsyncStorage.removeItem("metabloom-auth-token");
|
||
token = await createGuestToken();
|
||
response = await requestPeg(token);
|
||
}
|
||
if (!response.ok) throw new Error("service indisponible");
|
||
const data = await response.json();
|
||
const content = String(data.reply || data.message || "").trim();
|
||
if (!content) throw new Error("réponse vide");
|
||
setMessages((x) => [
|
||
...x,
|
||
{ id: `a-${Date.now()}`, role: "assistant", content },
|
||
]);
|
||
} catch {
|
||
setMessages((x) => [
|
||
...x,
|
||
{
|
||
id: `e-${Date.now()}`,
|
||
role: "assistant",
|
||
content:
|
||
"Je ne peux pas joindre le moteur IA pour le moment. Tu peux réessayer plus tard ou consulter les micro-leçons ci-dessous. En cas de symptôme ou d’urgence, contacte un professionnel ou les services d’urgence.",
|
||
},
|
||
]);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
return (
|
||
<View style={s.pegCard}>
|
||
<View style={s.pegHeader}>
|
||
<View style={s.pegAvatar}>
|
||
<Bot size={24} color={C.white} />
|
||
</View>
|
||
<View style={s.flexCopy}>
|
||
<Text style={s.pegName}>Peg</Text>
|
||
<Text style={s.pegStatus}>Coach bien-être · conseils éducatifs</Text>
|
||
</View>
|
||
<ShieldCheck size={21} color={C.green} />
|
||
</View>
|
||
<View style={s.chatList}>
|
||
{messages.map((message) => (
|
||
<View
|
||
key={message.id}
|
||
style={[s.messageRow, message.role === "user" && s.messageRowUser]}
|
||
>
|
||
<View
|
||
style={[
|
||
s.messageAvatar,
|
||
message.role === "user" && s.messageAvatarUser,
|
||
]}
|
||
>
|
||
{message.role === "assistant" ? (
|
||
<Bot size={16} color={C.green} />
|
||
) : (
|
||
<UserRound size={16} color="#7659a6" />
|
||
)}
|
||
</View>
|
||
<View style={[s.bubble, message.role === "user" && s.bubbleUser]}>
|
||
<Text
|
||
style={[
|
||
s.bubbleText,
|
||
message.role === "user" && s.bubbleTextUser,
|
||
]}
|
||
>
|
||
{message.content}
|
||
</Text>
|
||
</View>
|
||
</View>
|
||
))}
|
||
{loading && (
|
||
<View style={s.typing}>
|
||
<ActivityIndicator color={C.green} />
|
||
<Text style={s.pegStatus}>Peg réfléchit…</Text>
|
||
</View>
|
||
)}
|
||
</View>
|
||
<ScrollView
|
||
horizontal
|
||
showsHorizontalScrollIndicator={false}
|
||
contentContainerStyle={s.suggestions}
|
||
>
|
||
{suggestions.map((item) => (
|
||
<Pressable
|
||
key={item}
|
||
accessibilityRole="button"
|
||
onPress={() => send(item)}
|
||
style={({ pressed }) => [s.suggestion, pressed && s.pressed]}
|
||
>
|
||
<Text style={s.suggestionText}>{item}</Text>
|
||
</Pressable>
|
||
))}
|
||
</ScrollView>
|
||
<View style={s.composer}>
|
||
<TextInput
|
||
accessibilityLabel="Message pour Peg"
|
||
placeholder="Écris à Peg…"
|
||
placeholderTextColor="#7b8880"
|
||
value={input}
|
||
onChangeText={setInput}
|
||
multiline
|
||
maxLength={500}
|
||
style={s.chatInput}
|
||
/>
|
||
<Pressable
|
||
accessibilityRole="button"
|
||
accessibilityLabel="Envoyer à Peg"
|
||
disabled={!input.trim() || loading}
|
||
onPress={() => send()}
|
||
style={({ pressed }) => [
|
||
s.sendButton,
|
||
(!input.trim() || loading) && s.sendDisabled,
|
||
pressed && s.pressed,
|
||
]}
|
||
>
|
||
<Send size={20} color={C.white} />
|
||
</Pressable>
|
||
</View>
|
||
<Text style={s.pegDisclaimer}>
|
||
Peg peut se tromper. Elle ne remplace pas un professionnel de santé et
|
||
ne donne aucun diagnostic ni conseil de traitement.
|
||
</Text>
|
||
</View>
|
||
);
|
||
}
|
||
|
||
function Profile({ profile, setProfile, entries, reset }: any) {
|
||
const exportData = async () => {
|
||
const csv = [
|
||
"date,poids,eau_ml,pas,glycemie",
|
||
...entries.map((e: Entry) =>
|
||
[e.iso || e.date, e.weight, e.water, e.steps, e.glucose || ""].join(
|
||
",",
|
||
),
|
||
),
|
||
].join("\n");
|
||
await Share.share({ title: "Export MetaBloom", message: csv });
|
||
};
|
||
return (
|
||
<ScrollView contentContainerStyle={s.page}>
|
||
<Header eyebrow="MON ESPACE" title="Profil & sécurité" />
|
||
<Card style={s.profile}>
|
||
<View style={s.avatarLarge}>
|
||
<Text style={s.avatarLargeText}>
|
||
{profile.name[0]?.toUpperCase()}
|
||
</Text>
|
||
</View>
|
||
<View style={{ flex: 1 }}>
|
||
<Text style={s.tipTitle}>{profile.name}</Text>
|
||
<Text style={s.body}>
|
||
{profile.goal} · {profile.journey}
|
||
</Text>
|
||
</View>
|
||
</Card>
|
||
<Text style={s.h2}>Préférences</Text>
|
||
<Card style={s.setting}>
|
||
<IconTile icon={Activity} />
|
||
<View style={{ flex: 1 }}>
|
||
<Text style={s.metricLabel}>Unité glycémie</Text>
|
||
<Text style={s.body}>{profile.glucoseUnit}</Text>
|
||
</View>
|
||
<Pressable
|
||
accessibilityRole="button"
|
||
accessibilityLabel="Changer l’unité de glycémie"
|
||
onPress={() =>
|
||
setProfile({
|
||
...profile,
|
||
glucoseUnit: profile.glucoseUnit === "mg/dL" ? "mmol/L" : "mg/dL",
|
||
})
|
||
}
|
||
style={({ pressed }) => [s.smallButton, pressed && s.pressed]}
|
||
>
|
||
<Text style={s.smallButtonText}>Changer</Text>
|
||
</Pressable>
|
||
</Card>
|
||
<Card style={s.setting}>
|
||
<IconTile icon={Bell} />
|
||
<View style={{ flex: 1 }}>
|
||
<Text style={s.metricLabel}>Rappels doux</Text>
|
||
<Text style={s.body}>
|
||
{profile.reminders ? "Activés à 19 h" : "Désactivés"}
|
||
</Text>
|
||
</View>
|
||
<Switch
|
||
accessibilityLabel="Activer les rappels"
|
||
value={profile.reminders}
|
||
onValueChange={(reminders) => setProfile({ ...profile, reminders })}
|
||
trackColor={{ true: C.green }}
|
||
/>
|
||
</Card>
|
||
<Pressable
|
||
accessibilityRole="button"
|
||
accessibilityLabel="Exporter mes données CSV"
|
||
onPress={exportData}
|
||
style={({ pressed }) => [s.secondaryCard, pressed && s.pressed]}
|
||
>
|
||
<Download size={20} color={C.green} />
|
||
<Text style={s.secondaryCardText}>Exporter mes données CSV</Text>
|
||
</Pressable>
|
||
<Card style={s.safety}>
|
||
<IconTile icon={ShieldCheck} />
|
||
<Text style={s.tipTag}>SÉCURITÉ & CONFIDENTIALITÉ</Text>
|
||
<Text style={s.tipTitle}>Tes données restent sous ton contrôle.</Text>
|
||
<Text style={s.body}>
|
||
Tu peux les exporter ou les supprimer à tout moment. La
|
||
synchronisation en ligne n’est pas activée dans cette version.
|
||
</Text>
|
||
</Card>
|
||
<Pressable
|
||
onPress={() =>
|
||
Alert.alert(
|
||
"Effacer les données locales ?",
|
||
"Cette action supprime les données de cet appareil et réinitialise l’application.",
|
||
[
|
||
{ text: "Annuler" },
|
||
{ text: "Effacer", style: "destructive", onPress: reset },
|
||
],
|
||
)
|
||
}
|
||
style={s.danger}
|
||
>
|
||
<Text style={s.dangerText}>Effacer les données de cet appareil</Text>
|
||
</Pressable>
|
||
<Text style={s.disclaimer}>
|
||
En cas de malaise ou d’urgence, contactez immédiatement les services
|
||
d’urgence. MetaBloom ne fournit pas de conseil thérapeutique.
|
||
</Text>
|
||
</ScrollView>
|
||
);
|
||
}
|
||
|
||
const s = StyleSheet.create({
|
||
safe: { flex: 1, backgroundColor: C.cream },
|
||
shell: { flex: 1, width: "100%", alignSelf: "center" },
|
||
page: { padding: 20, paddingTop: 26, paddingBottom: 140, gap: 16 },
|
||
pressed: { opacity: 0.72, transform: [{ scale: 0.98 }] },
|
||
header: {
|
||
flexDirection: "row",
|
||
flexWrap: "wrap",
|
||
justifyContent: "space-between",
|
||
alignItems: "center",
|
||
gap: 12,
|
||
marginBottom: 4,
|
||
},
|
||
headerCopy: { flexGrow: 1, flexShrink: 1, minWidth: 180 },
|
||
headerAction: { flexShrink: 1, maxWidth: "100%" },
|
||
eyebrow: {
|
||
fontSize: 11,
|
||
fontWeight: "800",
|
||
letterSpacing: 1.4,
|
||
color: C.green,
|
||
flexShrink: 1,
|
||
},
|
||
title: {
|
||
fontSize: 28,
|
||
lineHeight: 35,
|
||
fontWeight: "800",
|
||
color: C.ink,
|
||
letterSpacing: -0.7,
|
||
marginTop: 4,
|
||
flexShrink: 1,
|
||
},
|
||
avatar: {
|
||
width: 44,
|
||
height: 44,
|
||
borderRadius: 22,
|
||
backgroundColor: C.yellow,
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
flexShrink: 0,
|
||
},
|
||
avatarText: { fontWeight: "800", color: C.ink, fontSize: 17 },
|
||
hero: {
|
||
borderRadius: 28,
|
||
padding: 22,
|
||
minHeight: 230,
|
||
flexDirection: "row",
|
||
flexWrap: "wrap",
|
||
alignItems: "center",
|
||
gap: 14,
|
||
overflow: "hidden",
|
||
},
|
||
heroTitle: {
|
||
fontSize: 27,
|
||
lineHeight: 34,
|
||
fontWeight: "800",
|
||
color: C.white,
|
||
marginTop: 18,
|
||
maxWidth: 360,
|
||
flexShrink: 1,
|
||
},
|
||
heroText: {
|
||
fontSize: 14,
|
||
lineHeight: 21,
|
||
color: "#dcebe4",
|
||
marginTop: 8,
|
||
maxWidth: 400,
|
||
flexShrink: 1,
|
||
},
|
||
heroButton: {
|
||
backgroundColor: C.white,
|
||
borderRadius: 14,
|
||
paddingVertical: 14,
|
||
paddingHorizontal: 16,
|
||
alignSelf: "flex-start",
|
||
marginTop: 18,
|
||
minHeight: 48,
|
||
justifyContent: "center",
|
||
maxWidth: "100%",
|
||
},
|
||
heroButtonText: {
|
||
color: C.green,
|
||
fontWeight: "800",
|
||
textAlign: "center",
|
||
flexShrink: 1,
|
||
},
|
||
ring: {
|
||
width: 96,
|
||
height: 96,
|
||
borderWidth: 8,
|
||
borderColor: C.yellow,
|
||
borderRadius: 50,
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
backgroundColor: "#ffffff13",
|
||
flexShrink: 0,
|
||
},
|
||
ringBig: { color: C.white, fontWeight: "900", fontSize: 28 },
|
||
ringSmall: { color: "#dcebe4", fontSize: 11 },
|
||
pill: {
|
||
borderRadius: 99,
|
||
paddingHorizontal: 11,
|
||
paddingVertical: 6,
|
||
alignSelf: "flex-start",
|
||
maxWidth: "100%",
|
||
flexShrink: 1,
|
||
},
|
||
pillText: { fontSize: 11, lineHeight: 16, fontWeight: "800", flexShrink: 1 },
|
||
sectionHead: {
|
||
flexDirection: "row",
|
||
flexWrap: "wrap",
|
||
justifyContent: "space-between",
|
||
alignItems: "center",
|
||
gap: 6,
|
||
},
|
||
h2: {
|
||
fontSize: 19,
|
||
lineHeight: 25,
|
||
fontWeight: "800",
|
||
color: C.ink,
|
||
flexShrink: 1,
|
||
},
|
||
caption: { fontSize: 12, lineHeight: 18, color: C.muted, flexShrink: 1 },
|
||
grid: { flexDirection: "row", flexWrap: "wrap", gap: 12 },
|
||
metric: {
|
||
backgroundColor: C.white,
|
||
borderRadius: 20,
|
||
padding: 17,
|
||
flexGrow: 1,
|
||
flexBasis: 130,
|
||
minWidth: 0,
|
||
minHeight: 154,
|
||
borderWidth: 1,
|
||
borderColor: C.line,
|
||
},
|
||
iconTile: {
|
||
width: 44,
|
||
height: 44,
|
||
borderRadius: 14,
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
marginBottom: 12,
|
||
flexShrink: 0,
|
||
},
|
||
metricLabel: {
|
||
fontSize: 12,
|
||
lineHeight: 18,
|
||
fontWeight: "700",
|
||
color: C.muted,
|
||
flexShrink: 1,
|
||
},
|
||
metricValue: {
|
||
fontSize: 22,
|
||
lineHeight: 29,
|
||
fontWeight: "900",
|
||
color: C.ink,
|
||
marginTop: 3,
|
||
flexShrink: 1,
|
||
},
|
||
metricSub: {
|
||
fontSize: 11,
|
||
lineHeight: 17,
|
||
color: C.muted,
|
||
marginTop: 3,
|
||
flexShrink: 1,
|
||
},
|
||
card: {
|
||
backgroundColor: C.white,
|
||
borderRadius: 22,
|
||
padding: 19,
|
||
borderWidth: 1,
|
||
borderColor: C.line,
|
||
minWidth: 0,
|
||
},
|
||
tip: {
|
||
flexDirection: "row",
|
||
flexWrap: "wrap",
|
||
gap: 14,
|
||
backgroundColor: "#fffaf0",
|
||
},
|
||
tipTag: {
|
||
fontSize: 10,
|
||
lineHeight: 16,
|
||
fontWeight: "900",
|
||
letterSpacing: 1.1,
|
||
color: C.green,
|
||
marginBottom: 6,
|
||
flexShrink: 1,
|
||
},
|
||
tipTitle: {
|
||
fontSize: 17,
|
||
lineHeight: 23,
|
||
fontWeight: "800",
|
||
color: C.ink,
|
||
marginBottom: 5,
|
||
flexShrink: 1,
|
||
},
|
||
body: { fontSize: 13, lineHeight: 20, color: C.muted, flexShrink: 1 },
|
||
disclaimer: {
|
||
fontSize: 11,
|
||
lineHeight: 17,
|
||
textAlign: "center",
|
||
color: C.muted,
|
||
paddingHorizontal: 8,
|
||
flexShrink: 1,
|
||
},
|
||
nav: {
|
||
position: "absolute",
|
||
bottom: 0,
|
||
left: 0,
|
||
right: 0,
|
||
minHeight: 86,
|
||
backgroundColor: "#fffffffa",
|
||
borderTopWidth: 1,
|
||
borderColor: C.line,
|
||
flexDirection: "row",
|
||
alignItems: "stretch",
|
||
paddingTop: 8,
|
||
paddingBottom: 8,
|
||
paddingHorizontal: 4,
|
||
},
|
||
navItem: {
|
||
flex: 1,
|
||
minWidth: 0,
|
||
minHeight: 62,
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
gap: 4,
|
||
borderRadius: 16,
|
||
paddingHorizontal: 2,
|
||
},
|
||
navItemActive: { backgroundColor: "#edf7f1" },
|
||
navLabel: {
|
||
fontSize: 10,
|
||
lineHeight: 14,
|
||
fontWeight: "700",
|
||
color: "#829087",
|
||
textAlign: "center",
|
||
flexShrink: 1,
|
||
},
|
||
active: { color: C.green },
|
||
lead: {
|
||
fontSize: 15,
|
||
lineHeight: 23,
|
||
color: C.muted,
|
||
marginTop: -8,
|
||
flexShrink: 1,
|
||
},
|
||
inputLabel: {
|
||
fontSize: 13,
|
||
lineHeight: 20,
|
||
fontWeight: "800",
|
||
color: C.ink,
|
||
marginTop: 17,
|
||
marginBottom: 7,
|
||
flexShrink: 1,
|
||
},
|
||
optional: { fontWeight: "500", color: C.muted },
|
||
inputRow: {
|
||
minHeight: 56,
|
||
borderWidth: 1.5,
|
||
borderColor: "#d8ded8",
|
||
borderRadius: 14,
|
||
flexDirection: "row",
|
||
alignItems: "center",
|
||
paddingHorizontal: 15,
|
||
backgroundColor: C.white,
|
||
gap: 8,
|
||
},
|
||
input: {
|
||
flexGrow: 1,
|
||
flexShrink: 1,
|
||
minWidth: 0,
|
||
fontSize: 18,
|
||
fontWeight: "800",
|
||
color: C.ink,
|
||
outlineStyle: "none",
|
||
} as any,
|
||
unit: {
|
||
fontWeight: "700",
|
||
color: C.muted,
|
||
flexShrink: 0,
|
||
textAlign: "right",
|
||
},
|
||
helper: {
|
||
fontSize: 11,
|
||
lineHeight: 17,
|
||
color: C.muted,
|
||
marginTop: 12,
|
||
flexShrink: 1,
|
||
},
|
||
stepper: {
|
||
flexDirection: "row",
|
||
flexWrap: "wrap",
|
||
alignItems: "center",
|
||
gap: 12,
|
||
paddingVertical: 13,
|
||
borderBottomWidth: 1,
|
||
borderBottomColor: C.line,
|
||
},
|
||
stepValue: {
|
||
fontSize: 17,
|
||
lineHeight: 23,
|
||
fontWeight: "800",
|
||
color: C.ink,
|
||
marginTop: 2,
|
||
flexShrink: 1,
|
||
},
|
||
stepBtn: {
|
||
width: 48,
|
||
height: 48,
|
||
borderRadius: 14,
|
||
backgroundColor: C.mint,
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
flexShrink: 0,
|
||
},
|
||
stepBtnText: { fontSize: 24, color: C.green, fontWeight: "700" },
|
||
primary: {
|
||
backgroundColor: C.green,
|
||
borderRadius: 16,
|
||
minHeight: 54,
|
||
padding: 16,
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
flexDirection: "row",
|
||
flexWrap: "wrap",
|
||
gap: 8,
|
||
maxWidth: "100%",
|
||
},
|
||
primaryText: {
|
||
color: C.white,
|
||
fontSize: 15,
|
||
lineHeight: 21,
|
||
fontWeight: "900",
|
||
textAlign: "center",
|
||
flexShrink: 1,
|
||
},
|
||
track: {
|
||
height: 8,
|
||
borderRadius: 4,
|
||
backgroundColor: "#ffffff3d",
|
||
overflow: "hidden",
|
||
},
|
||
fill: { height: 8, borderRadius: 4 },
|
||
bigNumber: {
|
||
fontSize: 28,
|
||
lineHeight: 36,
|
||
fontWeight: "900",
|
||
color: C.ink,
|
||
marginTop: 4,
|
||
flexShrink: 1,
|
||
},
|
||
bigUnit: { fontSize: 14, color: C.muted },
|
||
chart: {
|
||
height: 175,
|
||
flexDirection: "row",
|
||
alignItems: "flex-end",
|
||
justifyContent: "space-between",
|
||
marginTop: 20,
|
||
},
|
||
barCol: { flex: 1, minWidth: 0, alignItems: "center" },
|
||
bar: {
|
||
width: 18,
|
||
maxWidth: "70%",
|
||
backgroundColor: C.green,
|
||
borderRadius: 8,
|
||
marginVertical: 6,
|
||
},
|
||
barValue: { fontSize: 9, lineHeight: 13, color: C.muted },
|
||
barLabel: {
|
||
fontSize: 9,
|
||
lineHeight: 13,
|
||
color: C.muted,
|
||
textAlign: "center",
|
||
},
|
||
stat: { flexGrow: 1, flexBasis: 130, minWidth: 0 },
|
||
statIcon: { fontSize: 22 },
|
||
empty: { alignItems: "center", paddingVertical: 34 },
|
||
insight: { backgroundColor: C.mint },
|
||
profile: {
|
||
flexDirection: "row",
|
||
flexWrap: "wrap",
|
||
alignItems: "center",
|
||
gap: 15,
|
||
},
|
||
avatarLarge: {
|
||
width: 62,
|
||
height: 62,
|
||
borderRadius: 22,
|
||
backgroundColor: C.yellow,
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
flexShrink: 0,
|
||
},
|
||
avatarLargeText: { fontSize: 23, fontWeight: "900", color: C.ink },
|
||
setting: {
|
||
flexDirection: "row",
|
||
flexWrap: "wrap",
|
||
alignItems: "center",
|
||
gap: 13,
|
||
padding: 15,
|
||
},
|
||
chevron: { fontSize: 28, color: C.muted },
|
||
safety: { backgroundColor: C.mint },
|
||
danger: {
|
||
minHeight: 48,
|
||
padding: 16,
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
},
|
||
dangerText: {
|
||
color: "#a23d33",
|
||
fontWeight: "800",
|
||
textAlign: "center",
|
||
flexShrink: 1,
|
||
},
|
||
onboard: {
|
||
padding: 24,
|
||
paddingTop: 54,
|
||
paddingBottom: 50,
|
||
gap: 16,
|
||
maxWidth: 560,
|
||
width: "100%",
|
||
alignSelf: "center",
|
||
},
|
||
logo: {
|
||
width: 58,
|
||
height: 58,
|
||
borderRadius: 20,
|
||
backgroundColor: C.green,
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
marginBottom: 8,
|
||
},
|
||
logoText: { color: C.white, fontSize: 28, fontWeight: "900" },
|
||
onboardTitle: {
|
||
fontSize: 34,
|
||
lineHeight: 42,
|
||
fontWeight: "900",
|
||
color: C.ink,
|
||
letterSpacing: -1,
|
||
flexShrink: 1,
|
||
},
|
||
choice: {
|
||
backgroundColor: C.white,
|
||
borderWidth: 1,
|
||
borderColor: C.line,
|
||
borderRadius: 18,
|
||
padding: 15,
|
||
minHeight: 76,
|
||
flexDirection: "row",
|
||
alignItems: "center",
|
||
gap: 12,
|
||
},
|
||
choiceActive: {
|
||
borderWidth: 2,
|
||
borderColor: C.green,
|
||
backgroundColor: C.mint,
|
||
},
|
||
choiceEmoji: { fontSize: 24 },
|
||
check: { fontSize: 18, fontWeight: "900", color: C.green },
|
||
secondary: {
|
||
minHeight: 48,
|
||
padding: 14,
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
},
|
||
secondaryText: { color: C.green, fontWeight: "800", textAlign: "center" },
|
||
segment: { flexDirection: "row", gap: 8 },
|
||
segmentItem: {
|
||
minHeight: 48,
|
||
flex: 1,
|
||
minWidth: 0,
|
||
borderRadius: 13,
|
||
backgroundColor: C.cream,
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
borderWidth: 1,
|
||
borderColor: C.line,
|
||
paddingVertical: 8,
|
||
},
|
||
segmentActive: { backgroundColor: C.green, borderColor: C.green },
|
||
segmentText: { fontWeight: "800", color: C.ink },
|
||
scan: {
|
||
backgroundColor: "#fff8ed",
|
||
borderStyle: "dashed",
|
||
borderColor: "#d8a960",
|
||
},
|
||
coachHero: { borderRadius: 26, padding: 22, gap: 14 },
|
||
coachProgress: {
|
||
fontSize: 12,
|
||
lineHeight: 18,
|
||
fontWeight: "700",
|
||
color: "#eee8f7",
|
||
flexShrink: 1,
|
||
},
|
||
lesson: {
|
||
flexDirection: "row",
|
||
alignItems: "center",
|
||
gap: 12,
|
||
minHeight: 82,
|
||
},
|
||
lessonIcon: {
|
||
width: 48,
|
||
height: 48,
|
||
borderRadius: 15,
|
||
backgroundColor: C.cream,
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
},
|
||
smallButton: {
|
||
minHeight: 44,
|
||
paddingHorizontal: 12,
|
||
paddingVertical: 9,
|
||
borderRadius: 11,
|
||
backgroundColor: C.mint,
|
||
justifyContent: "center",
|
||
maxWidth: "100%",
|
||
flexShrink: 1,
|
||
},
|
||
smallButtonText: {
|
||
color: C.green,
|
||
fontWeight: "800",
|
||
fontSize: 12,
|
||
lineHeight: 18,
|
||
textAlign: "center",
|
||
},
|
||
secondaryCard: {
|
||
backgroundColor: C.white,
|
||
borderRadius: 16,
|
||
borderWidth: 1,
|
||
borderColor: C.green,
|
||
minHeight: 52,
|
||
padding: 16,
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
flexDirection: "row",
|
||
flexWrap: "wrap",
|
||
gap: 8,
|
||
},
|
||
secondaryCardText: {
|
||
color: C.green,
|
||
fontWeight: "900",
|
||
textAlign: "center",
|
||
flexShrink: 1,
|
||
},
|
||
flexCopy: { flex: 1, minWidth: 0, flexShrink: 1 },
|
||
pegCard: { backgroundColor: C.white, borderRadius: 24, borderWidth: 1, borderColor: C.line, overflow: "hidden" },
|
||
pegHeader: { padding: 16, flexDirection: "row", alignItems: "center", gap: 12, borderBottomWidth: 1, borderBottomColor: C.line },
|
||
pegAvatar: { width: 46, height: 46, borderRadius: 16, backgroundColor: "#7659a6", alignItems: "center", justifyContent: "center", flexShrink: 0 },
|
||
pegName: { fontSize: 18, lineHeight: 23, fontWeight: "900", color: C.ink },
|
||
pegStatus: { fontSize: 11, lineHeight: 16, color: C.muted, flexShrink: 1 },
|
||
chatList: { padding: 15, gap: 12, backgroundColor: "#fbfcfa" },
|
||
messageRow: { flexDirection: "row", alignItems: "flex-end", gap: 8, maxWidth: "92%" },
|
||
messageRowUser: { alignSelf: "flex-end", flexDirection: "row-reverse" },
|
||
messageAvatar: { width: 28, height: 28, borderRadius: 10, backgroundColor: C.mint, alignItems: "center", justifyContent: "center", flexShrink: 0 },
|
||
messageAvatarUser: { backgroundColor: "#eee8f7" },
|
||
bubble: { backgroundColor: C.white, borderRadius: 16, borderBottomLeftRadius: 5, paddingHorizontal: 13, paddingVertical: 10, borderWidth: 1, borderColor: C.line, minWidth: 0, flexShrink: 1 },
|
||
bubbleUser: { backgroundColor: "#7659a6", borderColor: "#7659a6", borderBottomLeftRadius: 16, borderBottomRightRadius: 5 },
|
||
bubbleText: { fontSize: 14, lineHeight: 20, color: C.ink, flexShrink: 1 },
|
||
bubbleTextUser: { color: C.white },
|
||
typing: { flexDirection: "row", alignItems: "center", gap: 8, paddingLeft: 36 },
|
||
suggestions: { gap: 8, paddingHorizontal: 15, paddingVertical: 12 },
|
||
suggestion: { borderRadius: 99, borderWidth: 1, borderColor: "#cddfd5", backgroundColor: "#f4faf6", paddingHorizontal: 13, paddingVertical: 9, maxWidth: 220 },
|
||
suggestionText: { fontSize: 12, lineHeight: 17, fontWeight: "700", color: C.green, flexShrink: 1 },
|
||
composer: { marginHorizontal: 15, marginBottom: 10, minHeight: 54, borderWidth: 1.5, borderColor: "#d8ded8", borderRadius: 17, backgroundColor: C.white, flexDirection: "row", alignItems: "flex-end", gap: 8, padding: 6, paddingLeft: 13 },
|
||
chatInput: { flex: 1, minWidth: 0, maxHeight: 110, fontSize: 15, lineHeight: 21, color: C.ink, paddingVertical: 9, outlineStyle: "none" } as any,
|
||
sendButton: { width: 42, height: 42, borderRadius: 14, backgroundColor: "#7659a6", alignItems: "center", justifyContent: "center", flexShrink: 0 },
|
||
sendDisabled: { opacity: 0.4 },
|
||
pegDisclaimer: { fontSize: 10, lineHeight: 15, color: C.muted, textAlign: "center", paddingHorizontal: 18, paddingBottom: 15, flexShrink: 1 },
|
||
});
|