From 5230e75b5f16df6cc08fa419c4897aeade1169ec Mon Sep 17 00:00:00 2001 From: Thibaud Moustier Date: Thu, 26 Feb 2026 15:43:57 +0100 Subject: [PATCH] =?utf8?q?Mobile=20:=20Modification=20'mobile'=20-=20Mise?= =?utf8?q?=20en=20place=20des=20diff=C3=A9rente=20fenetre=20+=20modificati?= =?utf8?q?on=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=utf8 Content-Transfer-Encoding: 8bit --- Wallette/mobile/.expo/README.md | 13 + Wallette/mobile/.expo/devices.json | 3 + Wallette/mobile/App.tsx | 144 ++++++-- .../mobile/src/components/AccountMenu.tsx | 119 +++++++ Wallette/mobile/src/mocks/prices.mock.ts | 16 + Wallette/mobile/src/models/AccountProfile.ts | 7 + Wallette/mobile/src/models/Portfolio.ts | 15 + Wallette/mobile/src/screens/AboutScreen.tsx | 164 +++++++++ Wallette/mobile/src/screens/AccountScreen.tsx | 164 +++++++++ Wallette/mobile/src/screens/AlertsScreen.tsx | 220 ++++++------ Wallette/mobile/src/screens/AuthScreen.tsx | 112 ++++++ .../mobile/src/screens/DashboardScreen.tsx | 190 +++++++---- .../mobile/src/screens/SettingsScreen.tsx | 63 +++- Wallette/mobile/src/screens/WalletScreen.tsx | 321 ++++++++++++------ Wallette/mobile/src/types/Alert.ts | 17 +- Wallette/mobile/src/utils/accountStorage.ts | 50 +++ Wallette/mobile/src/utils/portfolioStorage.ts | 53 +++ Wallette/mobile/src/utils/sessionStorage.ts | 45 +++ Wallette/mobile/src/utils/settingsStorage.ts | 23 +- 19 files changed, 1401 insertions(+), 338 deletions(-) create mode 100644 Wallette/mobile/.expo/README.md create mode 100644 Wallette/mobile/.expo/devices.json create mode 100644 Wallette/mobile/src/components/AccountMenu.tsx create mode 100644 Wallette/mobile/src/mocks/prices.mock.ts create mode 100644 Wallette/mobile/src/models/AccountProfile.ts create mode 100644 Wallette/mobile/src/models/Portfolio.ts create mode 100644 Wallette/mobile/src/screens/AboutScreen.tsx create mode 100644 Wallette/mobile/src/screens/AccountScreen.tsx create mode 100644 Wallette/mobile/src/screens/AuthScreen.tsx create mode 100644 Wallette/mobile/src/utils/accountStorage.ts create mode 100644 Wallette/mobile/src/utils/portfolioStorage.ts create mode 100644 Wallette/mobile/src/utils/sessionStorage.ts diff --git a/Wallette/mobile/.expo/README.md b/Wallette/mobile/.expo/README.md new file mode 100644 index 0000000..ce8c4b6 --- /dev/null +++ b/Wallette/mobile/.expo/README.md @@ -0,0 +1,13 @@ +> Why do I have a folder named ".expo" in my project? + +The ".expo" folder is created when an Expo project is started using "expo start" command. + +> What do the files contain? + +- "devices.json": contains information about devices that have recently opened this project. This is used to populate the "Development sessions" list in your development builds. +- "settings.json": contains the server configuration that is used to serve the application manifest. + +> Should I commit the ".expo" folder? + +No, you should not share the ".expo" folder. It does not contain any information that is relevant for other developers working on the project, it is specific to your machine. +Upon project creation, the ".expo" folder is already added to your ".gitignore" file. diff --git a/Wallette/mobile/.expo/devices.json b/Wallette/mobile/.expo/devices.json new file mode 100644 index 0000000..5efff6c --- /dev/null +++ b/Wallette/mobile/.expo/devices.json @@ -0,0 +1,3 @@ +{ + "devices": [] +} diff --git a/Wallette/mobile/App.tsx b/Wallette/mobile/App.tsx index 8f9403f..d33c4c3 100644 --- a/Wallette/mobile/App.tsx +++ b/Wallette/mobile/App.tsx @@ -1,7 +1,8 @@ -import { NavigationContainer } from "@react-navigation/native"; +import { NavigationContainer, createNavigationContainerRef } from "@react-navigation/native"; import { createNativeStackNavigator } from "@react-navigation/native-stack"; -import { TouchableOpacity } from "react-native"; +import { TouchableOpacity, View, Text, Alert as RNAlert } from "react-native"; import { Ionicons } from "@expo/vector-icons"; +import { useEffect, useState } from "react"; import DashboardScreen from "./src/screens/DashboardScreen"; import SettingsScreen from "./src/screens/SettingsScreen"; @@ -9,8 +10,13 @@ import HistoryScreen from "./src/screens/HistoryScreen"; import AlertsScreen from "./src/screens/AlertsScreen"; import StrategyScreen from "./src/screens/StrategyScreen"; import WalletScreen from "./src/screens/WalletScreen"; +import AuthScreen from "./src/screens/AuthScreen"; +import AccountScreen from "./src/screens/AccountScreen"; +import AboutScreen from "./src/screens/AboutScreen"; + +import { loadSession, clearSession } from "./src/utils/sessionStorage"; +import AccountMenu from "./src/components/AccountMenu"; -// Types des routes (pour éviter les erreurs de navigation) export type RootStackParamList = { Dashboard: undefined; Settings: undefined; @@ -18,13 +24,95 @@ export type RootStackParamList = { Alerts: undefined; Strategy: undefined; Wallet: undefined; + Account: undefined; + About: undefined; }; const Stack = createNativeStackNavigator(); +// ✅ navigationRef (permet de naviguer hors des screens, ex: depuis un Modal) +export const navigationRef = createNavigationContainerRef(); + export default function App() { + const [ready, setReady] = useState(false); + const [isAuthed, setIsAuthed] = useState(false); + + const [menuVisible, setMenuVisible] = useState(false); + const [sessionEmail, setSessionEmail] = useState("—"); + + useEffect(() => { + let active = true; + + async function init() { + const s = await loadSession(); + if (!active) return; + + setIsAuthed(!!s); + setSessionEmail(s?.email ?? "—"); + setReady(true); + } + + init(); + + return () => { + active = false; + }; + }, []); + + const doLogout = () => { + RNAlert.alert("Déconnexion", "Se déconnecter du compte ?", [ + { text: "Annuler", style: "cancel" }, + { + text: "Déconnexion", + style: "destructive", + onPress: async () => { + await clearSession(); + setIsAuthed(false); + setSessionEmail("—"); + }, + }, + ]); + }; + + const go = (route: keyof RootStackParamList) => { + // ✅ safe guard : navigation prête ? + if (!navigationRef.isReady()) return; + navigationRef.navigate(route); + }; + + if (!ready) { + return ( + + Initialisation… + + ); + } + + // Pas connecté -> écran Auth + if (!isAuthed) { + return ( + { + const s = await loadSession(); + setSessionEmail(s?.email ?? "—"); + setIsAuthed(true); + }} + /> + ); + } + return ( - + + {/* ✅ Modal menu Compte (navigue grâce au navigationRef) */} + setMenuVisible(false)} + onGoAccount={() => go("Account")} + onGoAbout={() => go("About")} + onLogout={doLogout} + /> + ({ title: "Dashboard", headerRight: () => ( - navigation.navigate("Settings")}> - - + + {/* 👤 Menu Compte */} + setMenuVisible(true)}> + + + + {/* ⚙️ Paramètres */} + navigation.navigate("Settings")}> + + + ), })} /> - + + + + - + - - - - - + + ); diff --git a/Wallette/mobile/src/components/AccountMenu.tsx b/Wallette/mobile/src/components/AccountMenu.tsx new file mode 100644 index 0000000..2be54cb --- /dev/null +++ b/Wallette/mobile/src/components/AccountMenu.tsx @@ -0,0 +1,119 @@ +import { Modal, View, Text, StyleSheet, TouchableOpacity, Pressable } from "react-native"; +import { Ionicons } from "@expo/vector-icons"; +import { ui } from "./ui/uiStyles"; + +type Props = { + visible: boolean; + email: string; + onClose: () => void; + + onGoAccount: () => void; + onGoAbout: () => void; + onLogout: () => void; +}; + +export default function AccountMenu({ + visible, + email, + onClose, + onGoAccount, + onGoAbout, + onLogout, +}: Props) { + return ( + + {/* Overlay */} + + {/* Stop propagation */} + null}> + + + + Compte + {email} + + + + + + + { onClose(); onGoAccount(); }}> + + Modification du compte + + + + { onClose(); onGoAbout(); }}> + + À propos + + + + { onClose(); onLogout(); }}> + + Déconnexion + + + + + ); +} + +const styles = StyleSheet.create({ + overlay: { + flex: 1, + backgroundColor: "rgba(15, 23, 42, 0.35)", + justifyContent: "flex-start", + paddingTop: 70, + paddingHorizontal: 16, + }, + + card: { + backgroundColor: "#fff", + borderRadius: 14, + borderWidth: 1, + borderColor: "#e5e7eb", + padding: 12, + }, + + headerRow: { + flexDirection: "row", + alignItems: "center", + gap: 10, + paddingBottom: 10, + borderBottomWidth: 1, + borderBottomColor: "#e5e7eb", + marginBottom: 10, + }, + + title: { + fontSize: 16, + fontWeight: "900", + color: "#0f172a", + }, + + item: { + flexDirection: "row", + alignItems: "center", + gap: 10, + paddingVertical: 12, + paddingHorizontal: 10, + borderRadius: 12, + }, + + itemDanger: { + marginTop: 6, + backgroundColor: "#dc262611", + }, + + itemIcon: { + width: 22, + opacity: 0.9, + }, + + itemText: { + flex: 1, + fontWeight: "900", + color: "#0f172a", + }, +}); \ No newline at end of file diff --git a/Wallette/mobile/src/mocks/prices.mock.ts b/Wallette/mobile/src/mocks/prices.mock.ts new file mode 100644 index 0000000..d93941c --- /dev/null +++ b/Wallette/mobile/src/mocks/prices.mock.ts @@ -0,0 +1,16 @@ +/** + * Mock prices (Step 3) + * -------------------- + * En attendant l'API REST /api/price/current. + */ +export const mockPrices: Record = { + BTC: 42150.23, + ETH: 2300.55, + SOL: 105.12, + ADA: 0.48, +}; + +export function getMockPrice(symbol: string): number | null { + const key = symbol.toUpperCase().trim(); + return typeof mockPrices[key] === "number" ? mockPrices[key] : null; +} \ No newline at end of file diff --git a/Wallette/mobile/src/models/AccountProfile.ts b/Wallette/mobile/src/models/AccountProfile.ts new file mode 100644 index 0000000..93ebefb --- /dev/null +++ b/Wallette/mobile/src/models/AccountProfile.ts @@ -0,0 +1,7 @@ +export interface AccountProfile { + displayName: string; + email: string; + // On ne stocke JAMAIS un mot de passe en clair. + // Ici on simule seulement un champ pour l'UI (Step 4 sans API). + updatedAtMs: number; +} \ No newline at end of file diff --git a/Wallette/mobile/src/models/Portfolio.ts b/Wallette/mobile/src/models/Portfolio.ts new file mode 100644 index 0000000..52f0f34 --- /dev/null +++ b/Wallette/mobile/src/models/Portfolio.ts @@ -0,0 +1,15 @@ +/** + * Portfolio (Step 3) + * ------------------ + * Mono-user / multi-cryptos. + * Une ligne = un asset (BTC, ETH, etc.) + quantité. + */ +export interface PortfolioAsset { + symbol: string; // ex: "BTC" + quantity: number; // ex: 0.25 +} + +export interface PortfolioState { + assets: PortfolioAsset[]; + updatedAtMs: number; +} \ No newline at end of file diff --git a/Wallette/mobile/src/screens/AboutScreen.tsx b/Wallette/mobile/src/screens/AboutScreen.tsx new file mode 100644 index 0000000..ab72035 --- /dev/null +++ b/Wallette/mobile/src/screens/AboutScreen.tsx @@ -0,0 +1,164 @@ +import { View, Text, StyleSheet, TextInput, TouchableOpacity, Alert as RNAlert } from "react-native"; +import { useEffect, useState } from "react"; +import { SafeAreaView } from "react-native-safe-area-context"; + +import { ui } from "../components/ui/uiStyles"; +import { loadSession } from "../utils/sessionStorage"; +import { loadAccountProfile, saveAccountProfile } from "../utils/accountStorage"; +import type { AccountProfile } from "../models/AccountProfile"; + +/** + * AccountScreen (Step 4 - sans API) + * -------------------------------- + * UI prête pour email + mot de passe. + * En réalité : + * - on ne stocke pas le mdp + * - on sauvegarde seulement displayName + email (local) + * + * Quand l'API arrive : POST /auth/update-profile + update password. + */ +export default function AccountScreen() { + const [profile, setProfile] = useState(null); + const [userId, setUserId] = useState(""); + + const [displayName, setDisplayName] = useState(""); + const [email, setEmail] = useState(""); + + // Champs UI (non persistés) pour mot de passe + const [password, setPassword] = useState(""); + const [password2, setPassword2] = useState(""); + + const [info, setInfo] = useState(null); + + useEffect(() => { + async function init() { + const [session, p] = await Promise.all([loadSession(), loadAccountProfile()]); + setUserId(session?.userId ?? ""); + setProfile(p); + setDisplayName(p.displayName); + setEmail(p.email); + } + init(); + }, []); + + if (!profile) { + return ( + + Chargement du compte… + + ); + } + + const handleSave = async () => { + setInfo(null); + + const normalizedEmail = email.trim().toLowerCase(); + const isValidEmail = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(normalizedEmail); + + if (!isValidEmail) { + setInfo("Email invalide."); + return; + } + + // Mot de passe : UI prête, mais pas stocké sans API + if (password || password2) { + if (password.length < 6) { + setInfo("Mot de passe trop court (min 6)."); + return; + } + if (password !== password2) { + setInfo("Les mots de passe ne correspondent pas."); + return; + } + + // Sans API : on ne peut pas réellement changer le mdp + RNAlert.alert( + "Mot de passe", + "Sans API, ce changement n’est pas appliqué (UI prête).", + [{ text: "OK" }] + ); + } + + const updated: AccountProfile = { + displayName: displayName.trim() || "Utilisateur", + email: normalizedEmail, + updatedAtMs: Date.now(), + }; + + await saveAccountProfile(updated); + setProfile(updated); + setPassword(""); + setPassword2(""); + setInfo("Profil sauvegardé ✅"); + }; + + return ( + + + Modification du compte + + + Informations + + UserId (session) + {userId || "—"} + + Nom affiché + + + Email + + + + + Mot de passe (UI prête) + + Le changement réel du mot de passe sera branché quand l’API auth sera disponible. + + + Nouveau mot de passe + + + Confirmation + + + + {!!info && {info}} + + + Sauvegarder + + + + ); +} + +const styles = StyleSheet.create({ + safeArea: { flex: 1, backgroundColor: ui.screen.backgroundColor }, + title: { fontSize: 22, fontWeight: "900", marginBottom: 12, color: "#0f172a" }, + fullButton: { flexGrow: 0, flexBasis: "auto", width: "100%", marginBottom: 10 }, + + mono: { + marginTop: 6, + fontFamily: "monospace", + color: "#0f172a", + opacity: 0.8, + }, + + input: { + borderWidth: 1, + borderColor: "#e5e7eb", + borderRadius: 10, + paddingHorizontal: 12, + paddingVertical: 10, + marginTop: 8, + backgroundColor: "#fff", + color: "#0f172a", + }, +}); \ No newline at end of file diff --git a/Wallette/mobile/src/screens/AccountScreen.tsx b/Wallette/mobile/src/screens/AccountScreen.tsx new file mode 100644 index 0000000..ab72035 --- /dev/null +++ b/Wallette/mobile/src/screens/AccountScreen.tsx @@ -0,0 +1,164 @@ +import { View, Text, StyleSheet, TextInput, TouchableOpacity, Alert as RNAlert } from "react-native"; +import { useEffect, useState } from "react"; +import { SafeAreaView } from "react-native-safe-area-context"; + +import { ui } from "../components/ui/uiStyles"; +import { loadSession } from "../utils/sessionStorage"; +import { loadAccountProfile, saveAccountProfile } from "../utils/accountStorage"; +import type { AccountProfile } from "../models/AccountProfile"; + +/** + * AccountScreen (Step 4 - sans API) + * -------------------------------- + * UI prête pour email + mot de passe. + * En réalité : + * - on ne stocke pas le mdp + * - on sauvegarde seulement displayName + email (local) + * + * Quand l'API arrive : POST /auth/update-profile + update password. + */ +export default function AccountScreen() { + const [profile, setProfile] = useState(null); + const [userId, setUserId] = useState(""); + + const [displayName, setDisplayName] = useState(""); + const [email, setEmail] = useState(""); + + // Champs UI (non persistés) pour mot de passe + const [password, setPassword] = useState(""); + const [password2, setPassword2] = useState(""); + + const [info, setInfo] = useState(null); + + useEffect(() => { + async function init() { + const [session, p] = await Promise.all([loadSession(), loadAccountProfile()]); + setUserId(session?.userId ?? ""); + setProfile(p); + setDisplayName(p.displayName); + setEmail(p.email); + } + init(); + }, []); + + if (!profile) { + return ( + + Chargement du compte… + + ); + } + + const handleSave = async () => { + setInfo(null); + + const normalizedEmail = email.trim().toLowerCase(); + const isValidEmail = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(normalizedEmail); + + if (!isValidEmail) { + setInfo("Email invalide."); + return; + } + + // Mot de passe : UI prête, mais pas stocké sans API + if (password || password2) { + if (password.length < 6) { + setInfo("Mot de passe trop court (min 6)."); + return; + } + if (password !== password2) { + setInfo("Les mots de passe ne correspondent pas."); + return; + } + + // Sans API : on ne peut pas réellement changer le mdp + RNAlert.alert( + "Mot de passe", + "Sans API, ce changement n’est pas appliqué (UI prête).", + [{ text: "OK" }] + ); + } + + const updated: AccountProfile = { + displayName: displayName.trim() || "Utilisateur", + email: normalizedEmail, + updatedAtMs: Date.now(), + }; + + await saveAccountProfile(updated); + setProfile(updated); + setPassword(""); + setPassword2(""); + setInfo("Profil sauvegardé ✅"); + }; + + return ( + + + Modification du compte + + + Informations + + UserId (session) + {userId || "—"} + + Nom affiché + + + Email + + + + + Mot de passe (UI prête) + + Le changement réel du mot de passe sera branché quand l’API auth sera disponible. + + + Nouveau mot de passe + + + Confirmation + + + + {!!info && {info}} + + + Sauvegarder + + + + ); +} + +const styles = StyleSheet.create({ + safeArea: { flex: 1, backgroundColor: ui.screen.backgroundColor }, + title: { fontSize: 22, fontWeight: "900", marginBottom: 12, color: "#0f172a" }, + fullButton: { flexGrow: 0, flexBasis: "auto", width: "100%", marginBottom: 10 }, + + mono: { + marginTop: 6, + fontFamily: "monospace", + color: "#0f172a", + opacity: 0.8, + }, + + input: { + borderWidth: 1, + borderColor: "#e5e7eb", + borderRadius: 10, + paddingHorizontal: 12, + paddingVertical: 10, + marginTop: 8, + backgroundColor: "#fff", + color: "#0f172a", + }, +}); \ No newline at end of file diff --git a/Wallette/mobile/src/screens/AlertsScreen.tsx b/Wallette/mobile/src/screens/AlertsScreen.tsx index dff674a..7668c21 100644 --- a/Wallette/mobile/src/screens/AlertsScreen.tsx +++ b/Wallette/mobile/src/screens/AlertsScreen.tsx @@ -1,20 +1,25 @@ import { View, Text, StyleSheet, FlatList, TouchableOpacity, Alert as RNAlert } from "react-native"; -import { useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { SafeAreaView } from "react-native-safe-area-context"; +import { ui } from "../components/ui/uiStyles"; import type { Alert } from "../types/Alert"; import { alertStore } from "../services/alertStore"; -import { ui } from "../components/ui/uiStyles"; /** - * Types locaux (évite dépendre d'exports qui ne sont pas toujours présents) + * AlertsScreen + * ----------- + * Liste des alertes reçues (Socket.IO) stockées dans alertStore. + * + * Objectifs : + * - Affichage clair des enums (CRITICAL/WARNING/INFO, BUY/SELL/HOLD/STOP_LOSS) + * - Tri : CRITICAL > WARNING > INFO, puis récent -> ancien + * - Filtre (par défaut : CRITICAL) + * - Clear avec confirmation */ -type AlertLevel = "CRITICAL" | "WARNING" | "INFO"; -type TradeDecision = "BUY" | "SELL" | "HOLD" | "STOP_LOSS"; - -type AlertFilter = "ALL" | AlertLevel; +type Filter = "CRITICAL" | "WARNING" | "INFO" | "ALL"; -function levelRank(level: AlertLevel) { +function severityRank(level: Alert["alertLevel"]): number { switch (level) { case "CRITICAL": return 3; @@ -26,19 +31,7 @@ function levelRank(level: AlertLevel) { } } -function getLevelColor(level: AlertLevel): string { - switch (level) { - case "CRITICAL": - return "#dc2626"; - case "WARNING": - return "#ca8a04"; - case "INFO": - default: - return "#2563eb"; - } -} - -function getActionColor(action: TradeDecision): string { +function actionColor(action: Alert["action"]): string { switch (action) { case "BUY": return "#16a34a"; @@ -52,74 +45,77 @@ function getActionColor(action: TradeDecision): string { } } -/** - * AlertsScreen - * ------------ - * Affiche les alertes reçues via Socket.IO (stockées dans alertStore). - * - * - Filtre optionnel (ALL / CRITICAL / WARNING / INFO) - * - Par défaut : ALL avec tri CRITICAL > WARNING > INFO puis plus récent - * - Bouton Clear avec confirmation - */ +function levelColor(level: Alert["alertLevel"]): 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 AlertsScreen() { - const [alerts, setAlerts] = useState([]); - const [filter, setFilter] = useState("ALL"); + const [items, setItems] = useState([]); + const [filter, setFilter] = useState("CRITICAL"); - // abonnement au store (temps réel) useEffect(() => { - const unsub = alertStore.subscribe(setAlerts); + // Load initial + setItems(alertStore.getAll?.() ?? []); + + // Live updates if subscribe exists + const unsub = alertStore.subscribe?.((all: Alert[]) => { + setItems(all); + }); + return () => { - unsub(); + if (typeof unsub === "function") unsub(); }; }, []); - const filteredAndSorted = useMemo(() => { - const filtered = - filter === "ALL" - ? alerts - : alerts.filter((a) => (a.alertLevel as AlertLevel) === filter); - - return [...filtered].sort((a, b) => { - const la = a.alertLevel as AlertLevel; - const lb = b.alertLevel as AlertLevel; + const filteredSorted = useMemo(() => { + const base = + filter === "ALL" ? items : items.filter((a) => a.alertLevel === filter); - // tri par niveau d'alerte (desc) - const byLevel = levelRank(lb) - levelRank(la); - if (byLevel !== 0) return byLevel; - - // tri par date (desc) si dispo - const ta = a.timestamp ?? 0; - const tb = b.timestamp ?? 0; - return tb - ta; + return [...base].sort((a, b) => { + // 1) severity + const r = severityRank(b.alertLevel) - severityRank(a.alertLevel); + if (r !== 0) return r; + // 2) timestamp desc + return (b.timestamp ?? 0) - (a.timestamp ?? 0); }); - }, [alerts, filter]); - - const confirmClear = () => { - if (alerts.length === 0) return; + }, [items, filter]); + const handleClear = useCallback(() => { RNAlert.alert( "Supprimer les alertes ?", - "Cette action efface la liste locale des alertes reçues (cela ne touche pas la base de données).", + "Cette action supprime les alertes stockées localement (alertStore).", [ { text: "Annuler", style: "cancel" }, { text: "Supprimer", style: "destructive", - onPress: () => alertStore.clear(), + onPress: () => { + alertStore.clear?.(); + setItems(alertStore.getAll?.() ?? []); + }, }, ] ); - }; + }, []); - const FilterButton = ({ value, label }: { value: AlertFilter; label: string }) => { + const FilterButton = ({ value, label }: { value: Filter; label: string }) => { const active = filter === value; return ( setFilter(value)} + style={[styles.filterBtn, active && styles.filterBtnActive]} > {label} @@ -132,67 +128,57 @@ export default function AlertsScreen() { `alert-${idx}`} + data={filteredSorted} + keyExtractor={(it, idx) => + it.id ?? + `${it.timestamp}-${it.pair}-${it.action}-${it.alertLevel}-${idx}` + } ListHeaderComponent={ - + - Alertes + Alertes - + Clear - - Filtre : {filter === "ALL" ? "Toutes" : filter} — Total : {alerts.length} - - - - - + + + - - Tri par défaut : CRITICAL > WARNING > INFO, puis plus récent. + + Tri : CRITICAL → WARNING → INFO, puis récent → ancien. } ListEmptyComponent={ Aucune alerte - En attente d’alertes Socket.IO… + Aucune alerte reçue pour ce filtre. } renderItem={({ item }) => { - const lvl = item.alertLevel as AlertLevel; - const act = item.action as TradeDecision; - - const lvlColor = getLevelColor(lvl); - const actColor = getActionColor(act); + const aColor = actionColor(item.action); + const lColor = levelColor(item.alertLevel); return ( {item.pair} - - {item.timestamp ? new Date(item.timestamp).toLocaleString() : "—"} - + {formatDate(item.timestamp)} - - - {lvl} + + + {item.action} - - {act} + + {item.alertLevel} @@ -201,14 +187,13 @@ export default function AlertsScreen() { {(item.confidence * 100).toFixed(0)}% - {typeof item.price === "number" && ( - - Prix - {item.price.toFixed(2)} - - )} - {item.reason} + + {!!item.price && ( + + Prix (optionnel) : {item.price.toFixed(2)} + + )} ); }} @@ -223,6 +208,12 @@ const styles = StyleSheet.create({ backgroundColor: ui.screen.backgroundColor, }, + screenTitle: { + fontSize: 22, + fontWeight: "900", + color: "#0f172a", + }, + headerRow: { flexDirection: "row", justifyContent: "space-between", @@ -237,9 +228,7 @@ const styles = StyleSheet.create({ borderColor: "#e5e7eb", backgroundColor: "#fff", }, - clearBtnDisabled: { - opacity: 0.5, - }, + clearBtnText: { fontWeight: "900", color: "#dc2626", @@ -249,27 +238,34 @@ const styles = StyleSheet.create({ flexDirection: "row", flexWrap: "wrap", gap: 8, - marginTop: 10, + marginTop: 12, + marginBottom: 10, }, + filterBtn: { paddingHorizontal: 12, paddingVertical: 8, borderRadius: 999, borderWidth: 1, - }, - filterBtnInactive: { borderColor: "#e5e7eb", backgroundColor: "#fff", }, filterBtnActive: { - borderColor: "#14532D", - backgroundColor: "#14532D22", + borderColor: "#0f172a", }, filterText: { fontWeight: "900", color: "#0f172a", + opacity: 0.7, }, filterTextActive: { - color: "#14532D", + opacity: 1, + }, + + badgesRow: { + flexDirection: "row", + gap: 8, + marginTop: 10, + flexWrap: "wrap", }, }); \ No newline at end of file diff --git a/Wallette/mobile/src/screens/AuthScreen.tsx b/Wallette/mobile/src/screens/AuthScreen.tsx new file mode 100644 index 0000000..8e15dac --- /dev/null +++ b/Wallette/mobile/src/screens/AuthScreen.tsx @@ -0,0 +1,112 @@ +import { View, Text, StyleSheet, TextInput, TouchableOpacity } from "react-native"; +import { useMemo, useState } from "react"; +import { SafeAreaView } from "react-native-safe-area-context"; + +import { ui } from "../components/ui/uiStyles"; +import { saveSession } from "../utils/sessionStorage"; + +/** + * AuthScreen (Step 4 - sans API) + * ------------------------------ + * Simule un login local : + * - Email -> userId stable + * - Session stockée en AsyncStorage + * + * Plus tard : on remplace par une auth REST réelle. + */ +export default function AuthScreen({ onAuthenticated }: { onAuthenticated: () => void }) { + const [email, setEmail] = useState("demo@example.com"); + const [error, setError] = useState(null); + + const normalizedEmail = useMemo(() => email.trim().toLowerCase(), [email]); + + const isValidEmail = useMemo(() => { + return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(normalizedEmail); + }, [normalizedEmail]); + + const makeUserId = (mail: string) => `user_${mail.replace(/[^a-z0-9]/g, "_")}`; + + const handleLogin = async () => { + setError(null); + + if (!isValidEmail) { + setError("Email invalide."); + return; + } + + const userId = makeUserId(normalizedEmail); + + await saveSession({ + userId, + email: normalizedEmail, + createdAtMs: Date.now(), + }); + + onAuthenticated(); + }; + + return ( + + + Connexion + + + Email + + + + {!!error && {error}} + + + Se connecter + + + + Step 4 (sans API) : session locale. Plus tard, auth réelle via serveur. + + + + + ); +} + +const styles = StyleSheet.create({ + safeArea: { + flex: 1, + backgroundColor: ui.screen.backgroundColor, + }, + title: { + fontSize: 22, + fontWeight: "900", + marginBottom: 12, + color: "#0f172a", + }, + input: { + borderWidth: 1, + borderColor: "#e5e7eb", + borderRadius: 10, + paddingHorizontal: 12, + paddingVertical: 10, + marginTop: 8, + backgroundColor: "#fff", + color: "#0f172a", + }, + fullButton: { + flexGrow: 0, + flexBasis: "auto", + width: "100%", + marginTop: 12, + }, + errorText: { + marginTop: 10, + color: "#dc2626", + fontWeight: "900", + }, +}); \ No newline at end of file diff --git a/Wallette/mobile/src/screens/DashboardScreen.tsx b/Wallette/mobile/src/screens/DashboardScreen.tsx index 8bd123a..d97a3e6 100644 --- a/Wallette/mobile/src/screens/DashboardScreen.tsx +++ b/Wallette/mobile/src/screens/DashboardScreen.tsx @@ -13,6 +13,7 @@ import { Ionicons } from "@expo/vector-icons"; import type { DashboardSummary } from "../types/DashboardSummary"; import { fetchDashboardSummary } from "../services/dashboardService"; + import { loadSettings } from "../utils/settingsStorage"; import type { UserSettings } from "../models/UserSettings"; @@ -24,18 +25,18 @@ import type { Alert } from "../types/Alert"; import { alertStore } from "../services/alertStore"; import { showAlertNotification } from "../services/notificationService"; -import { loadWallet } from "../utils/walletStorage"; -import type { WalletState } from "../models/Wallet"; +import { loadPortfolio } from "../utils/portfolioStorage"; +import type { PortfolioState, PortfolioAsset } from "../models/Portfolio"; +import { getMockPrice } from "../mocks/prices.mock"; + +import { loadSession } from "../utils/sessionStorage"; /** - * DashboardScreen (WF-01) — Responsive + No-scroll goal - * ---------------------------------------------------- - * - Cartes cliquables (chevron subtil) : - * * Portefeuille -> Wallet - * * Urgence -> Alertes - * * Prix BTC -> Historique - * - Conseiller : bouton -> Stratégie - * - Socket.IO non bloquant + notifications locales + * 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 */ export default function DashboardScreen() { const { height } = useWindowDimensions(); @@ -43,7 +44,7 @@ export default function DashboardScreen() { const [summary, setSummary] = useState(null); const [settings, setSettings] = useState(null); - const [wallet, setWallet] = useState(null); + const [portfolio, setPortfolio] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -67,17 +68,17 @@ export default function DashboardScreen() { setError(null); setLoading(true); - const [dashboardData, userSettings, walletData] = await Promise.all([ + const [dashboardData, userSettings, portfolioData] = await Promise.all([ fetchDashboardSummary(), loadSettings(), - loadWallet(), + loadPortfolio(), ]); if (!isActive) return; setSummary(dashboardData); setSettings(userSettings); - setWallet(walletData); + setPortfolio(portfolioData); setLastRefreshMs(Date.now()); } catch { if (isActive) setError("Impossible de charger le dashboard."); @@ -132,41 +133,62 @@ export default function DashboardScreen() { } }; + /** + * Socket.IO (non bloquant) + * - userId = session.userId + */ useEffect(() => { - if (!settings) return; + let unsub: null | (() => void) = null; + let active = true; - setSocketError(null); + async function initSocket() { + if (!settings) return; - const userId = "test-user"; + setSocketError(null); - try { - socketService.connect(SERVER_URL, userId); - setSocketConnected(true); - } catch { - setSocketConnected(false); - setSocketError("Socket : paramètres invalides (URL ou userId)."); - return; - } + const session = await loadSession(); + const userId = session?.userId; - const unsubscribeAlert = socketService.onAlert((alert) => { - alertStore.add(alert); - setLiveAlerts((prev) => [alert, ...prev].slice(0, 100)); - - if (settings.notificationsEnabled) { - void (async () => { - try { - await showAlertNotification(alert); - } catch (e) { - console.log("⚠️ Notification error:", e); - } - })(); + if (!userId) { + setSocketConnected(false); + setSocketError("Socket désactivé : session absente."); + return; } - }); - socketService.ping(); + try { + socketService.connect(SERVER_URL, userId); + if (!active) return; + setSocketConnected(true); + } catch { + if (!active) return; + setSocketConnected(false); + setSocketError("Socket : paramètres invalides (URL ou userId)."); + return; + } + + unsub = socketService.onAlert((alert) => { + alertStore.add(alert); + setLiveAlerts((prev) => [alert, ...prev].slice(0, 100)); + + if (settings.notificationsEnabled) { + void (async () => { + try { + await showAlertNotification(alert); + } catch (e) { + console.log("⚠️ Notification error:", e); + } + })(); + } + }); + + socketService.ping(); + } + + void initSocket(); return () => { - unsubscribeAlert(); + active = false; + if (unsub) unsub(); socketService.disconnect(); setSocketConnected(false); }; @@ -184,10 +206,32 @@ export default function DashboardScreen() { return liveAlerts[0]; }, [liveAlerts]); - const walletTotalValue = useMemo(() => { - if (!wallet || !summary) return null; - return wallet.quantity * summary.price; - }, [wallet, summary]); + const portfolioTotalValue = useMemo(() => { + if (!portfolio) return 0; + + return portfolio.assets.reduce((sum, a) => { + const price = getMockPrice(a.symbol); + if (price === null) return sum; + return sum + a.quantity * price; + }, 0); + }, [portfolio]); + + const topAssets: PortfolioAsset[] = useMemo(() => { + if (!portfolio) return []; + + const withValue = portfolio.assets.map((a) => { + const price = getMockPrice(a.symbol) ?? 0; + return { ...a, _value: a.quantity * price }; + }); + + withValue.sort((a, b) => (b as any)._value - (a as any)._value); + return withValue.slice(0, 3).map(({ symbol, quantity }) => ({ symbol, quantity })); + }, [portfolio]); + + const remainingCount = useMemo(() => { + if (!portfolio) return 0; + return Math.max(0, portfolio.assets.length - topAssets.length); + }, [portfolio, topAssets]); if (loading) { return ( @@ -205,7 +249,7 @@ export default function DashboardScreen() { ); } - if (!summary || !settings || !wallet) { + if (!summary || !settings || !portfolio) { return ( Initialisation… @@ -214,7 +258,12 @@ export default function DashboardScreen() { } const Chevron = () => ( - + ); return ( @@ -253,7 +302,7 @@ export default function DashboardScreen() { - {/* 2) PORTEFEUILLE — cliquable => Wallet */} + {/* 2) PORTEFEUILLE */} navigation.navigate("Wallet" as never)}> @@ -262,28 +311,44 @@ export default function DashboardScreen() { - Quantité BTC : - {wallet.quantity.toFixed(6)} BTC - - - - Valeur Totale : + Valeur globale : - {walletTotalValue !== null ? `${walletTotalValue.toFixed(2)} ${settings.currency}` : "—"} + {portfolioTotalValue.toFixed(2)} {settings.currency} - - BTC @ {summary.price.toFixed(2)} {settings.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) + + )} + + )} + + + BTC @ {summary.price.toFixed(2)} {settings.currency} (mock) - {/* 3) URGENCE — cliquable => Alertes */} + {/* 3) URGENCE */} navigation.navigate("Alerts" as never)}> - Urgence + Alertes @@ -307,7 +372,7 @@ export default function DashboardScreen() { - {/* 4) PRIX BTC — cliquable => Historique */} + {/* 4) PRIX BTC */} navigation.navigate("History" as never)}> @@ -317,7 +382,11 @@ export default function DashboardScreen() { @@ -404,7 +473,6 @@ const styles = StyleSheet.create({ marginTop: 10, }, - // ✅ header row + chevron icon headerRow: { flexDirection: "row", justifyContent: "space-between", diff --git a/Wallette/mobile/src/screens/SettingsScreen.tsx b/Wallette/mobile/src/screens/SettingsScreen.tsx index 24c9e77..4f753fb 100644 --- a/Wallette/mobile/src/screens/SettingsScreen.tsx +++ b/Wallette/mobile/src/screens/SettingsScreen.tsx @@ -6,16 +6,26 @@ import { loadSettings, saveSettings } from "../utils/settingsStorage"; import type { UserSettings } from "../models/UserSettings"; import { requestNotificationPermission } from "../services/notificationService"; +import { clearSession, loadSession } from "../utils/sessionStorage"; + import { ui } from "../components/ui/uiStyles"; -export default function SettingsScreen() { +/** + * SettingsScreen (Step 4) + * ---------------------- + * Paramètres stockés par userId (via settingsStorage). + * Ajout : bouton Déconnexion. + */ +export default function SettingsScreen({ onLogout }: { onLogout?: () => void }) { const [settings, setSettings] = useState(null); const [infoMessage, setInfoMessage] = useState(null); + const [sessionLabel, setSessionLabel] = useState(""); useEffect(() => { async function init() { - const data = await loadSettings(); + const [data, session] = await Promise.all([loadSettings(), loadSession()]); setSettings(data); + setSessionLabel(session?.email ?? "—"); } init(); }, []); @@ -63,11 +73,26 @@ export default function SettingsScreen() { setInfoMessage("Paramètres sauvegardés ✅"); }; + const handleLogout = async () => { + await clearSession(); + onLogout?.(); // App.tsx repasse sur Auth + }; + return ( Paramètres + {/* Carte session */} + + Compte + Connecté : {sessionLabel} + + + Se déconnecter + + + {/* Carte : Devise */} Devise @@ -91,9 +116,7 @@ export default function SettingsScreen() { {/* Carte : Notifications */} Notifications - - Statut : {settings.notificationsEnabled ? "ON" : "OFF"} - + Statut : {settings.notificationsEnabled ? "ON" : "OFF"} @@ -101,7 +124,7 @@ export default function SettingsScreen() { - {!!infoMessage && {infoMessage}} + {!!infoMessage && {infoMessage}} {/* Bouton Save */} @@ -124,25 +147,33 @@ const styles = StyleSheet.create({ marginBottom: 12, color: "#0f172a", }, - infoText: { - marginTop: 10, - opacity: 0.8, + boldInline: { + fontWeight: "900", + color: "#0f172a", }, - /** - * fullButton - * ---------- - * On neutralise les propriétés "grille" de ui.button (flexGrow/flexBasis), - * car elles sont utiles dans ActionsCard, mais pas dans Settings. - */ fullButton: { flexGrow: 0, flexBasis: "auto", width: "100%", marginTop: 10, }, - saveButton: { + marginTop: 4, marginBottom: 10, }, + + secondaryButton: { + marginTop: 10, + paddingVertical: 10, + borderRadius: 10, + borderWidth: 1, + borderColor: "#e5e7eb", + alignItems: "center", + backgroundColor: "#fff", + }, + secondaryButtonText: { + fontWeight: "900", + color: "#dc2626", + }, }); \ No newline at end of file diff --git a/Wallette/mobile/src/screens/WalletScreen.tsx b/Wallette/mobile/src/screens/WalletScreen.tsx index 0768379..0368f01 100644 --- a/Wallette/mobile/src/screens/WalletScreen.tsx +++ b/Wallette/mobile/src/screens/WalletScreen.tsx @@ -1,57 +1,55 @@ -import { View, Text, StyleSheet, TouchableOpacity, TextInput, Alert as RNAlert } from "react-native"; -import { useMemo, useState } from "react"; +import { + View, + Text, + StyleSheet, + TouchableOpacity, + TextInput, + Alert as RNAlert, + FlatList, +} from "react-native"; +import { useCallback, useMemo, useState } from "react"; import { SafeAreaView } from "react-native-safe-area-context"; import { useFocusEffect } from "@react-navigation/native"; -import { useCallback } from "react"; import { ui } from "../components/ui/uiStyles"; -import { loadWallet, saveWallet, clearWallet } from "../utils/walletStorage"; -import type { WalletState } from "../models/Wallet"; -import { fetchDashboardSummary } from "../services/dashboardService"; +import type { PortfolioAsset, PortfolioState } from "../models/Portfolio"; +import { loadPortfolio, savePortfolio, clearPortfolio } from "../utils/portfolioStorage"; +import { getMockPrice } from "../mocks/prices.mock"; import { loadSettings } from "../utils/settingsStorage"; import type { UserSettings } from "../models/UserSettings"; /** - * WalletScreen (WF-03 Step 1) - * --------------------------- - * Mono-utilisateur / mono-crypto : BTC uniquement. - * - L'utilisateur encode la quantité de BTC qu'il possède - * - On calcule la valeur estimée via le prix BTC du dashboard - * - Stockage local (AsyncStorage) pour ne pas dépendre de l'API + * WalletScreen (Step 3) + * --------------------- + * Mono-user / multi-cryptos + * - liste d'assets (BTC, ETH, SOL...) + * - quantité par asset + * - valeur globale du portefeuille + * + * Aujourd'hui : prix mock + * Demain : prix via GET /api/price/current?pair=XXX/EUR */ export default function WalletScreen() { - const [wallet, setWallet] = useState(null); + const [portfolio, setPortfolio] = useState(null); const [settings, setSettings] = useState(null); - // Prix BTC actuel (mock aujourd'hui, API demain) - const [btcPrice, setBtcPrice] = useState(null); - - // input texte (évite les bugs de virgule/points) + // Ajout asset + const [symbolInput, setSymbolInput] = useState("BTC"); const [qtyInput, setQtyInput] = useState("0"); const [info, setInfo] = useState(null); - // Recharge quand l’écran reprend le focus (retour depuis autre page) useFocusEffect( useCallback(() => { let active = true; async function init() { setInfo(null); - - const [w, s, dash] = await Promise.all([ - loadWallet(), - loadSettings(), - fetchDashboardSummary(), - ]); - + const [p, s] = await Promise.all([loadPortfolio(), loadSettings()]); if (!active) return; - setWallet(w); + setPortfolio(p); setSettings(s); - setBtcPrice(dash.price); - - setQtyInput(String(w.quantity)); } init(); @@ -62,6 +60,11 @@ export default function WalletScreen() { }, []) ); + const lastUpdatedLabel = useMemo(() => { + if (!portfolio) return "—"; + return new Date(portfolio.updatedAtMs).toLocaleString(); + }, [portfolio]); + const parsedQty = useMemo(() => { const normalized = qtyInput.replace(",", ".").trim(); const val = Number(normalized); @@ -70,49 +73,101 @@ export default function WalletScreen() { return val; }, [qtyInput]); + const normalizedSymbol = useMemo(() => symbolInput.toUpperCase().trim(), [symbolInput]); + const totalValue = useMemo(() => { - if (parsedQty === null || btcPrice === null) return null; - return parsedQty * btcPrice; - }, [parsedQty, btcPrice]); + if (!portfolio) return 0; - const lastUpdatedLabel = useMemo(() => { - if (!wallet) return "—"; - return new Date(wallet.updatedAtMs).toLocaleString(); - }, [wallet]); + return portfolio.assets.reduce((sum, a) => { + const price = getMockPrice(a.symbol); + if (price === null) return sum; + return sum + a.quantity * price; + }, 0); + }, [portfolio]); - const handleSave = async () => { - if (!wallet) return; + const handleAddOrUpdate = async () => { + if (!portfolio) return; + + setInfo(null); + + if (!normalizedSymbol || normalizedSymbol.length < 2) { + setInfo("Symbole invalide (ex: BTC)."); + return; + } if (parsedQty === null) { setInfo("Quantité invalide. Exemple : 0.25"); return; } - const updated: WalletState = { - ...wallet, - quantity: parsedQty, + const price = getMockPrice(normalizedSymbol); + if (price === null) { + setInfo("Prix inconnu (mock). Essayez: BTC, ETH, SOL, ADA."); + return; + } + + const existingIndex = portfolio.assets.findIndex((a) => a.symbol === normalizedSymbol); + + let updatedAssets: PortfolioAsset[]; + if (existingIndex >= 0) { + // update + updatedAssets = portfolio.assets.map((a) => + a.symbol === normalizedSymbol ? { ...a, quantity: parsedQty } : a + ); + } else { + // add + updatedAssets = [...portfolio.assets, { symbol: normalizedSymbol, quantity: parsedQty }]; + } + + const updated: PortfolioState = { + assets: updatedAssets, updatedAtMs: Date.now(), }; - await saveWallet(updated); - setWallet(updated); - setInfo("Portefeuille sauvegardé ✅"); + await savePortfolio(updated); + setPortfolio(updated); + + setInfo(existingIndex >= 0 ? "Asset mis à jour ✅" : "Asset ajouté ✅"); + }; + + const handleDelete = (symbol: string) => { + if (!portfolio) return; + + RNAlert.alert( + `Supprimer ${symbol} ?`, + "Cette action retire l’asset du portefeuille local.", + [ + { text: "Annuler", style: "cancel" }, + { + text: "Supprimer", + style: "destructive", + onPress: async () => { + const updated: PortfolioState = { + assets: portfolio.assets.filter((a) => a.symbol !== symbol), + updatedAtMs: Date.now(), + }; + await savePortfolio(updated); + setPortfolio(updated); + setInfo(`${symbol} supprimé ✅`); + }, + }, + ] + ); }; const handleClear = () => { RNAlert.alert( "Réinitialiser le portefeuille ?", - "Cela remet la quantité BTC à 0 (stockage local).", + "Cela supprime tous les assets du stockage local.", [ { text: "Annuler", style: "cancel" }, { text: "Réinitialiser", style: "destructive", onPress: async () => { - await clearWallet(); - const fresh = await loadWallet(); - setWallet(fresh); - setQtyInput("0"); + await clearPortfolio(); + const fresh = await loadPortfolio(); + setPortfolio(fresh); setInfo("Portefeuille réinitialisé ✅"); }, }, @@ -120,7 +175,7 @@ export default function WalletScreen() { ); }; - if (!wallet || !settings) { + if (!portfolio || !settings) { return ( Chargement du portefeuille… @@ -130,62 +185,113 @@ export default function WalletScreen() { return ( - - Portefeuille - - {/* Carte BTC */} - - BTC - - Quantité détenue - - - - - Prix BTC actuel :{" "} - - {btcPrice !== null ? `${btcPrice.toFixed(2)} ${settings.currency}` : "—"} + it.symbol} + ListHeaderComponent={ + + Portefeuille + + {/* Résumé global */} + + Résumé + + + Valeur globale : + + {totalValue.toFixed(2)} {settings.currency} + + + + + Dernière mise à jour :{" "} + {lastUpdatedLabel} + + + + Réinitialiser + + + + {/* Ajouter / modifier */} + + Ajouter / Modifier un asset + + Symbole (ex: BTC, ETH, SOL, ADA) + + + Quantité + + + + Enregistrer + + + {!!info && {info}} + + Prix utilisés = mock pour Step 3 (API à brancher plus tard). + + + + + Liste des assets : - - - - Valeur estimée :{" "} - - {totalValue !== null ? `${totalValue.toFixed(2)} ${settings.currency}` : "—"} - - - - {/* ✅ Dernière mise à jour */} - - Dernière mise à jour :{" "} - {lastUpdatedLabel} - - - - Enregistrer - - - - Réinitialiser - - - {!!info && {info}} - - - {/* Carte info Step */} - - Step 1 - - Mono-utilisateur / mono-crypto (BTC). Step 3 : portefeuille multi-cryptos + valeur globale. - - - + + } + ListEmptyComponent={ + + Aucun asset + Ajoutez BTC/ETH/SOL… pour commencer. + + } + renderItem={({ item }) => { + const price = getMockPrice(item.symbol); + const value = price !== null ? item.quantity * price : null; + + return ( + + + {item.symbol} + handleDelete(item.symbol)}> + Supprimer + + + + + Quantité + {item.quantity.toFixed(6)} + + + + Prix (mock) + + {price !== null ? `${price.toFixed(2)} ${settings.currency}` : "—"} + + + + + Valeur + + {value !== null ? `${value.toFixed(2)} ${settings.currency}` : "—"} + + + + ); + }} + /> ); } @@ -237,4 +343,9 @@ const styles = StyleSheet.create({ fontWeight: "900", color: "#dc2626", }, + + deleteText: { + fontWeight: "900", + color: "#dc2626", + }, }); \ No newline at end of file diff --git a/Wallette/mobile/src/types/Alert.ts b/Wallette/mobile/src/types/Alert.ts index a59d4b4..e747535 100644 --- a/Wallette/mobile/src/types/Alert.ts +++ b/Wallette/mobile/src/types/Alert.ts @@ -1,9 +1,24 @@ +/** + * Alert + * ----- + * Contrat mobile (Socket.IO / API). + * On garde des enums clairs comme demandé : + * - alertLevel : CRITICAL / WARNING / INFO + * - action : BUY / SELL / HOLD / STOP_LOSS + * + * id est optionnel : utile pour React (keyExtractor), + * mais le serveur peut ne pas l'envoyer au début. + */ export interface Alert { - action: "BUY" | "SELL" | "HOLD"; + id?: string; + + action: "BUY" | "SELL" | "HOLD" | "STOP_LOSS"; pair: string; // ex: "BTC/EUR" confidence: number; // 0..1 reason: string; + alertLevel: "INFO" | "WARNING" | "CRITICAL"; timestamp: number; // Unix ms + price?: number; } \ No newline at end of file diff --git a/Wallette/mobile/src/utils/accountStorage.ts b/Wallette/mobile/src/utils/accountStorage.ts new file mode 100644 index 0000000..cf15063 --- /dev/null +++ b/Wallette/mobile/src/utils/accountStorage.ts @@ -0,0 +1,50 @@ +import AsyncStorage from "@react-native-async-storage/async-storage"; +import type { AccountProfile } from "../models/AccountProfile"; +import { loadSession } from "./sessionStorage"; + +function keyFor(userId: string) { + return `accountProfile:${userId}`; +} + +const DEFAULT_PROFILE: AccountProfile = { + displayName: "Utilisateur", + email: "demo@example.com", + updatedAtMs: Date.now(), +}; + +export async function loadAccountProfile(): Promise { + const session = await loadSession(); + if (!session) return DEFAULT_PROFILE; + + const raw = await AsyncStorage.getItem(keyFor(session.userId)); + if (!raw) { + // Par défaut, on reprend l’email de session + return { ...DEFAULT_PROFILE, email: session.email }; + } + + try { + const parsed = JSON.parse(raw) as Partial; + return { + ...DEFAULT_PROFILE, + ...parsed, + email: String(parsed.email ?? session.email), + displayName: String(parsed.displayName ?? DEFAULT_PROFILE.displayName), + updatedAtMs: Number(parsed.updatedAtMs ?? Date.now()), + }; + } catch { + return { ...DEFAULT_PROFILE, email: session.email }; + } +} + +export async function saveAccountProfile(profile: AccountProfile): Promise { + const session = await loadSession(); + if (!session) return; + + const safe: AccountProfile = { + displayName: profile.displayName.trim() || "Utilisateur", + email: profile.email.trim().toLowerCase() || session.email, + updatedAtMs: Date.now(), + }; + + await AsyncStorage.setItem(keyFor(session.userId), JSON.stringify(safe)); +} \ No newline at end of file diff --git a/Wallette/mobile/src/utils/portfolioStorage.ts b/Wallette/mobile/src/utils/portfolioStorage.ts new file mode 100644 index 0000000..8774d15 --- /dev/null +++ b/Wallette/mobile/src/utils/portfolioStorage.ts @@ -0,0 +1,53 @@ +import AsyncStorage from "@react-native-async-storage/async-storage"; +import type { PortfolioState } from "../models/Portfolio"; +import { loadSession } from "./sessionStorage"; + +/** + * KEY devient "portfolio:" + */ +function keyFor(userId: string) { + return `portfolio:${userId}`; +} + +const DEFAULT_PORTFOLIO: PortfolioState = { + assets: [], + updatedAtMs: Date.now(), +}; + +export async function loadPortfolio(): Promise { + const session = await loadSession(); + if (!session) return DEFAULT_PORTFOLIO; + + const raw = await AsyncStorage.getItem(keyFor(session.userId)); + if (!raw) return DEFAULT_PORTFOLIO; + + try { + const parsed = JSON.parse(raw) as Partial; + return { + ...DEFAULT_PORTFOLIO, + ...parsed, + assets: Array.isArray(parsed.assets) ? parsed.assets : [], + }; + } catch { + return DEFAULT_PORTFOLIO; + } +} + +export async function savePortfolio(portfolio: PortfolioState): Promise { + const session = await loadSession(); + if (!session) return; + + const safe: PortfolioState = { + assets: Array.isArray(portfolio.assets) ? portfolio.assets : [], + updatedAtMs: Date.now(), + }; + + await AsyncStorage.setItem(keyFor(session.userId), JSON.stringify(safe)); +} + +export async function clearPortfolio(): Promise { + const session = await loadSession(); + if (!session) return; + + await AsyncStorage.removeItem(keyFor(session.userId)); +} \ No newline at end of file diff --git a/Wallette/mobile/src/utils/sessionStorage.ts b/Wallette/mobile/src/utils/sessionStorage.ts new file mode 100644 index 0000000..0c756c8 --- /dev/null +++ b/Wallette/mobile/src/utils/sessionStorage.ts @@ -0,0 +1,45 @@ +import AsyncStorage from "@react-native-async-storage/async-storage"; + +export type Session = { + userId: string; + email: string; + createdAtMs: number; +}; + +const KEY = "session"; + +/** + * Charge la session courante (si l'utilisateur est "connecté"). + */ +export async function loadSession(): Promise { + const raw = await AsyncStorage.getItem(KEY); + if (!raw) return null; + + try { + const parsed = JSON.parse(raw) as Partial; + if (!parsed.userId || !parsed.email) return null; + + return { + userId: String(parsed.userId), + email: String(parsed.email), + createdAtMs: Number(parsed.createdAtMs ?? Date.now()), + }; + } catch { + return null; + } +} + +/** + * Sauvegarde une session. + * Ici c'est un login "local" (sans API). + */ +export async function saveSession(session: Session): Promise { + await AsyncStorage.setItem(KEY, JSON.stringify(session)); +} + +/** + * Déconnexion. + */ +export async function clearSession(): Promise { + await AsyncStorage.removeItem(KEY); +} \ No newline at end of file diff --git a/Wallette/mobile/src/utils/settingsStorage.ts b/Wallette/mobile/src/utils/settingsStorage.ts index 07570d7..1fe6b39 100644 --- a/Wallette/mobile/src/utils/settingsStorage.ts +++ b/Wallette/mobile/src/utils/settingsStorage.ts @@ -1,26 +1,28 @@ import AsyncStorage from "@react-native-async-storage/async-storage"; import type { UserSettings } from "../models/UserSettings"; - -const KEY = "userSettings"; +import { loadSession } from "./sessionStorage"; /** - * Settings par défaut - * ------------------- - * On les fusionne avec ce qui est en storage. + * KEY devient "userSettings:" */ +function keyFor(userId: string) { + return `userSettings:${userId}`; +} + const DEFAULT_SETTINGS: UserSettings = { currency: "EUR", favoriteSymbol: "BTC", alertPreference: "critical", refreshMode: "manual", notificationsEnabled: false, - - // ✅ stratégie par défaut selectedStrategyKey: "RSI_SIMPLE", }; export async function loadSettings(): Promise { - const raw = await AsyncStorage.getItem(KEY); + const session = await loadSession(); + if (!session) return DEFAULT_SETTINGS; // sécurité (normalement, App bloque sans session) + + const raw = await AsyncStorage.getItem(keyFor(session.userId)); if (!raw) return DEFAULT_SETTINGS; try { @@ -32,5 +34,8 @@ export async function loadSettings(): Promise { } export async function saveSettings(settings: UserSettings): Promise { - await AsyncStorage.setItem(KEY, JSON.stringify(settings)); + const session = await loadSession(); + if (!session) return; + + await AsyncStorage.setItem(keyFor(session.userId), JSON.stringify(settings)); } \ No newline at end of file -- 2.50.1