From ab4dd14e5d16621aee49286c8f904622f5808580 Mon Sep 17 00:00:00 2001 From: Thibaud Moustier Date: Mon, 23 Feb 2026 18:58:28 +0100 Subject: [PATCH] Mobile : theme + HistoryScreen --- mobile-ui/App.tsx | 10 ++ mobile-ui/src/components/ActionsCard.tsx | 5 +- mobile-ui/src/components/ui/uiStyles.ts | 102 +++++++++----- mobile-ui/src/mocks/signals.mock.ts | 37 +++++ mobile-ui/src/screens/DashboardScreen.tsx | 121 ++++++----------- mobile-ui/src/screens/HistoryScreen.tsx | 158 ++++++++++++++++++++++ mobile-ui/src/services/signalService.ts | 16 +++ mobile-ui/src/theme/theme.ts | 37 +++++ mobile-ui/src/types/Signal.ts | 15 ++ 9 files changed, 383 insertions(+), 118 deletions(-) create mode 100644 mobile-ui/src/mocks/signals.mock.ts create mode 100644 mobile-ui/src/screens/HistoryScreen.tsx create mode 100644 mobile-ui/src/services/signalService.ts create mode 100644 mobile-ui/src/theme/theme.ts create mode 100644 mobile-ui/src/types/Signal.ts diff --git a/mobile-ui/App.tsx b/mobile-ui/App.tsx index c94646d..e491b2b 100644 --- a/mobile-ui/App.tsx +++ b/mobile-ui/App.tsx @@ -3,11 +3,13 @@ import { createNativeStackNavigator } from "@react-navigation/native-stack"; import DashboardScreen from "./src/screens/DashboardScreen"; import SettingsScreen from "./src/screens/SettingsScreen"; +import HistoryScreen from "./src/screens/HistoryScreen"; // Types des routes (pour éviter les erreurs de navigation) export type RootStackParamList = { Dashboard: undefined; Settings: undefined; + History: undefined; }; const Stack = createNativeStackNavigator(); @@ -21,11 +23,19 @@ export default function App() { component={DashboardScreen} options={{ title: "Dashboard" }} /> + + + + ); diff --git a/mobile-ui/src/components/ActionsCard.tsx b/mobile-ui/src/components/ActionsCard.tsx index eadcfee..dfc2a6c 100644 --- a/mobile-ui/src/components/ActionsCard.tsx +++ b/mobile-ui/src/components/ActionsCard.tsx @@ -3,6 +3,7 @@ import { ui } from "./ui/uiStyles"; type Props = { onGoSettings: () => void; + onGoHistory: () => void; }; /** @@ -14,7 +15,7 @@ type Props = { * - Historique (placeholder) * - Paramètres (navigation réelle via callback) */ -export default function ActionsCard({ onGoSettings }: Props) { +export default function ActionsCard({ onGoSettings, onGoHistory }: Props) { return ( Actions @@ -24,7 +25,7 @@ export default function ActionsCard({ onGoSettings }: Props) { Voir stratégie - {}}> + Historique diff --git a/mobile-ui/src/components/ui/uiStyles.ts b/mobile-ui/src/components/ui/uiStyles.ts index 3f2233f..ec44b35 100644 --- a/mobile-ui/src/components/ui/uiStyles.ts +++ b/mobile-ui/src/components/ui/uiStyles.ts @@ -1,84 +1,118 @@ import { StyleSheet } from "react-native"; +import { theme } from "../../theme/theme"; export const ui = StyleSheet.create({ + // Écran screen: { - backgroundColor: "#f6f7fb", + backgroundColor: theme.colors.bg, + }, + container: { + padding: theme.spacing.l, + }, + centered: { + flex: 1, + justifyContent: "center", + alignItems: "center", + backgroundColor: theme.colors.bg, }, - card: { - backgroundColor: "#fff", - borderRadius: 14, - padding: 14, - marginBottom: 12, + // Card +card: { + backgroundColor: theme.colors.card, + borderRadius: theme.radius.card, + padding: 14, + marginBottom: theme.spacing.m, - // ombre iOS - shadowColor: "#000", - shadowOpacity: 0.08, - shadowRadius: 10, - shadowOffset: { width: 0, height: 4 }, + borderWidth: 1, + borderColor: theme.colors.border, - // ombre Android - elevation: 3, - }, + shadowColor: "#000", + shadowOpacity: 0.08, + shadowRadius: 10, + shadowOffset: { width: 0, height: 4 }, + + elevation: 3, +}, title: { fontSize: 16, - fontWeight: "700", + fontWeight: "800", + color: theme.colors.text, marginBottom: 10, }, - muted: { - opacity: 0.65, - fontSize: 12, - }, - - rowBetween: { - flexDirection: "row", - justifyContent: "space-between", - alignItems: "center", - }, - value: { fontSize: 16, + color: theme.colors.text, }, valueBold: { fontSize: 16, - fontWeight: "700", + fontWeight: "800", + color: theme.colors.text, }, bigCenter: { fontSize: 28, - fontWeight: "800", + fontWeight: "900", textAlign: "center", + color: theme.colors.text, marginVertical: 6, }, + muted: { + fontSize: 12, + color: theme.colors.muted, + }, + + rowBetween: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + }, + + // Badge (pill) badge: { alignSelf: "center", paddingHorizontal: 12, paddingVertical: 6, - borderRadius: 999, + borderRadius: theme.radius.pill, marginTop: 6, }, - badgeText: { - fontWeight: "800", + fontWeight: "900", fontSize: 12, letterSpacing: 0.3, }, + // Boutons button: { - backgroundColor: "#111827", + backgroundColor: theme.colors.primary, paddingVertical: 12, - borderRadius: 12, + borderRadius: theme.radius.button, alignItems: "center", flexGrow: 1, flexBasis: "48%", }, - buttonText: { color: "#fff", + fontWeight: "900", + }, + + // Messages (warning/error/info) + banner: { + ...StyleSheet.flatten({ + backgroundColor: theme.colors.card, + borderRadius: theme.radius.card, + padding: 12, + marginBottom: theme.spacing.m, + }), + borderWidth: 1, + borderColor: theme.colors.border, + }, + + bannerText: { fontWeight: "800", + color: theme.colors.text, }, }); \ No newline at end of file diff --git a/mobile-ui/src/mocks/signals.mock.ts b/mobile-ui/src/mocks/signals.mock.ts new file mode 100644 index 0000000..0bad531 --- /dev/null +++ b/mobile-ui/src/mocks/signals.mock.ts @@ -0,0 +1,37 @@ +import type { Signal } from "../types/Signal"; + +export const SIGNALS_MOCK: Signal[] = [ + { + signalId: "sig-001", + pair: "BTC/EUR", + timestamp: Date.now() - 1000 * 60 * 8, + action: "BUY", + criticality: "INFO", + status: "ACTIVE", + confidence: 0.82, + reason: "RSI < 30, retournement haussier détecté", + priceAtSignal: 42150.23, + }, + { + signalId: "sig-002", + pair: "BTC/EUR", + timestamp: Date.now() - 1000 * 60 * 35, + action: "HOLD", + criticality: "WARNING", + status: "ACTIVE", + confidence: 0.61, + reason: "Marché incertain, volatilité élevée", + priceAtSignal: 41980.12, + }, + { + signalId: "sig-003", + pair: "BTC/EUR", + timestamp: Date.now() - 1000 * 60 * 90, + action: "SELL", + criticality: "CRITICAL", + status: "SUPERSEDED", + confidence: 0.74, + reason: "Cassure support + volume vendeur", + priceAtSignal: 42540.55, + }, +]; \ No newline at end of file diff --git a/mobile-ui/src/screens/DashboardScreen.tsx b/mobile-ui/src/screens/DashboardScreen.tsx index 168536f..a8a34b0 100644 --- a/mobile-ui/src/screens/DashboardScreen.tsx +++ b/mobile-ui/src/screens/DashboardScreen.tsx @@ -12,6 +12,7 @@ import MarketCard from "../components/MarketCard"; import StrategyCard from "../components/StrategyCard"; import WalletCard from "../components/WalletCard"; import ActionsCard from "../components/ActionsCard"; + import { ui } from "../components/ui/uiStyles"; // ✅ Socket.IO @@ -23,37 +24,28 @@ import type { Alert } from "../types/Alert"; * DashboardScreen * ---------------- * Écran principal mobile. - * - * Responsabilités : - * - Charger les données (mock/API REST) - * - Charger les paramètres utilisateur - * - Gérer le refresh automatique (settings.refreshMode) - * - Gérer la navigation - * - Recevoir les alertes temps réel via Socket.IO (non bloquant) - * - * L'affichage "business" est délégué aux composants (Cards). + * - Charge Dashboard + Settings + * - Refresh auto si activé + * - Socket.IO optionnel (non bloquant) pour alertes live + * - UI cohérente via uiStyles/theme */ export default function DashboardScreen() { const [summary, setSummary] = useState(null); const [settings, setSettings] = useState(null); - const [loading, setLoading] = useState(true); // erreurs REST/refresh const [error, setError] = useState(null); - // état Socket.IO (non bloquant) + // Socket state (non bloquant) const [socketConnected, setSocketConnected] = useState(false); const [socketError, setSocketError] = useState(null); - - // alertes reçues (optionnel mais utile) const [liveAlerts, setLiveAlerts] = useState([]); const navigation = useNavigation(); /** - * Chargement initial + rechargement à chaque focus - * (utile quand on revient des Paramètres) + * Chargement initial + rechargement quand on revient sur l'écran */ useFocusEffect( useCallback(() => { @@ -74,13 +66,9 @@ export default function DashboardScreen() { setSettings(userSettings); } } catch { - if (isActive) { - setError("Impossible de charger le dashboard."); - } + if (isActive) setError("Impossible de charger le dashboard."); } finally { - if (isActive) { - setLoading(false); - } + if (isActive) setLoading(false); } } @@ -93,7 +81,7 @@ export default function DashboardScreen() { ); /** - * Refresh automatique si activé + * Refresh auto (sans clignotement global) */ useEffect(() => { if (!settings) return; @@ -117,27 +105,21 @@ export default function DashboardScreen() { }, [settings]); /** - * Socket.IO : connexion / écoute des alertes - * - Non bloquant : si socket KO, l'app continue via REST - * - Se déclenche quand on a settings (souvent nécessaire pour userId) - * - * IMPORTANT : - * 👉 Version propre : on n'utilise PAS @ts-expect-error. - * 👉 On lit un userId optionnel (si présent), sinon on désactive le socket - * et on affiche un message clair. + * Socket.IO (non bloquant) + * - Si userId absent => on désactive socket proprement. + * - Si serveur pas prêt => connect_error timeout => on affiche un message, sans crasher. */ useEffect(() => { if (!settings) return; setSocketError(null); - // ✅ Proposition propre : un champ optionnel dans UserSettings : userId?: string - // Si pas dispo => on ne connecte pas (pas de fake "user-123" en prod) + // ✅ userId optionnel pour Step 1/2/3 (pas d'auth) const userId = settings.userId; if (!userId) { setSocketConnected(false); - setSocketError("Socket désactivé : userId absent (pas connecté)."); + setSocketError("Socket désactivé : userId absent."); return; } @@ -146,7 +128,7 @@ export default function DashboardScreen() { setSocketConnected(true); } catch { setSocketConnected(false); - setSocketError("Socket: impossible de se connecter (URL/userId)."); + setSocketError("Socket : paramètres invalides (URL ou userId)."); return; } @@ -154,7 +136,6 @@ export default function DashboardScreen() { setLiveAlerts((prev) => [alert, ...prev].slice(0, 50)); }); - // ping optionnel pour debug socketService.ping(); return () => { @@ -167,16 +148,16 @@ export default function DashboardScreen() { // Chargement if (loading) { return ( - + Chargement du dashboard… ); } - // Erreur bloquante + // Erreur bloquante (si rien à afficher) if (error && (!summary || !settings)) { return ( - + {error} ); @@ -185,7 +166,7 @@ export default function DashboardScreen() { // Sécurité if (!summary || !settings) { return ( - + Initialisation… ); @@ -193,85 +174,61 @@ export default function DashboardScreen() { return ( - - {/* Warning REST/refresh (non bloquant) */} + + {/* Bannière warning REST/refresh (non bloquant) */} {error && ( - - {error} + + {error} )} - {/* Socket status (non bloquant) */} - - + {/* Bannière Socket (non bloquant) */} + + Socket.IO : {socketConnected ? "connecté ✅" : "déconnecté ⚠️"} - {!!socketError && {socketError}} - - Alertes reçues : {liveAlerts.length} + {!!socketError && {socketError}} + + Alertes reçues : {liveAlerts.length} + - navigation.navigate("Settings" as never)} /> + navigation.navigate("Settings" as never)} + onGoHistory={() => navigation.navigate("History" as never)} + /> ); } -/* ===================== STYLES ===================== */ - const styles = StyleSheet.create({ safeArea: { flex: 1, backgroundColor: ui.screen.backgroundColor, }, - container: { - padding: 16, - }, - - centered: { - flex: 1, - justifyContent: "center", - alignItems: "center", - backgroundColor: ui.screen.backgroundColor, - }, - errorText: { color: "#dc2626", - fontWeight: "800", + fontWeight: "900", }, - // Bannières en style "card" comme les mockups - warningCard: { - ...ui.card, - borderWidth: 1, + bannerWarning: { borderColor: "#ca8a04", }, - - warningText: { + bannerWarningText: { color: "#ca8a04", - fontWeight: "800", }, - socketCard: { - ...ui.card, - borderWidth: 1, + bannerSocket: { borderColor: "#cbd5e1", }, - socketTitle: { - fontWeight: "800", color: "#0f172a", - marginBottom: 6, - }, - - socketMuted: { - ...ui.muted, - marginTop: 2, }, }); \ No newline at end of file diff --git a/mobile-ui/src/screens/HistoryScreen.tsx b/mobile-ui/src/screens/HistoryScreen.tsx new file mode 100644 index 0000000..d065eec --- /dev/null +++ b/mobile-ui/src/screens/HistoryScreen.tsx @@ -0,0 +1,158 @@ +import { View, Text, StyleSheet, FlatList } from "react-native"; +import { useEffect, useState } from "react"; +import { SafeAreaView } from "react-native-safe-area-context"; + +import type { Signal, SignalAction, SignalCriticality } from "../types/Signal"; +import { fetchRecentSignals } from "../services/signalService"; +import { ui } from "../components/ui/uiStyles"; + +function getActionColor(action: SignalAction): string { + switch (action) { + case "BUY": + return "#16a34a"; + case "SELL": + return "#dc2626"; + case "STOP_LOSS": + return "#991b1b"; + case "HOLD": + default: + return "#ca8a04"; + } +} + +function getCriticalityColor(level: SignalCriticality): string { + switch (level) { + case "CRITICAL": + return "#b91c1c"; + case "WARNING": + return "#ca8a04"; + case "INFO": + default: + return "#2563eb"; + } +} + +function formatDate(ms: number): string { + return new Date(ms).toLocaleString(); +} + +export default function HistoryScreen() { + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + let active = true; + + async function load() { + try { + setError(null); + setLoading(true); + const data = await fetchRecentSignals(20); + if (active) setItems(data); + } catch { + if (active) setError("Impossible de charger l'historique."); + } finally { + if (active) setLoading(false); + } + } + + load(); + + return () => { + active = false; + }; + }, []); + + if (loading) { + return ( + + Chargement de l'historique… + + ); + } + + if (error) { + return ( + + {error} + + ); + } + + return ( + + it.signalId} + ListEmptyComponent={ + + Aucun signal + Aucun historique disponible pour l’instant. + + } + renderItem={({ item }) => { + const actionColor = getActionColor(item.action); + const critColor = getCriticalityColor(item.criticality); + + return ( + + + {item.pair} + {formatDate(item.timestamp)} + + + {/* Badges */} + + + {item.action} + + + + {item.criticality} + + + + {item.status} + + + + + Confiance + {(item.confidence * 100).toFixed(1)}% + + + + Prix au signal + {item.priceAtSignal.toFixed(2)} + + + {item.reason} + + ); + }} + /> + + ); +} + +const styles = StyleSheet.create({ + safeArea: { + flex: 1, + backgroundColor: ui.screen.backgroundColor, + }, + container: { + padding: 16, + }, + centered: { + flex: 1, + justifyContent: "center", + alignItems: "center", + backgroundColor: ui.screen.backgroundColor, + }, + errorText: { + color: "#dc2626", + fontWeight: "800", + }, +}); \ No newline at end of file diff --git a/mobile-ui/src/services/signalService.ts b/mobile-ui/src/services/signalService.ts new file mode 100644 index 0000000..17c27bf --- /dev/null +++ b/mobile-ui/src/services/signalService.ts @@ -0,0 +1,16 @@ +import type { Signal } from "../types/Signal"; +import { SIGNALS_MOCK } from "../mocks/signals.mock"; + +/** + * Plus tard (API REST): + * GET /api/alerts/events?userId=...&limit=10 + * ou GET /api/signals?userId=...&pair=BTC/EUR&limit=... + * + * Pour l’instant : mock. + */ +export async function fetchRecentSignals(limit: number = 20): Promise { + // simulation d'appel réseau + return new Promise((resolve) => { + setTimeout(() => resolve(SIGNALS_MOCK.slice(0, limit)), 250); + }); +} \ No newline at end of file diff --git a/mobile-ui/src/theme/theme.ts b/mobile-ui/src/theme/theme.ts new file mode 100644 index 0000000..21a4b39 --- /dev/null +++ b/mobile-ui/src/theme/theme.ts @@ -0,0 +1,37 @@ +export const theme = { + colors: { + // Fond menthe très léger (crypto vibe) + bg: "#F2FBF6", + + // Cartes + card: "#FFFFFF", + + // Textes + text: "#0F172A", + muted: "#64748B", + + // Accent principal (boutons, éléments actifs) + primary: "#14532D", // vert profond + + // Bordures douces + border: "#DCEFE6", + + // États + success: "#16A34A", + warning: "#CA8A04", + danger: "#DC2626", + info: "#0EA5E9", // bleu clair (pas foncé) + }, + + radius: { + card: 14, + button: 12, + pill: 999, + }, + + spacing: { + s: 8, + m: 12, + l: 16, + }, +} as const; \ No newline at end of file diff --git a/mobile-ui/src/types/Signal.ts b/mobile-ui/src/types/Signal.ts new file mode 100644 index 0000000..acc9937 --- /dev/null +++ b/mobile-ui/src/types/Signal.ts @@ -0,0 +1,15 @@ +export type SignalAction = "BUY" | "SELL" | "HOLD" | "STOP_LOSS"; +export type SignalCriticality = "CRITICAL" | "WARNING" | "INFO"; +export type SignalStatus = "ACTIVE" | "SUPERSEDED" | "EXECUTED" | "INVALID"; + +export interface Signal { + signalId: string; + pair: string; // ex "BTC/EUR" + timestamp: number; // ms + action: SignalAction; + criticality: SignalCriticality; + status: SignalStatus; + confidence: number; // 0..1 + reason: string; + priceAtSignal: number; +} \ No newline at end of file -- 2.50.1