import { NavigationContainer } from "@react-navigation/native";
import { createNativeStackNavigator } from "@react-navigation/native-stack";
-import { TouchableOpacity } from "react-native";
+import { TouchableOpacity, View, Text } 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 { loadSession, clearSession } from "./src/utils/sessionStorage";
-// Types des routes (pour éviter les erreurs de navigation)
export type RootStackParamList = {
Dashboard: undefined;
Settings: undefined;
const Stack = createNativeStackNavigator<RootStackParamList>();
export default function App() {
+ const [ready, setReady] = useState(false);
+ const [isAuthed, setIsAuthed] = useState(false);
+
+ useEffect(() => {
+ let active = true;
+
+ async function init() {
+ const s = await loadSession();
+ if (!active) return;
+ setIsAuthed(!!s);
+ setReady(true);
+ }
+
+ init();
+
+ return () => {
+ active = false;
+ };
+ }, []);
+
+ if (!ready) {
+ return (
+ <View style={{ flex: 1, justifyContent: "center", alignItems: "center" }}>
+ <Text>Initialisation…</Text>
+ </View>
+ );
+ }
+
+ // Pas connecté -> écran Auth
+ if (!isAuthed) {
+ return (
+ <AuthScreen
+ onAuthenticated={() => {
+ setIsAuthed(true);
+ }}
+ />
+ );
+ }
+
+ // Connecté -> stack normal
return (
<NavigationContainer>
<Stack.Navigator id="MainStack" initialRouteName="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 }}>
+ {/* ⚙️ Settings */}
+ <TouchableOpacity onPress={() => navigation.navigate("Settings")}>
+ <Ionicons name="settings-outline" size={22} color="#0f172a" />
+ </TouchableOpacity>
+
+ {/* ⎋ Logout */}
+ <TouchableOpacity
+ onPress={async () => {
+ await clearSession();
+ setIsAuthed(false);
+ }}
+ >
+ <Ionicons name="log-out-outline" size={22} color="#0f172a" />
+ </TouchableOpacity>
+ </View>
),
})}
/>
- <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="Settings"
- component={SettingsScreen}
- options={{ title: "Paramètres" }}
- />
+ <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="Settings" options={{ title: "Paramètres" }}
+>
+ {() => <SettingsScreen onLogout={() => setIsAuthed(false)} />}
+</Stack.Screen>
</Stack.Navigator>
</NavigationContainer>
);
--- /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";
-// ✅ Step 3 portfolio
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) — Step 3
- * --------------------------------
- * - Portefeuille multi-cryptos (portfolioStep3, AsyncStorage)
- * - Valeur globale = somme(quantity * prix mock)
- * - 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);
-
- // ✅ Step 3 portfolio
const [portfolio, setPortfolio] = useState<PortfolioState | null>(null);
const [loading, setLoading] = useState<boolean>(true);
const navigation = useNavigation();
- /**
- * Load initial + reload au focus (retour depuis Settings/Strategy/Wallet)
- * On recharge aussi le portfolio local (Step 3).
- */
useFocusEffect(
useCallback(() => {
let isActive = true;
setSummary(dashboardData);
setSettings(userSettings);
setPortfolio(portfolioData);
-
setLastRefreshMs(Date.now());
} catch {
if (isActive) setError("Impossible de charger le dashboard.");
}, [])
);
- /**
- * Refresh auto (si activé)
- */
useEffect(() => {
if (!settings) return;
if (settings.refreshMode !== "auto") return;
};
}, [settings]);
- /**
- * Refresh manuel
- */
const handleManualRefresh = async () => {
try {
setRefreshing(true);
/**
* Socket.IO (non bloquant)
- * Step 1/2/3 : userId de test
+ * - 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);
};
}, [settings]);
- /**
- * Urgence : 1 seule alerte, la plus importante.
- */
const urgentAlert: Alert | null = useMemo(() => {
if (liveAlerts.length === 0) return null;
return liveAlerts[0];
}, [liveAlerts]);
- /**
- * Valeur globale du portefeuille (Step 3)
- * total = somme(quantity * prix(mock))
- */
const portfolioTotalValue = useMemo(() => {
if (!portfolio) return 0;
}, 0);
}, [portfolio]);
- /**
- * Résumé : top assets à afficher sur le dashboard
- * - on prend 3 assets max
- */
const topAssets: PortfolioAsset[] = useMemo(() => {
if (!portfolio) return [];
- // tri simple par valeur estimée (desc) si prix connu
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]);
return Math.max(0, portfolio.assets.length - topAssets.length);
}, [portfolio, topAssets]);
- // Loading / erreurs bloquantes
if (loading) {
return (
<View style={ui.centered}>
</TouchableOpacity>
</View>
- {/* 2) PORTEFEUILLE — cliquable => Wallet (Step 3) */}
+ {/* 2) PORTEFEUILLE */}
<TouchableOpacity activeOpacity={0.85} onPress={() => navigation.navigate("Wallet" as never)}>
<View style={[ui.card, compact && styles.cardCompact]}>
<View style={styles.headerRow}>
</Text>
</View>
- {/* Résumé assets */}
{portfolio.assets.length === 0 ? (
<Text style={[ui.muted, { marginTop: 8 }]}>
Aucun asset (ajoute BTC/ETH/SOL dans Portefeuille)
</View>
)}
- {/* Ligne informative BTC @ prix (mock) */}
<Text style={[ui.muted, { marginTop: 8 }]} numberOfLines={1}>
- BTC @ {summary.price.toFixed(2)} {settings.currency} (source mock)
+ 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}>
</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}>
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 AsyncStorage from "@react-native-async-storage/async-storage";
import type { PortfolioState } from "../models/Portfolio";
-
-const KEY = "portfolioStep3";
+import { loadSession } from "./sessionStorage";
/**
- * Portfolio par défaut : vide
+ * 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 raw = await AsyncStorage.getItem(KEY);
+ const session = await loadSession();
+ if (!session) return DEFAULT_PORTFOLIO;
+
+ const raw = await AsyncStorage.getItem(keyFor(session.userId));
if (!raw) return DEFAULT_PORTFOLIO;
try {
}
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(KEY, JSON.stringify(safe));
+
+ await AsyncStorage.setItem(keyFor(session.userId), JSON.stringify(safe));
}
export async function clearPortfolio(): Promise<void> {
- await AsyncStorage.removeItem(KEY);
+ 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