feat: app mobile React Native / Expo (v0.31.0)
Package mobile/ (Expo SDK 56, expo-router) réutilisant l'API backend : Login (mdp+TOTP), Chat (+ TTS expo-speech), Review (approuver/refuser). JWT en expo-secure-store, thème dark HUD, EXPO_PUBLIC_API_BASE. typecheck vert. STT mobile reporté (lib native), TTS OK. Versions gérées par Expo. Palier de risque : reversible (client mobile, API commune). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
import { Tabs, Redirect } from "expo-router";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useAuth } from "@/auth";
|
||||
import { C } from "@/theme";
|
||||
|
||||
export default function TabsLayout() {
|
||||
const { token, ready } = useAuth();
|
||||
if (ready && !token) return <Redirect href="/login" />;
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
screenOptions={{
|
||||
headerStyle: { backgroundColor: C.surface },
|
||||
headerTintColor: C.accent,
|
||||
tabBarStyle: { backgroundColor: C.surface, borderTopColor: C.border },
|
||||
tabBarActiveTintColor: C.accent,
|
||||
tabBarInactiveTintColor: C.muted,
|
||||
}}
|
||||
>
|
||||
<Tabs.Screen
|
||||
name="chat"
|
||||
options={{
|
||||
title: "Chat",
|
||||
tabBarIcon: ({ color, size }) => <Ionicons name="chatbubble-ellipses" color={color} size={size} />,
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="review"
|
||||
options={{
|
||||
title: "Review",
|
||||
tabBarIcon: ({ color, size }) => <Ionicons name="shield-checkmark" color={color} size={size} />,
|
||||
}}
|
||||
/>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { View, Text, TextInput, Pressable, ScrollView, StyleSheet } from "react-native";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import * as Speech from "expo-speech";
|
||||
import { useAuth } from "@/auth";
|
||||
import { api, ApiError } from "@/api";
|
||||
import { C } from "@/theme";
|
||||
|
||||
interface Msg {
|
||||
role: "user" | "assistant";
|
||||
text: string;
|
||||
}
|
||||
|
||||
export default function Chat() {
|
||||
const { token, logout } = useAuth();
|
||||
const [messages, setMessages] = useState<Msg[]>([]);
|
||||
const [input, setInput] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [speak, setSpeak] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const scroll = useRef<ScrollView>(null);
|
||||
|
||||
const send = async (): Promise<void> => {
|
||||
const t = input.trim();
|
||||
if (!t || busy || !token) return;
|
||||
setInput("");
|
||||
setError(null);
|
||||
setMessages((m) => [...m, { role: "user", text: t }]);
|
||||
setBusy(true);
|
||||
try {
|
||||
const { reply } = await api.chat(token, t);
|
||||
setMessages((m) => [...m, { role: "assistant", text: reply }]);
|
||||
if (speak) Speech.speak(reply, { language: "fr-FR" });
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 401) {
|
||||
await logout();
|
||||
return;
|
||||
}
|
||||
setError(err instanceof Error ? err.message : "Erreur");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={s.wrap}>
|
||||
<ScrollView
|
||||
ref={scroll}
|
||||
style={s.list}
|
||||
contentContainerStyle={{ padding: 12, gap: 8 }}
|
||||
onContentSizeChange={() => scroll.current?.scrollToEnd({ animated: true })}
|
||||
>
|
||||
{messages.length === 0 && <Text style={s.muted}>Pose une question à CHLOVA…</Text>}
|
||||
{messages.map((m, i) => (
|
||||
<View key={i} style={[s.bubble, m.role === "user" ? s.user : s.assistant]}>
|
||||
<Text style={s.bubbleText}>{m.text}</Text>
|
||||
</View>
|
||||
))}
|
||||
{busy && <Text style={s.muted}>CHLOVA réfléchit…</Text>}
|
||||
{error && <Text style={s.error}>{error}</Text>}
|
||||
</ScrollView>
|
||||
|
||||
<View style={s.bar}>
|
||||
<Pressable onPress={() => setSpeak((v) => !v)} style={s.iconBtn} accessibilityLabel="Voix">
|
||||
<Ionicons name={speak ? "volume-high" : "volume-mute"} size={20} color={speak ? C.accent : C.muted} />
|
||||
</Pressable>
|
||||
<TextInput
|
||||
style={s.input}
|
||||
placeholder="Message…"
|
||||
placeholderTextColor={C.muted}
|
||||
value={input}
|
||||
onChangeText={setInput}
|
||||
editable={!busy}
|
||||
/>
|
||||
<Pressable onPress={send} disabled={busy || !input.trim()} style={[s.send, (busy || !input.trim()) && s.disabled]}>
|
||||
<Ionicons name="send" size={18} color={C.bg} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const s = StyleSheet.create({
|
||||
wrap: { flex: 1, backgroundColor: C.bg },
|
||||
list: { flex: 1 },
|
||||
muted: { color: C.muted, fontSize: 13 },
|
||||
error: { color: C.danger, fontSize: 13 },
|
||||
bubble: { maxWidth: "85%", borderRadius: 10, padding: 10, borderWidth: 1 },
|
||||
user: { alignSelf: "flex-end", backgroundColor: C.surface2, borderColor: C.accent },
|
||||
assistant: { alignSelf: "flex-start", backgroundColor: C.surface, borderColor: C.border },
|
||||
bubbleText: { color: C.fg, fontSize: 14 },
|
||||
bar: { flexDirection: "row", alignItems: "center", gap: 8, padding: 10, borderTopWidth: 1, borderTopColor: C.border, backgroundColor: C.surface },
|
||||
iconBtn: { padding: 8, borderRadius: 8, borderWidth: 1, borderColor: C.border },
|
||||
input: { flex: 1, backgroundColor: C.surface2, borderColor: C.border, borderWidth: 1, borderRadius: 8, color: C.fg, paddingHorizontal: 12, paddingVertical: 8 },
|
||||
send: { backgroundColor: C.accent, borderRadius: 8, padding: 10 },
|
||||
disabled: { opacity: 0.5 },
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { View, Text, Pressable, FlatList, StyleSheet, Alert } from "react-native";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useAuth } from "@/auth";
|
||||
import { api, ApiError, type Asset } from "@/api";
|
||||
import { C } from "@/theme";
|
||||
|
||||
export default function Review() {
|
||||
const { token, logout } = useAuth();
|
||||
const [assets, setAssets] = useState<Asset[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const onErr = useCallback(
|
||||
(err: unknown): void => {
|
||||
if (err instanceof ApiError && err.status === 401) void logout();
|
||||
else setError(err instanceof Error ? err.message : "Erreur");
|
||||
},
|
||||
[logout],
|
||||
);
|
||||
|
||||
const refresh = useCallback(async (): Promise<void> => {
|
||||
if (!token) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const { assets } = await api.review(token);
|
||||
setAssets(assets);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
onErr(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [token, onErr]);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
const decide = async (id: string, action: "approve" | "refuse"): Promise<void> => {
|
||||
if (!token) return;
|
||||
try {
|
||||
if (action === "approve") await api.approve(token, id);
|
||||
else await api.refuse(token, id);
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
onErr(err);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmRefuse = (id: string): void => {
|
||||
Alert.alert("Refuser", `Refuser définitivement ${id} ?`, [
|
||||
{ text: "Annuler", style: "cancel" },
|
||||
{ text: "Refuser", style: "destructive", onPress: () => void decide(id, "refuse") },
|
||||
]);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={s.wrap}>
|
||||
<FlatList
|
||||
data={assets}
|
||||
keyExtractor={(a) => a.id}
|
||||
contentContainerStyle={{ padding: 12, gap: 8 }}
|
||||
onRefresh={() => void refresh()}
|
||||
refreshing={loading}
|
||||
ListEmptyComponent={!loading ? <Text style={s.muted}>Aucun asset en attente.</Text> : null}
|
||||
ListHeaderComponent={error ? <Text style={s.error}>{error}</Text> : null}
|
||||
renderItem={({ item: a }) => (
|
||||
<View style={s.card}>
|
||||
<Text style={s.id}>{a.id}</Text>
|
||||
<Text style={[s.tier, { color: a.riskTier === "privileged" ? C.danger : C.success }]}>
|
||||
{a.riskTier} · {a.status} · v{a.version}
|
||||
</Text>
|
||||
<View style={s.actions}>
|
||||
<Pressable style={[s.act, s.approve]} onPress={() => void decide(a.id, "approve")}>
|
||||
<Ionicons name="checkmark" size={16} color={C.success} />
|
||||
<Text style={[s.actText, { color: C.success }]}>Approuver</Text>
|
||||
</Pressable>
|
||||
<Pressable style={[s.act, s.refuse]} onPress={() => confirmRefuse(a.id)}>
|
||||
<Ionicons name="close" size={16} color={C.danger} />
|
||||
<Text style={[s.actText, { color: C.danger }]}>Refuser</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
/>
|
||||
<Pressable style={s.logout} onPress={() => void logout()}>
|
||||
<Ionicons name="log-out-outline" size={18} color={C.muted} />
|
||||
<Text style={s.muted}> Déconnexion</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const s = StyleSheet.create({
|
||||
wrap: { flex: 1, backgroundColor: C.bg },
|
||||
muted: { color: C.muted, fontSize: 13 },
|
||||
error: { color: C.danger, fontSize: 13, marginBottom: 8 },
|
||||
card: { backgroundColor: C.surface, borderColor: C.border, borderWidth: 1, borderRadius: 10, padding: 12, gap: 6 },
|
||||
id: { color: C.fg, fontFamily: "monospace", fontSize: 13 },
|
||||
tier: { fontSize: 12 },
|
||||
actions: { flexDirection: "row", gap: 8, marginTop: 4 },
|
||||
act: { flexDirection: "row", alignItems: "center", gap: 4, borderWidth: 1, borderRadius: 8, paddingHorizontal: 10, paddingVertical: 6 },
|
||||
approve: { borderColor: C.success },
|
||||
refuse: { borderColor: C.danger },
|
||||
actText: { fontSize: 13 },
|
||||
logout: { flexDirection: "row", alignItems: "center", justifyContent: "center", padding: 12, borderTopWidth: 1, borderTopColor: C.border },
|
||||
});
|
||||
Reference in New Issue
Block a user