From bba08e1af336d4811ee798e78d5b610a0a52dcb7 Mon Sep 17 00:00:00 2001 From: Thibaud Moustier Date: Sat, 28 Feb 2026 18:39:37 +0100 Subject: [PATCH] Mobile : Update (encore) --- .../mobile/src/screens/DashboardScreen.tsx | 953 +++++++++--------- Wallette/mobile/src/services/api/alertsApi.ts | 24 +- Wallette/mobile/src/services/api/authApi.ts | 87 ++ Wallette/mobile/src/services/api/http.ts | 70 +- Wallette/mobile/src/services/api/priceApi.ts | 41 +- Wallette/mobile/src/services/api/walletApi.ts | 74 +- Wallette/mobile/src/services/socketService.ts | 72 +- Wallette/mobile/src/types/Alert.ts | 36 +- 8 files changed, 784 insertions(+), 573 deletions(-) diff --git a/Wallette/mobile/src/screens/DashboardScreen.tsx b/Wallette/mobile/src/screens/DashboardScreen.tsx index 2aeeca9..6c8c0b0 100644 --- a/Wallette/mobile/src/screens/DashboardScreen.tsx +++ b/Wallette/mobile/src/screens/DashboardScreen.tsx @@ -1,498 +1,551 @@ +import React, { useCallback, useEffect, useMemo, useState } from "react"; import { View, Text, StyleSheet, - ScrollView, TouchableOpacity, - useWindowDimensions, + ScrollView, + TextInput, + Modal, + KeyboardAvoidingView, + Platform, + ActivityIndicator, + Alert as RNAlert, } from "react-native"; -import { useState, useCallback, useEffect, useMemo } from "react"; +import AsyncStorage from "@react-native-async-storage/async-storage"; import { SafeAreaView } from "react-native-safe-area-context"; import { useFocusEffect, useNavigation } from "@react-navigation/native"; -import { Ionicons } from "@expo/vector-icons"; -import type { DashboardSummary } from "../types/DashboardSummary"; - -/** - * ✅ Façade Dashboard (API-ready) - * - aujourd'hui : ce fichier peut encore être mock si ton dashboardApi l'est - * - demain : REST via gateway (/api/...) - */ -import { getDashboardSummary } from "../services/api/dashboardApi"; - -import { loadSettings } from "../utils/settingsStorage"; +import type { PortfolioState, PortfolioAsset } from "../models/Portfolio"; import type { UserSettings } from "../models/UserSettings"; +import type { Alert as AlertType } from "../types/Alert"; -import { ui } from "../components/ui/uiStyles"; +import { loadSession } from "../utils/sessionStorage"; +import { loadSettings } from "../utils/settingsStorage"; +import { loadPortfolio, savePortfolio } from "../utils/portfolioStorage"; -import { socketService } from "../services/socketService"; import { SERVER_URL } from "../config/env"; -import type { Alert } from "../types/Alert"; +import { socketService } from "../services/socketService"; import { alertStore } from "../services/alertStore"; import { showAlertNotification } from "../services/notificationService"; -import { loadPortfolio } from "../utils/portfolioStorage"; -import type { PortfolioState, PortfolioAsset } from "../models/Portfolio"; - -import { loadSession } from "../utils/sessionStorage"; - -/** - * ✅ API-only (price) - */ import { getCurrentPrice } from "../services/api/priceApi"; +import { getAlertHistory } from "../services/api/alertsApi"; +import { getPortfolio as getPortfolioFromApi } from "../services/api/walletApi"; /** - * DashboardScreen — Step 4 - * ------------------------ - * - Multi-users : userId vient de la session (AsyncStorage) - * - Settings/Portfolio sont déjà "scopés" par userId (via storages) - * - Socket.IO auth utilise userId de session + * DashboardScreen (aligné Web) + * --------------------------- + * Web consomme : + * - GET /api/prices/current?pair=BTC/EUR -> { price } + * - GET /api/wallet/:userId -> { balance } + * - GET /api/alerts/history?userId=... -> Alert[] * - * IMPORTANT : - * - Les prix (portfolio) sont chargés via API-only (pas de mock). - * - Pour performance : on charge les prix du TOP 3 (affichage "valeur partielle"). + * Mobile : + * - UI identique dans l’esprit (crypto + adresse + buy/sell) + * - "Acheter/Vendre" = simulation (local) -> pas de trading réel */ + +type CryptoSymbol = "BTC" | "ETH" | "LTC"; +type TradeSide = "BUY" | "SELL"; +const CRYPTOS: CryptoSymbol[] = ["BTC", "ETH", "LTC"]; + +function walletAddressKey(userId: string) { + return `walletAddress:${userId}`; +} + +function normalizeSymbol(s: string) { + return s.trim().toUpperCase(); +} + +function mergePortfolios(base: PortfolioState, patch: PortfolioState): PortfolioState { + // patch écrase base sur les symboles présents dans patch + const map = new Map(); + + for (const a of base.assets) map.set(normalizeSymbol(a.symbol), a.quantity); + for (const a of patch.assets) map.set(normalizeSymbol(a.symbol), a.quantity); + + const assets: PortfolioAsset[] = Array.from(map.entries()) + .filter(([, qty]) => Number.isFinite(qty) && qty > 0) + .map(([symbol, quantity]) => ({ symbol, quantity })) + .sort((a, b) => a.symbol.localeCompare(b.symbol)); + + return { assets, updatedAtMs: Date.now() }; +} + export default function DashboardScreen() { - const { height } = useWindowDimensions(); - const compact = height < 760; + const navigation = useNavigation(); - const [summary, setSummary] = useState(null); + // session / settings + const [userId, setUserId] = useState(null); const [settings, setSettings] = useState(null); - const [portfolio, setPortfolio] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); + // dashboard data + const [selectedCrypto, setSelectedCrypto] = useState("BTC"); + const [currentPrice, setCurrentPrice] = useState(null); + const [lastPriceUpdateMs, setLastPriceUpdateMs] = useState(null); + const [portfolio, setPortfolio] = useState({ assets: [], updatedAtMs: Date.now() }); + + // wallet address + const [walletAddress, setWalletAddress] = useState(""); + const [walletAddressInfo, setWalletAddressInfo] = useState(null); + + // alerts + const [liveAlerts, setLiveAlerts] = useState([]); const [socketConnected, setSocketConnected] = useState(false); - const [socketError, setSocketError] = useState(null); + const [socketInfo, setSocketInfo] = useState(null); + + // loading / errors + const [loading, setLoading] = useState(true); + const [softError, setSoftError] = useState(null); - const [liveAlerts, setLiveAlerts] = useState([]); + // modal trade + const [tradeOpen, setTradeOpen] = useState(false); + const [tradeSide, setTradeSide] = useState("BUY"); + const [tradeQty, setTradeQty] = useState("0.01"); + const [tradeInfo, setTradeInfo] = useState(null); - const [lastRefreshMs, setLastRefreshMs] = useState(null); - const [refreshing, setRefreshing] = useState(false); + const currency: "EUR" | "USD" = settings?.currency === "USD" ? "USD" : "EUR"; + const pair = useMemo(() => `${selectedCrypto}/${currency}`, [selectedCrypto, currency]); - // ✅ Prix (API-only) pour TOP 3 assets - const [assetPrices, setAssetPrices] = useState>({}); - const [assetPricesError, setAssetPricesError] = useState(null); + const selectedQty = useMemo(() => { + const a = portfolio.assets.find((x) => normalizeSymbol(x.symbol) === selectedCrypto); + return a?.quantity ?? 0; + }, [portfolio, selectedCrypto]); - const navigation = useNavigation(); + const urgentAlert = useMemo(() => { + if (liveAlerts.length === 0) return null; - /** - * Top 3 assets (par quantité, puis symbole — simple et défendable) - * NOTE: si tu veux une vraie logique "top valeur", il faut les prix de tous les assets. - */ - const topAssets: PortfolioAsset[] = useMemo(() => { - if (!portfolio) return []; - - const copy = [...portfolio.assets]; - copy.sort((a, b) => b.quantity - a.quantity || a.symbol.localeCompare(b.symbol)); - return copy.slice(0, 3); - }, [portfolio]); - - const remainingCount = useMemo(() => { - if (!portfolio) return 0; - return Math.max(0, portfolio.assets.length - topAssets.length); - }, [portfolio, topAssets]); - - /** - * Charge prix API-only pour les TOP 3 assets - */ - const loadTopAssetPrices = useCallback(async (assets: PortfolioAsset[], currency: string) => { - setAssetPricesError(null); - - if (assets.length === 0) { - setAssetPrices({}); - return; + const critical = liveAlerts.find((a) => String(a.alertLevel).toUpperCase() === "CRITICAL"); + if (critical) return critical; + + const warning = liveAlerts.find((a) => String(a.alertLevel).toUpperCase() === "WARNING"); + if (warning) return warning; + + return liveAlerts[0]; + }, [liveAlerts]); + + const fetchPrice = useCallback( + async (p: string) => { + const res = await getCurrentPrice(p); + setCurrentPrice(res.price); + setLastPriceUpdateMs(res.timestampMs ?? Date.now()); + }, + [] + ); + + const refreshAll = useCallback(async () => { + setSoftError(null); + + const session = await loadSession(); + const uid = session?.userId ?? null; + setUserId(uid); + + const s = await loadSettings(); + setSettings(s); + + // Local portfolio (fallback + base) + const localPortfolio = await loadPortfolio(); + setPortfolio(localPortfolio); + + // Adresse wallet (local, par userId) + if (uid) { + const addr = (await AsyncStorage.getItem(walletAddressKey(uid))) ?? ""; + setWalletAddress(addr); + } else { + setWalletAddress(""); } + // Prix (API) try { - const entries = await Promise.all( - assets.map(async (a) => { - const pair = `${a.symbol}/${currency}`; - const cur = await getCurrentPrice(pair); - return [a.symbol, cur.price] as const; - }) - ); - - const map: Record = {}; - for (const [sym, pr] of entries) map[sym] = pr; - setAssetPrices(map); - } catch (e: any) { - setAssetPrices({}); - setAssetPricesError(e?.message ?? "Impossible de charger les prix (API)."); + await fetchPrice(`${selectedCrypto}/${s.currency === "USD" ? "USD" : "EUR"}`); + } catch { + setSoftError("Prix indisponible pour le moment (API)."); } - }, []); - /** - * Chargement initial (focus) - * - dashboard - * - settings - * - portfolio - * + prix (API-only) sur top 3 - */ + // Wallet (API) -> fusion pour ne pas écraser ETH/LTC locaux + if (uid) { + try { + const apiPortfolio = await getPortfolioFromApi(); + const merged = mergePortfolios(localPortfolio, apiPortfolio); + setPortfolio(merged); + await savePortfolio(merged); + } catch { + // pas bloquant + setSoftError((prev) => prev ?? "Wallet indisponible pour le moment (API)."); + } + } + + // Alert history (API) + socket live ensuite + if (uid) { + try { + const history = await getAlertHistory(); + // On met en store + state + for (const a of history) alertStore.add(a); + setLiveAlerts(history.slice(0, 50)); + } catch { + // pas bloquant + } + } + }, [fetchPrice, selectedCrypto]); + useFocusEffect( useCallback(() => { - let isActive = true; + let active = true; - async function loadData() { + (async () => { try { - setError(null); setLoading(true); - - const [dashboardData, userSettings, portfolioData] = await Promise.all([ - getDashboardSummary(), - loadSettings(), - loadPortfolio(), - ]); - - if (!isActive) return; - - setSummary(dashboardData); - setSettings(userSettings); - setPortfolio(portfolioData); - setLastRefreshMs(Date.now()); - - // prix API-only sur top 3 - const copy = [...portfolioData.assets]; - copy.sort((a, b) => b.quantity - a.quantity || a.symbol.localeCompare(b.symbol)); - const top = copy.slice(0, 3); - await loadTopAssetPrices(top, userSettings.currency); - } catch { - if (isActive) setError("Impossible de charger le dashboard."); + await refreshAll(); } finally { - if (isActive) setLoading(false); + if (active) setLoading(false); } - } - - void loadData(); + })(); return () => { - isActive = false; + active = false; }; - }, [loadTopAssetPrices]) + }, [refreshAll]) ); - /** - * Refresh auto (si activé) - */ - useEffect(() => { - if (!settings) return; - if (settings.refreshMode !== "auto") return; - - let cancelled = false; - - const intervalId = setInterval(async () => { - try { - const data = await getDashboardSummary(); - if (!cancelled) { - setSummary(data); - setLastRefreshMs(Date.now()); - } - } catch { - if (!cancelled) setError("Erreur lors du rafraîchissement automatique."); - } - }, 5000); - - return () => { - cancelled = true; - clearInterval(intervalId); - }; - }, [settings]); - - /** - * Refresh manuel - */ - const handleManualRefresh = async () => { - try { - setRefreshing(true); - const data = await getDashboardSummary(); - setSummary(data); - setLastRefreshMs(Date.now()); - setError(null); - - // refresh prix top assets aussi (API-only) - if (settings) { - await loadTopAssetPrices(topAssets, settings.currency); - } - } catch { - setError("Erreur lors de l'actualisation."); - } finally { - setRefreshing(false); - } - }; - - /** - * Socket.IO (non bloquant) - * - userId = session.userId - */ + // Socket : alert live (non bloquant) useEffect(() => { let unsub: null | (() => void) = null; - let active = true; + let alive = true; - async function initSocket() { - if (!settings) return; - - setSocketError(null); + (async () => { + setSocketInfo(null); + setSocketConnected(false); const session = await loadSession(); - const userId = session?.userId; + const uid = session?.userId; - if (!userId) { - setSocketConnected(false); - setSocketError("Socket désactivé : session absente."); + if (!uid) { + setSocketInfo("Socket désactivé : session absente."); return; } try { - socketService.connect(SERVER_URL, userId); - if (!active) return; + socketService.connect(SERVER_URL, uid); + if (!alive) return; setSocketConnected(true); } catch { - if (!active) return; - setSocketConnected(false); - setSocketError("Socket : paramètres invalides (URL ou userId)."); + if (!alive) return; + setSocketInfo("Socket indisponible (URL ou serveur)."); return; } - unsub = socketService.onAlert((alert) => { - alertStore.add(alert); - setLiveAlerts((prev) => [alert, ...prev].slice(0, 100)); + unsub = socketService.onAlert((a) => { + alertStore.add(a); + setLiveAlerts((prev) => [a, ...prev].slice(0, 100)); - if (settings.notificationsEnabled) { + // notifications si activées + if (settings?.notificationsEnabled) { void (async () => { try { - await showAlertNotification(alert); - } catch (e) { - console.log("⚠️ Notification error:", e); + await showAlertNotification(a); + } catch { + // silence } })(); } }); + // Optionnel socketService.ping(); - } - - void initSocket(); + })(); return () => { - active = false; + alive = false; if (unsub) unsub(); socketService.disconnect(); setSocketConnected(false); }; }, [settings]); - /** - * Alerte "urgente" (priorité : CRITICAL > WARNING > INFO) - * Le dashboard n'affiche qu'une seule alerte (la plus importante). - */ - const urgentAlert: Alert | null = useMemo(() => { - if (liveAlerts.length === 0) return null; + const handleSaveWalletAddress = useCallback(async () => { + setWalletAddressInfo(null); - const critical = liveAlerts.filter((a) => a.alertLevel === "CRITICAL"); - if (critical.length > 0) return critical[0]; + if (!userId) { + setWalletAddressInfo("Impossible : session absente."); + return; + } - const warning = liveAlerts.filter((a) => a.alertLevel === "WARNING"); - if (warning.length > 0) return warning[0]; + const trimmed = walletAddress.trim(); + if (trimmed.length < 6) { + setWalletAddressInfo("Adresse trop courte. Vérifie la saisie."); + return; + } - return liveAlerts[0]; - }, [liveAlerts]); + await AsyncStorage.setItem(walletAddressKey(userId), trimmed); + setWalletAddressInfo("Adresse enregistrée ✅"); + }, [walletAddress, userId]); - /** - * Valeur portefeuille (partielle = top3), API-only - */ - const portfolioPartialValue = useMemo(() => { - if (!portfolio) return null; + const openTrade = useCallback((side: TradeSide) => { + setTradeSide(side); + setTradeQty("0.01"); + setTradeInfo(null); + setTradeOpen(true); + }, []); - let sum = 0; - let hasAny = false; + const confirmTrade = useCallback(async () => { + setTradeInfo(null); - for (const a of topAssets) { - const price = assetPrices[a.symbol]; - if (typeof price !== "number") continue; - hasAny = true; - sum += a.quantity * price; + const qty = Number(tradeQty.replace(",", ".").trim()); + if (!Number.isFinite(qty) || qty <= 0) { + setTradeInfo("Quantité invalide (ex: 0.01)."); + return; } - return hasAny ? sum : null; - }, [portfolio, topAssets, assetPrices]); + const symbol = selectedCrypto; + const current = portfolio.assets.find((a) => normalizeSymbol(a.symbol) === symbol); + const currentQty = current?.quantity ?? 0; - if (loading) { - return ( - - Chargement du dashboard… - - ); - } + let nextQty = currentQty; - if (error && (!summary || !settings)) { - return ( - - {error} - - ); - } + if (tradeSide === "BUY") { + nextQty = currentQty + qty; + } else { + if (qty > currentQty) { + setTradeInfo(`Vente impossible : tu n'as que ${currentQty.toFixed(6)} ${symbol}.`); + return; + } + nextQty = currentQty - qty; + } + + const nextAssets = portfolio.assets + .filter((a) => normalizeSymbol(a.symbol) !== symbol) + .concat(nextQty > 0 ? [{ symbol, quantity: nextQty }] : []) + .sort((a, b) => normalizeSymbol(a.symbol).localeCompare(normalizeSymbol(b.symbol))); - if (!summary || !settings || !portfolio) { + const nextPortfolio: PortfolioState = { + assets: nextAssets, + updatedAtMs: Date.now(), + }; + + // Aujourd’hui : local (simulation) + await savePortfolio(nextPortfolio); + setPortfolio(nextPortfolio); + + setTradeOpen(false); + + RNAlert.alert("Transaction enregistrée ✅", `${tradeSide} ${qty} ${symbol}`); + }, [tradeQty, tradeSide, selectedCrypto, portfolio]); + + const handleManualRefreshPrice = useCallback(async () => { + try { + setSoftError(null); + await fetchPrice(pair); + } catch { + setSoftError("Prix indisponible pour le moment (API)."); + } + }, [fetchPrice, pair]); + + if (loading) { return ( - - Initialisation… + + + Chargement… ); } - const Chevron = () => ( - - ); - return ( - - {error && ( - - - {error} - + + {!!softError && ( + + {softError} )} - {/* 1) CONSEILLER */} - - Conseiller - - {summary.decision} + {/* COMPTE UTILISATEUR */} + + + Compte utilisateur + {socketConnected ? "Socket: ON ✅" : "Socket: OFF ⚠️"} + - - Pourquoi ? {summary.reason} + + UserId : {userId ?? "—"} - - Stratégie : {settings.selectedStrategyKey} - + {!!socketInfo && {socketInfo}} + - navigation.navigate("Strategy" as never)} - > - Sélectionner stratégie + {/* CHOIX CRYPTO + ADRESSE */} + + Choisir une cryptomonnaie + + + {CRYPTOS.map((c) => { + const active = c === selectedCrypto; + return ( + { + setSelectedCrypto(c); + try { + await fetchPrice(`${c}/${currency}`); + } catch { + setSoftError("Prix indisponible pour le moment (API)."); + } + }} + > + {c} + + ); + })} + + + Adresse du portefeuille + { + setWalletAddressInfo(null); + setWalletAddress(t); + }} + placeholder="Entrez une adresse crypto" + style={styles.input} + autoCapitalize="none" + autoCorrect={false} + /> + + + Enregistrer l’adresse + + {!!walletAddressInfo && {walletAddressInfo}} - {/* 2) PORTEFEUILLE */} - navigation.navigate("Wallet" as never)}> - - - Portefeuille - - + {/* SOLDE + PRIX (comme le Web) */} + + + Solde + + {selectedQty.toFixed(6)} {selectedCrypto} + + Source : wallet local (+ API BTC fusionné) + - - Valeur (top 3) : - - {portfolioPartialValue !== null ? `${portfolioPartialValue.toFixed(2)} ${settings.currency}` : "—"} - + + + Prix + + Actualiser + - {!!assetPricesError && ( - - ⚠️ {assetPricesError} - - )} + + {(currentPrice ?? 0).toFixed(2)} {currency} + - {portfolio.assets.length === 0 ? ( - - Aucun asset (ajoute BTC/ETH/SOL dans Portefeuille) - - ) : ( - - {topAssets.map((a) => ( - - {a.symbol} - {a.quantity.toFixed(6)} - - ))} - - {remainingCount > 0 && ( - - +{remainingCount} autre(s) asset(s) - - )} - - )} + + Pair : {pair} — Maj : {lastPriceUpdateMs ? new Date(lastPriceUpdateMs).toLocaleTimeString() : "—"} + - + - {/* 3) URGENCE */} - navigation.navigate("Alerts" as never)}> - - - Alertes - - + {/* SIGNAL (minimal) */} + navigation.navigate("Alerts" as never)} activeOpacity={0.85}> + + Signal du marché + Ouvrir alertes + - {urgentAlert ? ( - - - {urgentAlert.alertLevel} : {urgentAlert.reason} - - - {urgentAlert.action} {urgentAlert.pair} — {(urgentAlert.confidence * 100).toFixed(0)}% - - - ) : ( - Aucune alerte pour le moment. - )} + {urgentAlert ? ( + <> + {String(urgentAlert.action ?? "HOLD")} + + {String(urgentAlert.alertLevel ?? "INFO")} — {String(urgentAlert.reason ?? urgentAlert.message ?? "—")} + + + {String(urgentAlert.pair ?? "")} — Confiance{" "} + {typeof urgentAlert.confidence === "number" ? `${Math.round(urgentAlert.confidence * 100)}%` : "—"} + + + ) : ( + Aucune alerte pour le moment. + )} + - - Socket : {socketConnected ? "connecté ✅" : "déconnecté ⚠️"} - {socketError ? ` — ${socketError}` : ""} - + {/* STRATÉGIE (bouton) */} + navigation.navigate("Strategy" as never)} activeOpacity={0.85}> + + Stratégie + Configurer + + + (L’écran Stratégie reste inchangé — ici on ne fait que naviguer.) + - {/* 4) PRIX BTC */} - navigation.navigate("History" as never)}> - - - - Prix BTC - - + {/* ACTIONS (Acheter / Vendre) */} + + Actions - - {refreshing ? "…" : "Actualiser"} - - + + openTrade("BUY")}> + Acheter + - - Dernière maj : {lastRefreshMs ? new Date(lastRefreshMs).toLocaleTimeString() : "—"} - + openTrade("SELL")}> + Vendre + + - - - Prix BTC - - {summary.price.toFixed(2)} {settings.currency} + + Note : Acheter/Vendre = simulation (registre local). Pas de trading réel. + + + + {/* MODAL BUY/SELL */} + setTradeOpen(false)}> + + + + + {tradeSide === "BUY" ? "Acheter" : "Vendre"} {selectedCrypto} + + + Prix : {(currentPrice ?? 0).toFixed(2)} {currency} + + + Quantité + { + setTradeInfo(null); + setTradeQty(t); + }} + keyboardType="decimal-pad" + placeholder="0.01" + style={styles.input} + /> + + {!!tradeInfo && {tradeInfo}} + + + setTradeOpen(false)}> + Annuler + + + void confirmTrade()}> + Confirmer + + - + + + + {/* Bouton refresh global */} + void refreshAll()}> + Tout actualiser @@ -500,126 +553,102 @@ export default function DashboardScreen() { } const styles = StyleSheet.create({ - safeArea: { - flex: 1, - backgroundColor: ui.screen.backgroundColor, - }, + safeArea: { flex: 1, backgroundColor: "#0b1220" }, + container: { padding: 16, paddingBottom: 28 }, - containerCompact: { - padding: 12, - }, + centered: { flex: 1, alignItems: "center", justifyContent: "center", backgroundColor: "#0b1220" }, - cardCompact: { + banner: { + borderWidth: 1, + borderColor: "#f59e0b", + backgroundColor: "rgba(245,158,11,0.12)", padding: 12, - marginBottom: 10, + borderRadius: 12, + marginBottom: 12, }, + bannerText: { color: "#f59e0b", fontWeight: "800" }, - titleCompact: { - marginBottom: 6, - }, - - bigCompact: { - fontSize: 24, - marginVertical: 4, - }, - - bannerWarning: { - borderColor: "#ca8a04", - }, - bannerWarningText: { - color: "#ca8a04", - }, - bannerCompact: { - padding: 10, - marginBottom: 10, + card: { + backgroundColor: "#ffffff", + borderRadius: 16, + padding: 14, + marginBottom: 12, + borderWidth: 1, + borderColor: "#e5e7eb", }, - errorText: { - color: "#dc2626", - fontWeight: "900", - }, + rowBetween: { flexDirection: "row", justifyContent: "space-between", alignItems: "center" }, + rowGap: { flexDirection: "row", gap: 12, marginBottom: 12 }, + half: { flex: 1, marginBottom: 0 }, - centerText: { - textAlign: "center", - }, + title: { fontSize: 16, fontWeight: "900", color: "#0f172a" }, + muted: { marginTop: 6, color: "#475569", fontWeight: "600" }, + bold: { fontWeight: "900", color: "#0f172a" }, - boldInline: { - fontWeight: "900", - color: "#0f172a", - }, + bigValue: { marginTop: 10, fontSize: 22, fontWeight: "900", color: "#0f172a" }, + signalAction: { marginTop: 10, fontSize: 24, fontWeight: "900", color: "#0f172a" }, - fullButton: { - flexGrow: 0, - flexBasis: "auto", - width: "100%", - marginTop: 12, - }, - buttonCompact: { + cryptoRow: { flexDirection: "row", gap: 10, marginTop: 10 }, + cryptoBtn: { + flex: 1, paddingVertical: 10, - marginTop: 10, - }, - - headerRow: { - flexDirection: "row", - justifyContent: "space-between", + borderRadius: 12, + borderWidth: 1, + borderColor: "#e5e7eb", alignItems: "center", + backgroundColor: "#fff", }, + cryptoBtnActive: { borderColor: "#0f172a" }, + cryptoText: { fontWeight: "900", color: "#0f172a", opacity: 0.7 }, + cryptoTextActive: { opacity: 1 }, - urgentBox: { + input: { borderWidth: 1, borderColor: "#e5e7eb", - borderRadius: 10, - padding: 10, - backgroundColor: "#ffffff", - }, - urgentBoxCompact: { - padding: 8, - }, - urgentTitle: { - fontWeight: "900", + borderRadius: 12, + paddingHorizontal: 12, + paddingVertical: 10, + marginTop: 8, + backgroundColor: "#fff", color: "#0f172a", }, - priceHeaderRow: { - flexDirection: "row", - justifyContent: "space-between", + secondaryButton: { + marginTop: 10, + paddingVertical: 12, + borderRadius: 12, + borderWidth: 1, + borderColor: "#e5e7eb", alignItems: "center", - marginBottom: 8, + backgroundColor: "#fff", }, + secondaryButtonText: { fontWeight: "900", color: "#0f172a", opacity: 0.9 }, refreshBtn: { paddingHorizontal: 12, paddingVertical: 8, - borderRadius: 10, + borderRadius: 12, borderWidth: 1, borderColor: "#e5e7eb", backgroundColor: "#fff", }, - refreshBtnCompact: { - paddingVertical: 6, - }, - refreshBtnDisabled: { - opacity: 0.6, - }, - refreshBtnText: { - fontWeight: "900", - color: "#0f172a", - }, - - priceCard: { - marginTop: 10, - borderWidth: 1, - borderColor: "#e5e7eb", - borderRadius: 10, - padding: 12, - backgroundColor: "#ffffff", - }, - priceCardCompact: { - paddingVertical: 10, - }, - priceBig: { - fontSize: 22, - fontWeight: "900", - color: "#0f172a", - }, + refreshText: { fontWeight: "900", color: "#0f172a" }, + + buySellRow: { flexDirection: "row", gap: 10, marginTop: 10 }, + buyBtn: { flex: 1, backgroundColor: "#16a34a", paddingVertical: 12, borderRadius: 12, alignItems: "center" }, + sellBtn: { flex: 1, backgroundColor: "#dc2626", paddingVertical: 12, borderRadius: 12, alignItems: "center" }, + buySellText: { color: "#fff", fontWeight: "900" }, + + modalBackdrop: { flex: 1, backgroundColor: "rgba(0,0,0,0.35)", justifyContent: "center", padding: 16 }, + modalWrap: { width: "100%" }, + modalCard: { backgroundColor: "#fff", borderRadius: 16, padding: 16, borderWidth: 1, borderColor: "#e5e7eb" }, + modalTitle: { fontSize: 18, fontWeight: "900", color: "#0f172a" }, + modalError: { marginTop: 10, fontWeight: "800", color: "#dc2626" }, + + modalButtonsRow: { flexDirection: "row", gap: 10, marginTop: 14 }, + modalBtn: { flex: 1, paddingVertical: 12, borderRadius: 12, alignItems: "center" }, + modalBtnSecondary: { backgroundColor: "#fff", borderWidth: 1, borderColor: "#e5e7eb" }, + modalBtnSecondaryText: { fontWeight: "900", color: "#0f172a" }, + modalBtnPrimary: { backgroundColor: "#0f172a" }, + modalBtnPrimaryText: { fontWeight: "900", color: "#fff" }, }); \ No newline at end of file diff --git a/Wallette/mobile/src/services/api/alertsApi.ts b/Wallette/mobile/src/services/api/alertsApi.ts index 0e51f28..b0f282b 100644 --- a/Wallette/mobile/src/services/api/alertsApi.ts +++ b/Wallette/mobile/src/services/api/alertsApi.ts @@ -4,27 +4,23 @@ import { loadSession } from "../../utils/sessionStorage"; import { alertStore } from "../alertStore"; /** - * alertsApi (API-first) - * -------------------- - * - Lecture events serveur (optionnel) : GET /api/alerts/events - * - Clear : local (car pas d'endpoint "clear" dans le contrat) + * alertsApi + * --------- + * Aligné Web : + * - GET /api/alerts/history?userId=... + * Réponse : Alert[] */ -export async function getAlertEvents(limit = 10): Promise { + +export async function getAlertHistory(): Promise { const session = await loadSession(); const userId = session?.userId; - if (!userId) throw new Error("Session absente : impossible de charger les alertes."); + if (!userId) throw new Error("Session absente : impossible de charger l'historique des alertes."); - const data = await apiGet<{ events: Alert[] }>( - `/alerts/events?userId=${encodeURIComponent(userId)}&limit=${encodeURIComponent(String(limit))}` - ); + const data = await apiGet(`/alerts/history?userId=${encodeURIComponent(userId)}`); - return data.events ?? []; + return Array.isArray(data) ? (data as Alert[]) : []; } -/** - * Clear local : vide la liste affichée (alertStore). - * (Le contrat API ne prévoit pas de suppression globale côté serveur.) - */ export async function clearAlertsLocal(): Promise { alertStore.clear?.(); } \ No newline at end of file diff --git a/Wallette/mobile/src/services/api/authApi.ts b/Wallette/mobile/src/services/api/authApi.ts index e69de29..347dd62 100644 --- a/Wallette/mobile/src/services/api/authApi.ts +++ b/Wallette/mobile/src/services/api/authApi.ts @@ -0,0 +1,87 @@ +import type { AuthUser } from "../../models/AuthUser"; +import type { Session } from "../../utils/sessionStorage"; + +import { createUser, verifyLogin, findUserById } from "../../utils/authUsersStorage"; +import { loadSession, saveSession, clearSession } from "../../utils/sessionStorage"; + +/** + * authApi.ts + * ---------- + * Façade d'authentification. + * + * Aujourd'hui (Step local) : + * - login/register/logout en local (AsyncStorage) + * + * Demain (API ready) : + * - tu pourras remplacer l'implémentation par : + * POST /api/auth/login + * POST /api/auth/register + * GET /api/auth/me + * POST /api/auth/logout + * + * L'UI ne change pas : elle appelle toujours authApi. + */ + +export type AuthResult = + | { ok: true; user: AuthUser; session: Session } + | { ok: false; message: string }; + +export async function register(params: { + email: string; + username: string; + displayName?: string; + password: string; +}): Promise { + const res = await createUser(params); + if (!res.ok) return { ok: false, message: res.message }; + + const session: Session = { + userId: res.user.userId, + email: res.user.email, + createdAtMs: Date.now(), + }; + + await saveSession(session); + + return { ok: true, user: res.user, session }; +} + +export async function login(params: { + login: string; // email OU username + password: string; +}): Promise { + const res = await verifyLogin(params); + if (!res.ok) return { ok: false, message: res.message }; + + const session: Session = { + userId: res.user.userId, + email: res.user.email, + createdAtMs: Date.now(), + }; + + await saveSession(session); + + return { ok: true, user: res.user, session }; +} + +export async function logout(): Promise { + await clearSession(); +} + +/** + * Retourne l'utilisateur courant (si session active). + * Utile pour afficher profil / sécuriser certaines actions. + */ +export async function getCurrentUser(): Promise { + const session = await loadSession(); + if (!session?.userId) return null; + + return await findUserById(session.userId); +} + +/** + * Juste la session (pratique pour guards). + */ +export async function getSession(): Promise { + return await loadSession(); +} \ No newline at end of file diff --git a/Wallette/mobile/src/services/api/http.ts b/Wallette/mobile/src/services/api/http.ts index 8956e3b..a699996 100644 --- a/Wallette/mobile/src/services/api/http.ts +++ b/Wallette/mobile/src/services/api/http.ts @@ -1,43 +1,71 @@ import { API_BASE_URL } from "../../config/env"; /** - * Petit helper HTTP (fetch) : - * - force JSON - * - vérifie le format { ok: true, data } / { ok:false, error } - * - jette une erreur claire si l’API répond mal ou si HTTP != 2xx + * HTTP helper (fetch) + * ------------------- + * Compatible avec 2 formats : + * A) "wrap" : { ok:true, data: ... } / { ok:false, error:{message} } + * B) "raw" : { price: ... } / { balance: ... } / Alert[] */ -export async function apiGet(path: string): Promise { - const res = await fetch(`${API_BASE_URL}${path}`, { method: "GET" }); - const json = await res.json().catch(() => null); +async function parseJsonSafe(res: Response) { + const text = await res.text(); + try { + return text ? JSON.parse(text) : null; + } catch { + return null; + } +} + +function buildUrl(path: string) { + const p = path.startsWith("/") ? path : `/${path}`; + return `${API_BASE_URL}${p}`; +} - if (!res.ok) { - const msg = json?.error?.message ?? `HTTP ${res.status}`; +function unwrapOrRaw(json: any): T { + // Format "wrap" + if (json && typeof json === "object" && "ok" in json) { + if (json.ok === true) return json.data as T; + const msg = json?.error?.message ?? "Réponse API invalide (ok=false)"; throw new Error(msg); } - if (!json || json.ok !== true) { - const msg = json?.error?.message ?? "Réponse API invalide (ok=false)"; + + // Format "raw" + return json as T; +} + +export async function apiGet(path: string): Promise { + const res = await fetch(buildUrl(path), { + method: "GET", + headers: { Accept: "application/json" }, + }); + + const json = await parseJsonSafe(res); + + if (!res.ok) { + const msg = json?.error?.message ?? json?.message ?? `HTTP ${res.status}`; throw new Error(msg); } - return json.data as T; + + return unwrapOrRaw(json); } export async function apiPost(path: string, body: unknown): Promise { - const res = await fetch(`${API_BASE_URL}${path}`, { + const res = await fetch(buildUrl(path), { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { + Accept: "application/json", + "Content-Type": "application/json", + }, body: JSON.stringify(body), }); - const json = await res.json().catch(() => null); + const json = await parseJsonSafe(res); if (!res.ok) { - const msg = json?.error?.message ?? `HTTP ${res.status}`; + const msg = json?.error?.message ?? json?.message ?? `HTTP ${res.status}`; throw new Error(msg); } - if (!json || json.ok !== true) { - const msg = json?.error?.message ?? "Réponse API invalide (ok=false)"; - throw new Error(msg); - } - return json.data as T; + + return unwrapOrRaw(json); } \ No newline at end of file diff --git a/Wallette/mobile/src/services/api/priceApi.ts b/Wallette/mobile/src/services/api/priceApi.ts index 30eb157..09bba8d 100644 --- a/Wallette/mobile/src/services/api/priceApi.ts +++ b/Wallette/mobile/src/services/api/priceApi.ts @@ -1,13 +1,14 @@ import { apiGet } from "./http"; /** - * priceApi (API-only) - * ------------------- - * Contrat : - * - GET /api/price/current?pair=BTC/EUR + * priceApi + * -------- + * Web utilise : + * - GET /api/prices/current?pair=BTC/EUR + * Réponse attendue côté web : { price: number, ... } * - * Retour attendu (à ajuster si le backend diffère) : - * { ok:true, data:{ pair, timestamp_ms, current_price } } + * On reste robuste : + * - accepte aussi d'anciens formats si jamais. */ export type PriceCurrent = { @@ -17,15 +18,27 @@ export type PriceCurrent = { }; export async function getCurrentPrice(pair: string): Promise { - const data = await apiGet<{ - pair: string; - timestamp_ms: number; - current_price: number; - }>(`/price/current?pair=${encodeURIComponent(pair)}`); + const data = await apiGet(`/prices/current?pair=${encodeURIComponent(pair)}`); + + // Format web : { price: number } + const price = + (typeof data?.price === "number" ? data.price : null) ?? + (typeof data?.current_price === "number" ? data.current_price : null) ?? + (typeof data?.data?.price === "number" ? data.data.price : null) ?? + (typeof data?.data?.current_price === "number" ? data.data.current_price : null); + + if (typeof price !== "number" || !Number.isFinite(price)) { + throw new Error("Prix invalide (API)."); + } + + const ts = + (typeof data?.timestampMs === "number" ? data.timestampMs : null) ?? + (typeof data?.timestamp_ms === "number" ? data.timestamp_ms : null) ?? + Date.now(); return { - pair: data.pair, - timestampMs: Number(data.timestamp_ms), - price: Number(data.current_price), + pair, + timestampMs: Number(ts), + price: Number(price), }; } \ No newline at end of file diff --git a/Wallette/mobile/src/services/api/walletApi.ts b/Wallette/mobile/src/services/api/walletApi.ts index b6986bc..89170c1 100644 --- a/Wallette/mobile/src/services/api/walletApi.ts +++ b/Wallette/mobile/src/services/api/walletApi.ts @@ -1,52 +1,50 @@ -import { apiPost, apiGet } from "./http"; +import { apiGet } from "./http"; import { loadSession } from "../../utils/sessionStorage"; import type { PortfolioState } from "../../models/Portfolio"; /** - * walletApi (API-only) - * -------------------- - * - GET /api/wallets?userId=... (à confirmer) - * - POST /api/wallets/upsert + * walletApi + * --------- + * Aligné Web : + * - GET /api/wallet/:userId + * Réponse attendue : { balance: number } * - * NOTE: reset = upsert d'un portefeuille vide (pas besoin d'un endpoint dédié) + * Le web affiche le solde en BTC. + * Côté mobile, on convertit en PortfolioState (assets). */ -function nowMs() { - return Date.now(); -} +type WalletResponse = { + balance?: number; + // si un jour ils passent multi-assets, on ne casse pas : + balances?: Record; +}; export async function getPortfolio(): Promise { const session = await loadSession(); const userId = session?.userId; if (!userId) throw new Error("Session absente : impossible de charger le portefeuille."); - const data = await apiGet<{ portfolio: PortfolioState }>( - `/wallets?userId=${encodeURIComponent(userId)}` - ); - - if (!data.portfolio) throw new Error("Réponse API invalide : portfolio manquant."); - return data.portfolio; -} - -export async function upsertPortfolio(next: PortfolioState): Promise { - const session = await loadSession(); - const userId = session?.userId; - if (!userId) throw new Error("Session absente : impossible de sauvegarder le portefeuille."); - - const data = await apiPost<{ portfolio: PortfolioState }>(`/wallets/upsert`, { - userId, - portfolio: next, - }); - - if (!data.portfolio) throw new Error("Réponse API invalide : portfolio manquant."); - return data.portfolio; -} - -/** - * Reset portefeuille (API-only) - * => on sauvegarde un portefeuille vide via /wallets/upsert - */ -export async function resetPortfolio(): Promise { - const empty: PortfolioState = { assets: [], updatedAtMs: nowMs() }; - return await upsertPortfolio(empty); + const data = await apiGet(`/wallet/${encodeURIComponent(userId)}`); + + // Format actuel web : balance BTC + if (typeof data?.balance === "number" && Number.isFinite(data.balance)) { + return { + assets: [{ symbol: "BTC", quantity: data.balance }], + updatedAtMs: Date.now(), + }; + } + + // Format futur possible : balances { BTC:..., ETH:..., LTC:... } + const balances = data?.balances; + if (balances && typeof balances === "object") { + const assets = Object.entries(balances) + .filter(([, qty]) => typeof qty === "number" && Number.isFinite(qty) && qty > 0) + .map(([symbol, qty]) => ({ symbol: symbol.toUpperCase(), quantity: qty })) + .sort((a, b) => a.symbol.localeCompare(b.symbol)); + + return { assets, updatedAtMs: Date.now() }; + } + + // fallback safe + return { assets: [], updatedAtMs: Date.now() }; } \ No newline at end of file diff --git a/Wallette/mobile/src/services/socketService.ts b/Wallette/mobile/src/services/socketService.ts index 27bf531..4d93f28 100644 --- a/Wallette/mobile/src/services/socketService.ts +++ b/Wallette/mobile/src/services/socketService.ts @@ -1,43 +1,97 @@ import { io, Socket } from "socket.io-client"; import type { Alert } from "../types/Alert"; +/** + * socketService.ts + * ---------------- + * Objectif : + * - Connexion Socket.IO via le Gateway (duckdns ou local) + * - Auth par event "auth" (payload = userId) + * - Réception des alertes via event "alert" + * + * Important : + * - Le Gateway doit proxy /socket.io/* vers alerts-service. + * - En prod (https), socket.io bascule en wss automatiquement. + */ + class SocketService { private socket: Socket | null = null; private listeners = new Set<(alert: Alert) => void>(); + private currentUserId: string | null = null; + private currentServerUrl: string | null = null; connect(serverUrl: string, userId: string) { if (!serverUrl) throw new Error("serverUrl is required"); if (!userId) throw new Error("userId is required"); - // évite d'ouvrir plusieurs connexions si connect() est appelé plusieurs fois - if (this.socket?.connected) return; + // Si on est déjà connecté au même serveur avec le même userId -> rien à faire + if ( + this.socket && + this.socket.connected && + this.currentServerUrl === serverUrl && + this.currentUserId === userId + ) { + return; + } + + // Si on change de serveur ou de userId, on repart proprement + if (this.socket) { + this.disconnect(); + } + + this.currentServerUrl = serverUrl; + this.currentUserId = userId; this.socket = io(serverUrl, { - transports: ["websocket", "polling"], // plus fiable sur mobile + // Très important en environnement proxy + path: "/socket.io", + + // Mobile : websocket + fallback polling + transports: ["websocket", "polling"], + reconnection: true, - reconnectionAttempts: 5, + reconnectionAttempts: 10, + reconnectionDelay: 800, + reconnectionDelayMax: 3000, timeout: 10000, }); + const emitAuth = () => { + if (!this.socket || !this.currentUserId) return; + this.socket.emit("auth", this.currentUserId); + }; + this.socket.on("connect", () => { console.log("✅ Socket connecté:", this.socket?.id); - // tuto de ton camarade : auth via userId - this.socket?.emit("auth", userId); + emitAuth(); }); + // Si le serveur supporte le message this.socket.on("auth_success", (data: any) => { console.log("✅ Auth success:", data?.message ?? data); }); + // Alertes live this.socket.on("alert", (alert: Alert) => { for (const cb of this.listeners) cb(alert); }); - this.socket.on("connect_error", (err) => { + // Debug utile + this.socket.on("connect_error", (err: any) => { console.log("❌ Socket connect_error:", err?.message ?? err); }); - this.socket.on("disconnect", (reason) => { + this.socket.on("error", (err: any) => { + console.log("❌ Socket error:", err?.message ?? err); + }); + + this.socket.on("reconnect", (attempt: number) => { + console.log("🔁 Socket reconnect:", attempt); + // Après reconnexion, on renvoie auth (certains backends oublient la session socket) + emitAuth(); + }); + + this.socket.on("disconnect", (reason: string) => { console.log("⚠️ Socket disconnect:", reason); }); } @@ -61,6 +115,8 @@ class SocketService { this.socket.removeAllListeners(); this.socket.disconnect(); this.socket = null; + this.currentUserId = null; + this.currentServerUrl = null; this.listeners.clear(); } } diff --git a/Wallette/mobile/src/types/Alert.ts b/Wallette/mobile/src/types/Alert.ts index e747535..ac89884 100644 --- a/Wallette/mobile/src/types/Alert.ts +++ b/Wallette/mobile/src/types/Alert.ts @@ -1,24 +1,28 @@ /** - * Alert - * ----- - * Contrat mobile (Socket.IO / API). - * On garde des enums clairs comme demandé : - * - alertLevel : CRITICAL / WARNING / INFO - * - action : BUY / SELL / HOLD / STOP_LOSS + * Alert.ts + * -------- + * Format d'alerte utilisé par : + * - Socket.IO (event "alert") + * - REST history : GET /api/alerts/history?userId=... * - * id est optionnel : utile pour React (keyExtractor), - * mais le serveur peut ne pas l'envoyer au début. + * Le Web utilise parfois `alert.message`. + * Donc on rend ce champ optionnel (pour compatibilité). */ + +export type AlertLevel = "CRITICAL" | "WARNING" | "INFO"; +export type AlertAction = "BUY" | "SELL" | "HOLD" | "STOP_LOSS"; + export interface Alert { - id?: string; + action?: AlertAction; // BUY / SELL / HOLD / STOP_LOSS + pair?: string; // ex: BTC/EUR + confidence?: number; // 0..1 + reason?: string; // texte explicatif - action: "BUY" | "SELL" | "HOLD" | "STOP_LOSS"; - pair: string; // ex: "BTC/EUR" - confidence: number; // 0..1 - reason: string; + // ✅ Compatibilité Web (script.js affiche alert.message si présent) + message?: string; - alertLevel: "INFO" | "WARNING" | "CRITICAL"; - timestamp: number; // Unix ms + alertLevel?: AlertLevel; // CRITICAL / WARNING / INFO + timestamp?: number; // ms - price?: number; + price?: number; // prix au moment de l’alerte } \ No newline at end of file -- 2.50.1