--- /dev/null
+> 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.
--- /dev/null
+{
+ "devices": []
+}
-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";
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;
Alerts: undefined;
Strategy: undefined;
Wallet: undefined;
+ Account: undefined;
+ About: undefined;
};
const Stack = createNativeStackNavigator<RootStackParamList>();
+// ✅ navigationRef (permet de naviguer hors des screens, ex: depuis un Modal)
+export const navigationRef = createNavigationContainerRef<RootStackParamList>();
+
export default function App() {
+ const [ready, setReady] = useState(false);
+ const [isAuthed, setIsAuthed] = useState(false);
+
+ const [menuVisible, setMenuVisible] = useState(false);
+ const [sessionEmail, setSessionEmail] = useState<string>("—");
+
+ 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 (
+ <View style={{ flex: 1, justifyContent: "center", alignItems: "center" }}>
+ <Text>Initialisation…</Text>
+ </View>
+ );
+ }
+
+ // Pas connecté -> écran Auth
+ if (!isAuthed) {
+ return (
+ <AuthScreen
+ onAuthenticated={async () => {
+ const s = await loadSession();
+ setSessionEmail(s?.email ?? "—");
+ setIsAuthed(true);
+ }}
+ />
+ );
+ }
+
return (
- <NavigationContainer>
+ <NavigationContainer ref={navigationRef}>
+ {/* ✅ Modal menu Compte (navigue grâce au navigationRef) */}
+ <AccountMenu
+ visible={menuVisible}
+ email={sessionEmail}
+ onClose={() => setMenuVisible(false)}
+ onGoAccount={() => go("Account")}
+ onGoAbout={() => go("About")}
+ onLogout={doLogout}
+ />
+
<Stack.Navigator id="MainStack" initialRouteName="Dashboard">
<Stack.Screen
name="Dashboard"
options={({ navigation }) => ({
title: "Dashboard",
headerRight: () => (
- <TouchableOpacity onPress={() => navigation.navigate("Settings")}>
- <Ionicons name="settings-outline" size={22} color="#0f172a" />
- </TouchableOpacity>
+ <View style={{ flexDirection: "row", gap: 12 }}>
+ {/* 👤 Menu Compte */}
+ <TouchableOpacity onPress={() => setMenuVisible(true)}>
+ <Ionicons name="person-circle-outline" size={24} color="#0f172a" />
+ </TouchableOpacity>
+
+ {/* ⚙️ Paramètres */}
+ <TouchableOpacity onPress={() => navigation.navigate("Settings")}>
+ <Ionicons name="settings-outline" size={22} color="#0f172a" />
+ </TouchableOpacity>
+ </View>
),
})}
/>
- <Stack.Screen
- name="Wallet"
- component={WalletScreen}
- options={{ title: "Portefeuille" }}
- />
+ <Stack.Screen name="Wallet" component={WalletScreen} options={{ title: "Portefeuille" }} />
+ <Stack.Screen name="Strategy" component={StrategyScreen} options={{ title: "Stratégie" }} />
+ <Stack.Screen name="Alerts" component={AlertsScreen} options={{ title: "Alertes" }} />
+ <Stack.Screen name="History" component={HistoryScreen} options={{ title: "Historique" }} />
- <Stack.Screen
- name="Strategy"
- component={StrategyScreen}
- options={{ title: "Stratégie" }}
- />
+ <Stack.Screen name="Settings" component={SettingsScreen} options={{ title: "Paramètres" }} />
- <Stack.Screen
- name="Alerts"
- component={AlertsScreen}
- options={{ title: "Alertes" }}
- />
-
- <Stack.Screen
- name="History"
- component={HistoryScreen}
- options={{ title: "Historique" }}
- />
-
- <Stack.Screen
- name="Settings"
- component={SettingsScreen}
- options={{ title: "Paramètres" }}
- />
+ <Stack.Screen name="Account" component={AccountScreen} options={{ title: "Compte" }} />
+ <Stack.Screen name="About" component={AboutScreen} options={{ title: "À propos" }} />
</Stack.Navigator>
</NavigationContainer>
);
--- /dev/null
+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 (
+ <Modal transparent visible={visible} animationType="fade" onRequestClose={onClose}>
+ {/* Overlay */}
+ <Pressable style={styles.overlay} onPress={onClose}>
+ {/* Stop propagation */}
+ <Pressable style={styles.card} onPress={() => null}>
+ <View style={styles.headerRow}>
+ <Ionicons name="person-circle-outline" size={28} color="#0f172a" style={{ opacity: 0.8 }} />
+ <View style={{ flex: 1 }}>
+ <Text style={styles.title}>Compte</Text>
+ <Text style={ui.muted} numberOfLines={1}>{email}</Text>
+ </View>
+ <TouchableOpacity onPress={onClose}>
+ <Ionicons name="close" size={22} color="#0f172a" style={{ opacity: 0.7 }} />
+ </TouchableOpacity>
+ </View>
+
+ <TouchableOpacity style={styles.item} onPress={() => { onClose(); onGoAccount(); }}>
+ <Ionicons name="create-outline" size={18} color="#0f172a" style={styles.itemIcon} />
+ <Text style={styles.itemText}>Modification du compte</Text>
+ <Ionicons name="chevron-forward-outline" size={18} color="#0f172a" style={{ opacity: 0.35 }} />
+ </TouchableOpacity>
+
+ <TouchableOpacity style={styles.item} onPress={() => { onClose(); onGoAbout(); }}>
+ <Ionicons name="information-circle-outline" size={18} color="#0f172a" style={styles.itemIcon} />
+ <Text style={styles.itemText}>À propos</Text>
+ <Ionicons name="chevron-forward-outline" size={18} color="#0f172a" style={{ opacity: 0.35 }} />
+ </TouchableOpacity>
+
+ <TouchableOpacity style={[styles.item, styles.itemDanger]} onPress={() => { onClose(); onLogout(); }}>
+ <Ionicons name="log-out-outline" size={18} color="#dc2626" style={styles.itemIcon} />
+ <Text style={[styles.itemText, { color: "#dc2626" }]}>Déconnexion</Text>
+ </TouchableOpacity>
+ </Pressable>
+ </Pressable>
+ </Modal>
+ );
+}
+
+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
--- /dev/null
+/**
+ * Mock prices (Step 3)
+ * --------------------
+ * En attendant l'API REST /api/price/current.
+ */
+export const mockPrices: Record<string, number> = {
+ 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
--- /dev/null
+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
--- /dev/null
+/**
+ * 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
--- /dev/null
+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<AccountProfile | null>(null);
+ const [userId, setUserId] = useState<string>("");
+
+ const [displayName, setDisplayName] = useState<string>("");
+ const [email, setEmail] = useState<string>("");
+
+ // Champs UI (non persistés) pour mot de passe
+ const [password, setPassword] = useState<string>("");
+ const [password2, setPassword2] = useState<string>("");
+
+ const [info, setInfo] = useState<string | null>(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 (
+ <View style={ui.centered}>
+ <Text>Chargement du compte…</Text>
+ </View>
+ );
+ }
+
+ 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 (
+ <SafeAreaView style={styles.safeArea}>
+ <View style={ui.container}>
+ <Text style={styles.title}>Modification du compte</Text>
+
+ <View style={ui.card}>
+ <Text style={ui.title}>Informations</Text>
+
+ <Text style={ui.muted}>UserId (session)</Text>
+ <Text style={styles.mono}>{userId || "—"}</Text>
+
+ <Text style={[ui.muted, { marginTop: 10 }]}>Nom affiché</Text>
+ <TextInput value={displayName} onChangeText={setDisplayName} style={styles.input} />
+
+ <Text style={[ui.muted, { marginTop: 10 }]}>Email</Text>
+ <TextInput
+ value={email}
+ onChangeText={setEmail}
+ autoCapitalize="none"
+ keyboardType="email-address"
+ style={styles.input}
+ />
+ </View>
+
+ <View style={ui.card}>
+ <Text style={ui.title}>Mot de passe (UI prête)</Text>
+ <Text style={ui.muted}>
+ Le changement réel du mot de passe sera branché quand l’API auth sera disponible.
+ </Text>
+
+ <Text style={[ui.muted, { marginTop: 10 }]}>Nouveau mot de passe</Text>
+ <TextInput value={password} onChangeText={setPassword} secureTextEntry style={styles.input} />
+
+ <Text style={[ui.muted, { marginTop: 10 }]}>Confirmation</Text>
+ <TextInput value={password2} onChangeText={setPassword2} secureTextEntry style={styles.input} />
+ </View>
+
+ {!!info && <Text style={[ui.muted, { marginBottom: 10 }]}>{info}</Text>}
+
+ <TouchableOpacity style={[ui.button, styles.fullButton]} onPress={handleSave}>
+ <Text style={ui.buttonText}>Sauvegarder</Text>
+ </TouchableOpacity>
+ </View>
+ </SafeAreaView>
+ );
+}
+
+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
--- /dev/null
+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<AccountProfile | null>(null);
+ const [userId, setUserId] = useState<string>("");
+
+ const [displayName, setDisplayName] = useState<string>("");
+ const [email, setEmail] = useState<string>("");
+
+ // Champs UI (non persistés) pour mot de passe
+ const [password, setPassword] = useState<string>("");
+ const [password2, setPassword2] = useState<string>("");
+
+ const [info, setInfo] = useState<string | null>(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 (
+ <View style={ui.centered}>
+ <Text>Chargement du compte…</Text>
+ </View>
+ );
+ }
+
+ 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 (
+ <SafeAreaView style={styles.safeArea}>
+ <View style={ui.container}>
+ <Text style={styles.title}>Modification du compte</Text>
+
+ <View style={ui.card}>
+ <Text style={ui.title}>Informations</Text>
+
+ <Text style={ui.muted}>UserId (session)</Text>
+ <Text style={styles.mono}>{userId || "—"}</Text>
+
+ <Text style={[ui.muted, { marginTop: 10 }]}>Nom affiché</Text>
+ <TextInput value={displayName} onChangeText={setDisplayName} style={styles.input} />
+
+ <Text style={[ui.muted, { marginTop: 10 }]}>Email</Text>
+ <TextInput
+ value={email}
+ onChangeText={setEmail}
+ autoCapitalize="none"
+ keyboardType="email-address"
+ style={styles.input}
+ />
+ </View>
+
+ <View style={ui.card}>
+ <Text style={ui.title}>Mot de passe (UI prête)</Text>
+ <Text style={ui.muted}>
+ Le changement réel du mot de passe sera branché quand l’API auth sera disponible.
+ </Text>
+
+ <Text style={[ui.muted, { marginTop: 10 }]}>Nouveau mot de passe</Text>
+ <TextInput value={password} onChangeText={setPassword} secureTextEntry style={styles.input} />
+
+ <Text style={[ui.muted, { marginTop: 10 }]}>Confirmation</Text>
+ <TextInput value={password2} onChangeText={setPassword2} secureTextEntry style={styles.input} />
+ </View>
+
+ {!!info && <Text style={[ui.muted, { marginBottom: 10 }]}>{info}</Text>}
+
+ <TouchableOpacity style={[ui.button, styles.fullButton]} onPress={handleSave}>
+ <Text style={ui.buttonText}>Sauvegarder</Text>
+ </TouchableOpacity>
+ </View>
+ </SafeAreaView>
+ );
+}
+
+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
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;
}
}
-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";
}
}
-/**
- * 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<Alert[]>([]);
- const [filter, setFilter] = useState<AlertFilter>("ALL");
+ const [items, setItems] = useState<Alert[]>([]);
+ const [filter, setFilter] = useState<Filter>("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 (
<TouchableOpacity
- style={[
- styles.filterBtn,
- active ? styles.filterBtnActive : styles.filterBtnInactive,
- ]}
onPress={() => setFilter(value)}
+ style={[styles.filterBtn, active && styles.filterBtnActive]}
>
<Text style={[styles.filterText, active && styles.filterTextActive]}>
{label}
<SafeAreaView style={styles.safeArea}>
<FlatList
contentContainerStyle={ui.container}
- data={filteredAndSorted}
- keyExtractor={(_, idx) => `alert-${idx}`}
+ data={filteredSorted}
+ keyExtractor={(it, idx) =>
+ it.id ??
+ `${it.timestamp}-${it.pair}-${it.action}-${it.alertLevel}-${idx}`
+ }
ListHeaderComponent={
- <View style={ui.card}>
+ <View style={{ marginBottom: 12 }}>
<View style={styles.headerRow}>
- <Text style={ui.title}>Alertes</Text>
+ <Text style={styles.screenTitle}>Alertes</Text>
- <TouchableOpacity
- style={[styles.clearBtn, alerts.length === 0 && styles.clearBtnDisabled]}
- onPress={confirmClear}
- disabled={alerts.length === 0}
- >
+ <TouchableOpacity onPress={handleClear} style={styles.clearBtn}>
<Text style={styles.clearBtnText}>Clear</Text>
</TouchableOpacity>
</View>
- <Text style={ui.muted}>
- Filtre : {filter === "ALL" ? "Toutes" : filter} — Total : {alerts.length}
- </Text>
-
<View style={styles.filtersRow}>
- <FilterButton value="ALL" label="Toutes" />
- <FilterButton value="CRITICAL" label="Critical" />
- <FilterButton value="WARNING" label="Warning" />
+ <FilterButton value="CRITICAL" label="Critiques" />
+ <FilterButton value="WARNING" label="Warnings" />
<FilterButton value="INFO" label="Info" />
+ <FilterButton value="ALL" label="Toutes" />
</View>
- <Text style={[ui.muted, { marginTop: 6 }]}>
- Tri par défaut : CRITICAL > WARNING > INFO, puis plus récent.
+ <Text style={ui.muted}>
+ Tri : CRITICAL → WARNING → INFO, puis récent → ancien.
</Text>
</View>
}
ListEmptyComponent={
<View style={ui.card}>
<Text style={ui.title}>Aucune alerte</Text>
- <Text style={ui.muted}>En attente d’alertes Socket.IO…</Text>
+ <Text style={ui.muted}>Aucune alerte reçue pour ce filtre.</Text>
</View>
}
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 (
<View style={ui.card}>
<View style={ui.rowBetween}>
<Text style={ui.valueBold}>{item.pair}</Text>
- <Text style={ui.muted}>
- {item.timestamp ? new Date(item.timestamp).toLocaleString() : "—"}
- </Text>
+ <Text style={ui.muted}>{formatDate(item.timestamp)}</Text>
</View>
- <View style={{ flexDirection: "row", gap: 8, flexWrap: "wrap", marginTop: 10 }}>
- <View style={[ui.badge, { backgroundColor: `${lvlColor}22`, marginTop: 0 }]}>
- <Text style={[ui.badgeText, { color: lvlColor }]}>{lvl}</Text>
+ <View style={styles.badgesRow}>
+ <View style={[ui.badge, { backgroundColor: `${aColor}22`, marginTop: 0 }]}>
+ <Text style={[ui.badgeText, { color: aColor }]}>{item.action}</Text>
</View>
- <View style={[ui.badge, { backgroundColor: `${actColor}22`, marginTop: 0 }]}>
- <Text style={[ui.badgeText, { color: actColor }]}>{act}</Text>
+ <View style={[ui.badge, { backgroundColor: `${lColor}22`, marginTop: 0 }]}>
+ <Text style={[ui.badgeText, { color: lColor }]}>{item.alertLevel}</Text>
</View>
</View>
<Text style={ui.valueBold}>{(item.confidence * 100).toFixed(0)}%</Text>
</View>
- {typeof item.price === "number" && (
- <View style={[ui.rowBetween, { marginTop: 6 }]}>
- <Text style={ui.value}>Prix</Text>
- <Text style={ui.valueBold}>{item.price.toFixed(2)}</Text>
- </View>
- )}
-
<Text style={[ui.muted, { marginTop: 10 }]}>{item.reason}</Text>
+
+ {!!item.price && (
+ <Text style={[ui.muted, { marginTop: 6 }]}>
+ Prix (optionnel) : {item.price.toFixed(2)}
+ </Text>
+ )}
</View>
);
}}
backgroundColor: ui.screen.backgroundColor,
},
+ screenTitle: {
+ fontSize: 22,
+ fontWeight: "900",
+ color: "#0f172a",
+ },
+
headerRow: {
flexDirection: "row",
justifyContent: "space-between",
borderColor: "#e5e7eb",
backgroundColor: "#fff",
},
- clearBtnDisabled: {
- opacity: 0.5,
- },
+
clearBtnText: {
fontWeight: "900",
color: "#dc2626",
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
--- /dev/null
+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<string>("demo@example.com");
+ const [error, setError] = useState<string | null>(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 (
+ <SafeAreaView style={styles.safeArea}>
+ <View style={ui.container}>
+ <Text style={styles.title}>Connexion</Text>
+
+ <View style={ui.card}>
+ <Text style={ui.title}>Email</Text>
+
+ <TextInput
+ value={email}
+ onChangeText={setEmail}
+ autoCapitalize="none"
+ keyboardType="email-address"
+ placeholder="ex: demo@example.com"
+ style={styles.input}
+ />
+
+ {!!error && <Text style={styles.errorText}>{error}</Text>}
+
+ <TouchableOpacity style={[ui.button, styles.fullButton]} onPress={handleLogin}>
+ <Text style={ui.buttonText}>Se connecter</Text>
+ </TouchableOpacity>
+
+ <Text style={[ui.muted, { marginTop: 10 }]}>
+ Step 4 (sans API) : session locale. Plus tard, auth réelle via serveur.
+ </Text>
+ </View>
+ </View>
+ </SafeAreaView>
+ );
+}
+
+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
import type { DashboardSummary } from "../types/DashboardSummary";
import { fetchDashboardSummary } from "../services/dashboardService";
+
import { loadSettings } from "../utils/settingsStorage";
import type { UserSettings } from "../models/UserSettings";
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();
const [summary, setSummary] = useState<DashboardSummary | null>(null);
const [settings, setSettings] = useState<UserSettings | null>(null);
- const [wallet, setWallet] = useState<WalletState | null>(null);
+ const [portfolio, setPortfolio] = useState<PortfolioState | null>(null);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
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.");
}
};
+ /**
+ * 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);
};
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 (
);
}
- if (!summary || !settings || !wallet) {
+ if (!summary || !settings || !portfolio) {
return (
<View style={ui.centered}>
<Text>Initialisation…</Text>
}
const Chevron = () => (
- <Ionicons name="chevron-forward-outline" size={18} color="#0f172a" style={{ opacity: 0.35 }} />
+ <Ionicons
+ name="chevron-forward-outline"
+ size={18}
+ color="#0f172a"
+ style={{ opacity: 0.35 }}
+ />
);
return (
</TouchableOpacity>
</View>
- {/* 2) PORTEFEUILLE — cliquable => Wallet */}
+ {/* 2) PORTEFEUILLE */}
<TouchableOpacity activeOpacity={0.85} onPress={() => navigation.navigate("Wallet" as never)}>
<View style={[ui.card, compact && styles.cardCompact]}>
<View style={styles.headerRow}>
</View>
<View style={ui.rowBetween}>
- <Text style={ui.value}>Quantité BTC :</Text>
- <Text style={ui.valueBold}>{wallet.quantity.toFixed(6)} BTC</Text>
- </View>
-
- <View style={[ui.rowBetween, { marginTop: 6 }]}>
- <Text style={ui.value}>Valeur Totale :</Text>
+ <Text style={ui.value}>Valeur globale :</Text>
<Text style={ui.valueBold}>
- {walletTotalValue !== null ? `${walletTotalValue.toFixed(2)} ${settings.currency}` : "—"}
+ {portfolioTotalValue.toFixed(2)} {settings.currency}
</Text>
</View>
- <Text style={[ui.muted, { marginTop: 6 }]} numberOfLines={1}>
- BTC @ {summary.price.toFixed(2)} {settings.currency}
+ {portfolio.assets.length === 0 ? (
+ <Text style={[ui.muted, { marginTop: 8 }]}>
+ Aucun asset (ajoute BTC/ETH/SOL dans Portefeuille)
+ </Text>
+ ) : (
+ <View style={{ marginTop: 8 }}>
+ {topAssets.map((a) => (
+ <View key={a.symbol} style={[ui.rowBetween, { marginTop: 4 }]}>
+ <Text style={ui.muted}>{a.symbol}</Text>
+ <Text style={ui.valueBold}>{a.quantity.toFixed(6)}</Text>
+ </View>
+ ))}
+
+ {remainingCount > 0 && (
+ <Text style={[ui.muted, { marginTop: 6 }]} numberOfLines={1}>
+ +{remainingCount} autre(s) asset(s)
+ </Text>
+ )}
+ </View>
+ )}
+
+ <Text style={[ui.muted, { marginTop: 8 }]} numberOfLines={1}>
+ BTC @ {summary.price.toFixed(2)} {settings.currency} (mock)
</Text>
</View>
</TouchableOpacity>
- {/* 3) URGENCE — cliquable => Alertes */}
+ {/* 3) URGENCE */}
<TouchableOpacity activeOpacity={0.85} onPress={() => navigation.navigate("Alerts" as never)}>
<View style={[ui.card, compact && styles.cardCompact]}>
<View style={styles.headerRow}>
- <Text style={[ui.title, compact && styles.titleCompact]}>Urgence</Text>
+ <Text style={[ui.title, compact && styles.titleCompact]}>Alertes</Text>
<Chevron />
</View>
</View>
</TouchableOpacity>
- {/* 4) PRIX BTC — cliquable => Historique */}
+ {/* 4) PRIX BTC */}
<TouchableOpacity activeOpacity={0.85} onPress={() => navigation.navigate("History" as never)}>
<View style={[ui.card, compact && styles.cardCompact]}>
<View style={styles.priceHeaderRow}>
</View>
<TouchableOpacity
- style={[styles.refreshBtn, refreshing && styles.refreshBtnDisabled, compact && styles.refreshBtnCompact]}
+ style={[
+ styles.refreshBtn,
+ refreshing && styles.refreshBtnDisabled,
+ compact && styles.refreshBtnCompact,
+ ]}
onPress={handleManualRefresh}
disabled={refreshing}
>
marginTop: 10,
},
- // ✅ header row + chevron icon
headerRow: {
flexDirection: "row",
justifyContent: "space-between",
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<UserSettings | null>(null);
const [infoMessage, setInfoMessage] = useState<string | null>(null);
+ const [sessionLabel, setSessionLabel] = useState<string>("");
useEffect(() => {
async function init() {
- const data = await loadSettings();
+ const [data, session] = await Promise.all([loadSettings(), loadSession()]);
setSettings(data);
+ setSessionLabel(session?.email ?? "—");
}
init();
}, []);
setInfoMessage("Paramètres sauvegardés ✅");
};
+ const handleLogout = async () => {
+ await clearSession();
+ onLogout?.(); // App.tsx repasse sur Auth
+ };
+
return (
<SafeAreaView style={styles.safeArea}>
<View style={ui.container}>
<Text style={styles.screenTitle}>Paramètres</Text>
+ {/* Carte session */}
+ <View style={ui.card}>
+ <Text style={ui.title}>Compte</Text>
+ <Text style={ui.muted}>Connecté : <Text style={styles.boldInline}>{sessionLabel}</Text></Text>
+
+ <TouchableOpacity style={styles.secondaryButton} onPress={handleLogout}>
+ <Text style={styles.secondaryButtonText}>Se déconnecter</Text>
+ </TouchableOpacity>
+ </View>
+
{/* Carte : Devise */}
<View style={ui.card}>
<Text style={ui.title}>Devise</Text>
{/* Carte : Notifications */}
<View style={ui.card}>
<Text style={ui.title}>Notifications</Text>
- <Text style={ui.value}>
- Statut : {settings.notificationsEnabled ? "ON" : "OFF"}
- </Text>
+ <Text style={ui.value}>Statut : {settings.notificationsEnabled ? "ON" : "OFF"}</Text>
<TouchableOpacity style={[ui.button, styles.fullButton]} onPress={toggleNotifications}>
<Text style={ui.buttonText}>
</Text>
</TouchableOpacity>
- {!!infoMessage && <Text style={styles.infoText}>{infoMessage}</Text>}
+ {!!infoMessage && <Text style={[ui.muted, { marginTop: 10 }]}>{infoMessage}</Text>}
</View>
{/* Bouton Save */}
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
-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<WalletState | null>(null);
+ const [portfolio, setPortfolio] = useState<PortfolioState | null>(null);
const [settings, setSettings] = useState<UserSettings | null>(null);
- // Prix BTC actuel (mock aujourd'hui, API demain)
- const [btcPrice, setBtcPrice] = useState<number | null>(null);
-
- // input texte (évite les bugs de virgule/points)
+ // Ajout asset
+ const [symbolInput, setSymbolInput] = useState<string>("BTC");
const [qtyInput, setQtyInput] = useState<string>("0");
const [info, setInfo] = useState<string | null>(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();
}, [])
);
+ 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);
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é ✅");
},
},
);
};
- if (!wallet || !settings) {
+ if (!portfolio || !settings) {
return (
<View style={ui.centered}>
<Text>Chargement du portefeuille…</Text>
return (
<SafeAreaView style={styles.safeArea}>
- <View style={ui.container}>
- <Text style={styles.screenTitle}>Portefeuille</Text>
-
- {/* Carte BTC */}
- <View style={ui.card}>
- <Text style={ui.title}>BTC</Text>
-
- <Text style={ui.muted}>Quantité détenue</Text>
-
- <TextInput
- value={qtyInput}
- onChangeText={setQtyInput}
- keyboardType="decimal-pad"
- placeholder="ex: 0.25"
- style={styles.input}
- />
-
- <Text style={[ui.muted, { marginTop: 10 }]}>
- Prix BTC actuel :{" "}
- <Text style={styles.boldInline}>
- {btcPrice !== null ? `${btcPrice.toFixed(2)} ${settings.currency}` : "—"}
+ <FlatList
+ contentContainerStyle={ui.container}
+ data={portfolio.assets}
+ keyExtractor={(it) => it.symbol}
+ ListHeaderComponent={
+ <View>
+ <Text style={styles.screenTitle}>Portefeuille</Text>
+
+ {/* Résumé global */}
+ <View style={ui.card}>
+ <Text style={ui.title}>Résumé</Text>
+
+ <View style={ui.rowBetween}>
+ <Text style={ui.value}>Valeur globale :</Text>
+ <Text style={ui.valueBold}>
+ {totalValue.toFixed(2)} {settings.currency}
+ </Text>
+ </View>
+
+ <Text style={[ui.muted, { marginTop: 6 }]}>
+ Dernière mise à jour :{" "}
+ <Text style={styles.boldInline}>{lastUpdatedLabel}</Text>
+ </Text>
+
+ <TouchableOpacity style={styles.secondaryButton} onPress={handleClear}>
+ <Text style={styles.secondaryButtonText}>Réinitialiser</Text>
+ </TouchableOpacity>
+ </View>
+
+ {/* Ajouter / modifier */}
+ <View style={ui.card}>
+ <Text style={ui.title}>Ajouter / Modifier un asset</Text>
+
+ <Text style={ui.muted}>Symbole (ex: BTC, ETH, SOL, ADA)</Text>
+ <TextInput
+ value={symbolInput}
+ onChangeText={setSymbolInput}
+ autoCapitalize="characters"
+ placeholder="BTC"
+ style={styles.input}
+ />
+
+ <Text style={[ui.muted, { marginTop: 10 }]}>Quantité</Text>
+ <TextInput
+ value={qtyInput}
+ onChangeText={setQtyInput}
+ keyboardType="decimal-pad"
+ placeholder="0.25"
+ style={styles.input}
+ />
+
+ <TouchableOpacity style={[ui.button, styles.fullButton]} onPress={handleAddOrUpdate}>
+ <Text style={ui.buttonText}>Enregistrer</Text>
+ </TouchableOpacity>
+
+ {!!info && <Text style={[ui.muted, { marginTop: 10 }]}>{info}</Text>}
+ <Text style={[ui.muted, { marginTop: 10 }]}>
+ Prix utilisés = mock pour Step 3 (API à brancher plus tard).
+ </Text>
+ </View>
+
+ <Text style={[ui.muted, { marginBottom: 10 }]}>
+ Liste des assets :
</Text>
- </Text>
-
- <Text style={[ui.muted, { marginTop: 6 }]}>
- Valeur estimée :{" "}
- <Text style={styles.boldInline}>
- {totalValue !== null ? `${totalValue.toFixed(2)} ${settings.currency}` : "—"}
- </Text>
- </Text>
-
- {/* ✅ Dernière mise à jour */}
- <Text style={[ui.muted, { marginTop: 6 }]}>
- Dernière mise à jour :{" "}
- <Text style={styles.boldInline}>{lastUpdatedLabel}</Text>
- </Text>
-
- <TouchableOpacity style={[ui.button, styles.fullButton]} onPress={handleSave}>
- <Text style={ui.buttonText}>Enregistrer</Text>
- </TouchableOpacity>
-
- <TouchableOpacity style={styles.secondaryButton} onPress={handleClear}>
- <Text style={styles.secondaryButtonText}>Réinitialiser</Text>
- </TouchableOpacity>
-
- {!!info && <Text style={[ui.muted, { marginTop: 10 }]}>{info}</Text>}
- </View>
-
- {/* Carte info Step */}
- <View style={ui.card}>
- <Text style={ui.title}>Step 1</Text>
- <Text style={ui.muted}>
- Mono-utilisateur / mono-crypto (BTC). Step 3 : portefeuille multi-cryptos + valeur globale.
- </Text>
- </View>
- </View>
+ </View>
+ }
+ ListEmptyComponent={
+ <View style={ui.card}>
+ <Text style={ui.title}>Aucun asset</Text>
+ <Text style={ui.muted}>Ajoutez BTC/ETH/SOL… pour commencer.</Text>
+ </View>
+ }
+ renderItem={({ item }) => {
+ const price = getMockPrice(item.symbol);
+ const value = price !== null ? item.quantity * price : null;
+
+ return (
+ <View style={ui.card}>
+ <View style={ui.rowBetween}>
+ <Text style={ui.valueBold}>{item.symbol}</Text>
+ <TouchableOpacity onPress={() => handleDelete(item.symbol)}>
+ <Text style={styles.deleteText}>Supprimer</Text>
+ </TouchableOpacity>
+ </View>
+
+ <View style={[ui.rowBetween, { marginTop: 8 }]}>
+ <Text style={ui.value}>Quantité</Text>
+ <Text style={ui.valueBold}>{item.quantity.toFixed(6)}</Text>
+ </View>
+
+ <View style={[ui.rowBetween, { marginTop: 6 }]}>
+ <Text style={ui.value}>Prix (mock)</Text>
+ <Text style={ui.valueBold}>
+ {price !== null ? `${price.toFixed(2)} ${settings.currency}` : "—"}
+ </Text>
+ </View>
+
+ <View style={[ui.rowBetween, { marginTop: 6 }]}>
+ <Text style={ui.value}>Valeur</Text>
+ <Text style={ui.valueBold}>
+ {value !== null ? `${value.toFixed(2)} ${settings.currency}` : "—"}
+ </Text>
+ </View>
+ </View>
+ );
+ }}
+ />
</SafeAreaView>
);
}
fontWeight: "900",
color: "#dc2626",
},
+
+ deleteText: {
+ fontWeight: "900",
+ color: "#dc2626",
+ },
});
\ No newline at end of file
+/**
+ * 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
--- /dev/null
+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<AccountProfile> {
+ 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<AccountProfile>;
+ 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<void> {
+ 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
--- /dev/null
+import AsyncStorage from "@react-native-async-storage/async-storage";
+import type { PortfolioState } from "../models/Portfolio";
+import { loadSession } from "./sessionStorage";
+
+/**
+ * KEY devient "portfolio:<userId>"
+ */
+function keyFor(userId: string) {
+ return `portfolio:${userId}`;
+}
+
+const DEFAULT_PORTFOLIO: PortfolioState = {
+ assets: [],
+ updatedAtMs: Date.now(),
+};
+
+export async function loadPortfolio(): Promise<PortfolioState> {
+ 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<PortfolioState>;
+ return {
+ ...DEFAULT_PORTFOLIO,
+ ...parsed,
+ assets: Array.isArray(parsed.assets) ? parsed.assets : [],
+ };
+ } catch {
+ return DEFAULT_PORTFOLIO;
+ }
+}
+
+export async function savePortfolio(portfolio: PortfolioState): Promise<void> {
+ 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<void> {
+ const session = await loadSession();
+ if (!session) return;
+
+ await AsyncStorage.removeItem(keyFor(session.userId));
+}
\ No newline at end of file
--- /dev/null
+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<Session | null> {
+ const raw = await AsyncStorage.getItem(KEY);
+ if (!raw) return null;
+
+ try {
+ const parsed = JSON.parse(raw) as Partial<Session>;
+ 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<void> {
+ await AsyncStorage.setItem(KEY, JSON.stringify(session));
+}
+
+/**
+ * Déconnexion.
+ */
+export async function clearSession(): Promise<void> {
+ await AsyncStorage.removeItem(KEY);
+}
\ No newline at end of file
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:<userId>"
*/
+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<UserSettings> {
- 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 {
}
export async function saveSettings(settings: UserSettings): Promise<void> {
- 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