+++ /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, createNavigationContainerRef } from "@react-navigation/native";
+import {
+ NavigationContainer,
+ createNavigationContainerRef,
+} from "@react-navigation/native";
import { createNativeStackNavigator } from "@react-navigation/native-stack";
import { TouchableOpacity, View, Text, Alert as RNAlert } from "react-native";
import { Ionicons } from "@expo/vector-icons";
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 TutorialScreen from "./src/screens/TutorialScreen";
+
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";
+import { loadSession, clearSession } from "./src/utils/sessionStorage";
+import { hasSeenTutorial } from "./src/utils/tutorialStorage";
+
export type RootStackParamList = {
Dashboard: undefined;
Settings: undefined;
const Stack = createNativeStackNavigator<RootStackParamList>();
-// ✅ navigationRef (permet de naviguer hors des screens, ex: depuis un Modal)
+// ✅ navigationRef (navigate depuis le Modal AccountMenu)
export const navigationRef = createNavigationContainerRef<RootStackParamList>();
export default function App() {
const [ready, setReady] = useState(false);
+
+ // Auth state
const [isAuthed, setIsAuthed] = useState(false);
+ const [sessionEmail, setSessionEmail] = useState<string>("—");
+
+ // Tutorial gate
+ const [needsTutorial, setNeedsTutorial] = useState(false);
+ // Account menu
const [menuVisible, setMenuVisible] = useState(false);
- const [sessionEmail, setSessionEmail] = useState<string>("—");
useEffect(() => {
let active = true;
const s = await loadSession();
if (!active) return;
- setIsAuthed(!!s);
+ const authed = !!s;
+ setIsAuthed(authed);
setSessionEmail(s?.email ?? "—");
+
+ if (authed) {
+ const seen = await hasSeenTutorial();
+ if (!active) return;
+ setNeedsTutorial(!seen);
+ } else {
+ setNeedsTutorial(false);
+ }
+
setReady(true);
}
await clearSession();
setIsAuthed(false);
setSessionEmail("—");
+ setNeedsTutorial(false);
},
},
]);
};
const go = (route: keyof RootStackParamList) => {
- // ✅ safe guard : navigation prête ?
if (!navigationRef.isReady()) return;
navigationRef.navigate(route);
};
);
}
- // Pas connecté -> écran Auth
+ // 1) Pas connecté -> Auth (hors stack)
if (!isAuthed) {
return (
<AuthScreen
const s = await loadSession();
setSessionEmail(s?.email ?? "—");
setIsAuthed(true);
+
+ // Après login, on vérifie le tutoriel
+ const seen = await hasSeenTutorial();
+ setNeedsTutorial(!seen);
+ }}
+ />
+ );
+ }
+
+ // 2) Connecté mais tuto pas vu -> Tutoriel (hors stack)
+ if (needsTutorial) {
+ return (
+ <TutorialScreen
+ onDone={() => {
+ setNeedsTutorial(false);
}}
/>
);
}
+ // 3) Connecté + tuto vu -> App normale
return (
<NavigationContainer ref={navigationRef}>
- {/* ✅ Modal menu Compte (navigue grâce au navigationRef) */}
<AccountMenu
visible={menuVisible}
email={sessionEmail}
<View style={{ flexDirection: "row", gap: 12 }}>
{/* 👤 Menu Compte */}
<TouchableOpacity onPress={() => setMenuVisible(true)}>
- <Ionicons name="person-circle-outline" size={24} color="#0f172a" />
+ <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
+ 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="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="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="Settings" options={{ title: "Paramètres" }}>
+ {() => (
+ <SettingsScreen
+ onRequestTutorial={() => {
+ // Force le tuto à s'afficher hors stack
+ setNeedsTutorial(true);
+ }}
+ />
+ )}
+ </Stack.Screen>
- <Stack.Screen name="Account" component={AccountScreen} options={{ title: "Compte" }} />
- <Stack.Screen name="About" component={AboutScreen} options={{ title: "À propos" }} />
+ <Stack.Screen
+ name="Account"
+ component={AccountScreen}
+ options={{ title: "Compte" }}
+ />
+ <Stack.Screen
+ name="About"
+ component={AboutScreen}
+ options={{ title: "À propos" }}
+ />
</Stack.Navigator>
</NavigationContainer>
);
"@react-navigation/native": "^7.1.28",
"@react-navigation/native-stack": "^7.13.0",
"expo": "~54.0.33",
+ "expo-crypto": "~15.0.8",
"expo-notifications": "~0.32.16",
"expo-status-bar": "~3.0.9",
"react": "19.1.0",
"react-native": "*"
}
},
+ "node_modules/expo-crypto": {
+ "version": "15.0.8",
+ "resolved": "https://registry.npmjs.org/expo-crypto/-/expo-crypto-15.0.8.tgz",
+ "integrity": "sha512-aF7A914TB66WIlTJvl5J6/itejfY78O7dq3ibvFltL9vnTALJ/7LYHvLT4fwmx9yUNS6ekLBtDGWivFWnj2Fcw==",
+ "license": "MIT",
+ "dependencies": {
+ "base64-js": "^1.3.0"
+ },
+ "peerDependencies": {
+ "expo": "*"
+ }
+ },
"node_modules/expo-font": {
"version": "14.0.11",
"resolved": "https://registry.npmjs.org/expo-font/-/expo-font-14.0.11.tgz",
"@react-navigation/native": "^7.1.28",
"@react-navigation/native-stack": "^7.13.0",
"expo": "~54.0.33",
+ "expo-crypto": "~15.0.8",
"expo-notifications": "~0.32.16",
"expo-status-bar": "~3.0.9",
"react": "19.1.0",
--- /dev/null
+import { Platform } from "react-native";
+
+/**
+ * API Gateway (microservices)
+ * ---------------------------
+ * - Téléphone physique : utiliser l'IP du PC sur le réseau local
+ * - Android émulateur : 10.0.2.2
+ * - iOS simulateur : localhost (souvent OK)
+ *
+ * IMPORTANT : le mobile doit appeler UNIQUEMENT le gateway:
+ * http://<HOST>:3000/api/...
+ * Socket.IO passe aussi via le gateway:
+ * http://<HOST>:3000 (proxy /socket.io/*)
+ */
+
+// ✅ Change uniquement cette valeur pour ton PC
+const DEV_LAN_IP = "192.168.129.121";
+
+export function getGatewayHost(): string {
+ if (__DEV__) {
+ // Téléphone physique / LAN
+ return DEV_LAN_IP;
+ }
+ // En prod (ou build), tu mettras un vrai domaine/IP si nécessaire
+ return DEV_LAN_IP;
+}
+
+export function getGatewayBaseUrl(): string {
+ const host =
+ Platform.OS === "android" && !__DEV__
+ ? "10.0.2.2"
+ : getGatewayHost();
+
+ return `http://${host}:3000`;
+}
+
+// REST base
+export const API_BASE_URL = `${getGatewayBaseUrl()}/api`;
+
+// Socket base (pas /api)
+export const SOCKET_BASE_URL = `${getGatewayBaseUrl()}`;
\ No newline at end of file
-// ⚠️ En dev téléphone réel : URL = IP du PC sur le même réseau
-export const SERVER_URL = "http://192.168.129.121:3000";
-export const WS_URL = "ws://192.168.129.121:3000"; // optionnel, Socket.IO peut aussi utiliser http(s)
\ No newline at end of file
+import { Platform } from "react-native";
+
+/**
+ * env.ts
+ * ------
+ * Objectif : centraliser les URLs réseau pour le mobile.
+ *
+ * IMPORTANT (microservices + gateway) :
+ * - Le mobile parle UNIQUEMENT au gateway : http://<HOST>:3000
+ * - REST = http://<HOST>:3000/api/...
+ * - Socket.IO = http://<HOST>:3000 (proxy /socket.io/* via gateway)
+ *
+ * Téléphone physique :
+ * - <HOST> = IP du PC sur le Wi-Fi (ex: 192.168.x.x)
+ *
+ * Émulateurs (si un jour) :
+ * - Android: 10.0.2.2
+ * - iOS: localhost
+ */
+
+const DEV_LAN_IP = "192.168.129.121";
+
+function resolveHost(): string {
+ // On part sur téléphone physique (ton cas).
+ // Si un jour tu testes sur émulateur Android, tu peux mettre une condition:
+ // if (Platform.OS === "android" && __DEV__ && isEmulator) return "10.0.2.2";
+ return DEV_LAN_IP;
+}
+
+// Base gateway (HTTP)
+export const GATEWAY_BASE_URL = `http://${resolveHost()}:3000`;
+
+// REST (via gateway)
+export const API_BASE_URL = `${GATEWAY_BASE_URL}/api`;
+
+// Socket.IO (via gateway)
+export const SERVER_URL = GATEWAY_BASE_URL;
\ 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
+export interface AuthUser {
+ userId: string;
+
+ email: string;
+ username: string;
+
+ // Optionnel : nom/prénom ou nom affiché
+ displayName?: string;
+
+ // On ne stocke JAMAIS le mot de passe en clair
+ passwordHash: string;
+
+ createdAtMs: number;
+}
\ No newline at end of file
-import { View, Text, StyleSheet, TextInput, TouchableOpacity, Alert as RNAlert } from "react-native";
-import { useEffect, useState } from "react";
+import { View, Text, StyleSheet, TouchableOpacity } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
+import { Ionicons } from "@expo/vector-icons";
+import { useEffect, useState } from "react";
import { ui } from "../components/ui/uiStyles";
import { loadSession } from "../utils/sessionStorage";
-import { loadAccountProfile, saveAccountProfile } from "../utils/accountStorage";
-import type { AccountProfile } from "../models/AccountProfile";
+import { findUserById } from "../utils/authUsersStorage";
/**
- * 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.
+ * AboutScreen
+ * ------------------------
+ * "Détails du compte" + sections À propos / Support / Version.
+ * Les infos affichées viennent du compte local (AuthUsersStorage) :
+ * - displayName (optionnel)
+ * - username
+ * - email
*/
-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);
+export default function AboutScreen() {
+ const [email, setEmail] = useState<string>("—");
+ const [displayName, setDisplayName] = useState<string>("Utilisateur");
+ const [username, setUsername] = useState<string>("—");
+ const [userId, setUserId] = useState<string>("—");
useEffect(() => {
- async function init() {
- const [session, p] = await Promise.all([loadSession(), loadAccountProfile()]);
- setUserId(session?.userId ?? "");
- setProfile(p);
- setDisplayName(p.displayName);
- setEmail(p.email);
- }
- init();
- }, []);
+ let active = true;
- if (!profile) {
- return (
- <View style={ui.centered}>
- <Text>Chargement du compte…</Text>
- </View>
- );
- }
+ async function init() {
+ const session = await loadSession();
+ if (!active) return;
+
+ if (!session) {
+ setEmail("—");
+ setDisplayName("Utilisateur");
+ setUsername("—");
+ setUserId("—");
+ return;
+ }
- const handleSave = async () => {
- setInfo(null);
+ setUserId(session.userId);
+ setEmail(session.email);
- const normalizedEmail = email.trim().toLowerCase();
- const isValidEmail = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(normalizedEmail);
+ const user = await findUserById(session.userId);
+ if (!active) return;
- if (!isValidEmail) {
- setInfo("Email invalide.");
- return;
+ setUsername(user?.username ?? session.userId.replace("user_", ""));
+ setDisplayName(user?.displayName ?? "Utilisateur");
+ setEmail(user?.email ?? session.email);
}
- // 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" }]
- );
- }
+ init();
- const updated: AccountProfile = {
- displayName: displayName.trim() || "Utilisateur",
- email: normalizedEmail,
- updatedAtMs: Date.now(),
+ return () => {
+ active = false;
};
+ }, []);
- await saveAccountProfile(updated);
- setProfile(updated);
- setPassword("");
- setPassword2("");
- setInfo("Profil sauvegardé ✅");
- };
+ const Row = ({
+ icon,
+ title,
+ subtitle,
+ }: {
+ icon: any;
+ title: string;
+ subtitle?: string;
+ }) => (
+ <View style={styles.row}>
+ <Ionicons name={icon} size={18} color="#0f172a" style={{ opacity: 0.75 }} />
+ <View style={{ flex: 1 }}>
+ <Text style={styles.rowTitle}>{title}</Text>
+ {!!subtitle && <Text style={ui.muted}>{subtitle}</Text>}
+ </View>
+ <Ionicons name="chevron-forward-outline" size={18} color="#0f172a" style={{ opacity: 0.25 }} />
+ </View>
+ );
return (
<SafeAreaView style={styles.safeArea}>
<View style={ui.container}>
- <Text style={styles.title}>Modification du compte</Text>
+ <Text style={styles.title}>Détails du compte</Text>
+ {/* Carte profil (style WF-09) */}
<View style={ui.card}>
- <Text style={ui.title}>Informations</Text>
-
- <Text style={ui.muted}>UserId (session)</Text>
- <Text style={styles.mono}>{userId || "—"}</Text>
+ <View style={styles.profileRow}>
+ <Ionicons
+ name="person-circle-outline"
+ size={52}
+ color="#0f172a"
+ style={{ opacity: 0.75 }}
+ />
+ <View style={{ flex: 1 }}>
+ <Text style={styles.profileName}>{displayName}</Text>
+ <Text style={ui.muted} numberOfLines={1}>{email}</Text>
+ <Text style={[ui.muted, { marginTop: 2 }]} numberOfLines={1}>
+ @{username}
+ </Text>
+ </View>
+ </View>
+
+ <View style={styles.metaBox}>
+ <Text style={styles.metaLabel}>UserId</Text>
+ <Text style={styles.metaValue} numberOfLines={1}>{userId}</Text>
+ </View>
+ </View>
- <Text style={[ui.muted, { marginTop: 10 }]}>Nom affiché</Text>
- <TextInput value={displayName} onChangeText={setDisplayName} style={styles.input} />
+ {/* À propos */}
+ <View style={ui.card}>
+ <Text style={ui.title}>À propos</Text>
- <Text style={[ui.muted, { marginTop: 10 }]}>Email</Text>
- <TextInput
- value={email}
- onChangeText={setEmail}
- autoCapitalize="none"
- keyboardType="email-address"
- style={styles.input}
+ <Row icon="sparkles-outline" title="Wall-e-tte" subtitle="Conseiller crypto — projet PDW 2025-2026" />
+ <Row
+ icon="shield-checkmark-outline"
+ title="Avertissement"
+ subtitle="Outil éducatif : ce n’est pas un conseil financier."
/>
</View>
+ {/* Support */}
<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} />
+ <Text style={ui.title}>Support</Text>
+ <Row icon="help-circle-outline" title="Aide" subtitle="Comprendre les écrans et les signaux." />
+ <Row icon="mail-outline" title="Contact" subtitle="Support de l’équipe (à définir)." />
</View>
- {!!info && <Text style={[ui.muted, { marginBottom: 10 }]}>{info}</Text>}
+ {/* Version */}
+ <View style={ui.card}>
+ <Text style={ui.title}>Version</Text>
+ <Text style={ui.muted}>App : 1.0.0</Text>
+ <Text style={[ui.muted, { marginTop: 6 }]}>Build : Step 4 (sans API)</Text>
+ </View>
- <TouchableOpacity style={[ui.button, styles.fullButton]} onPress={handleSave}>
- <Text style={ui.buttonText}>Sauvegarder</Text>
+ {/* Bouton discret (optionnel) */}
+ <TouchableOpacity style={styles.secondaryButton}>
+ <Text style={styles.secondaryButtonText}>Voir les licences (optionnel)</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,
+ profileRow: { flexDirection: "row", alignItems: "center", gap: 12 },
+ profileName: { fontSize: 18, fontWeight: "900", color: "#0f172a" },
+
+ metaBox: {
+ marginTop: 12,
+ borderTopWidth: 1,
+ borderTopColor: "#e5e7eb",
+ paddingTop: 10,
+ },
+ metaLabel: {
+ fontWeight: "900",
+ color: "#0f172a",
+ opacity: 0.7,
+ marginBottom: 4,
+ },
+ metaValue: {
fontFamily: "monospace",
color: "#0f172a",
- opacity: 0.8,
+ opacity: 0.85,
},
- input: {
+ row: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: 10,
+ paddingVertical: 12,
+ borderTopWidth: 1,
+ borderTopColor: "#e5e7eb",
+ },
+ rowTitle: { fontWeight: "900", color: "#0f172a" },
+
+ secondaryButton: {
+ marginTop: 6,
+ paddingVertical: 12,
+ borderRadius: 12,
borderWidth: 1,
borderColor: "#e5e7eb",
- borderRadius: 10,
- paddingHorizontal: 12,
- paddingVertical: 10,
- marginTop: 8,
+ alignItems: "center",
backgroundColor: "#fff",
- color: "#0f172a",
},
+ secondaryButtonText: { fontWeight: "900", color: "#0f172a", opacity: 0.8 },
});
\ No newline at end of file
-import { View, Text, StyleSheet, TextInput, TouchableOpacity, Alert as RNAlert } from "react-native";
+import { View, Text, StyleSheet, TextInput, TouchableOpacity } 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";
+import { findUserById, updateCurrentUserProfile } from "../utils/authUsersStorage";
/**
* 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)
+ * - Email : affiché mais NON modifiable (important)
+ * - Username : affiché mais NON modifiable (sinon userId local casse)
+ * - DisplayName : modifiable
+ * - Mot de passe : modifiable (hash local)
*
- * Quand l'API arrive : POST /auth/update-profile + update password.
+ * Plus tard : remplacement par une route API (DB).
*/
export default function AccountScreen() {
- const [profile, setProfile] = useState<AccountProfile | null>(null);
+ const [loading, setLoading] = useState(true);
+
const [userId, setUserId] = useState<string>("");
+ const [email, setEmail] = useState<string>(""); // affichage only
+ const [username, setUsername] = useState<string>(""); // affichage only
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);
+ const [error, setError] = useState<string | null>(null);
useEffect(() => {
+ let active = true;
+
async function init() {
- const [session, p] = await Promise.all([loadSession(), loadAccountProfile()]);
- setUserId(session?.userId ?? "");
- setProfile(p);
- setDisplayName(p.displayName);
- setEmail(p.email);
+ const session = await loadSession();
+ if (!session) {
+ if (!active) return;
+ setError("Session absente.");
+ setLoading(false);
+ return;
+ }
+
+ const u = await findUserById(session.userId);
+
+ if (!active) return;
+
+ setUserId(session.userId);
+ setEmail(u?.email ?? session.email);
+ setUsername(u?.username ?? session.userId.replace("user_", ""));
+ setDisplayName(u?.displayName ?? "");
+
+ setLoading(false);
}
+
init();
+
+ return () => {
+ active = false;
+ };
}, []);
- if (!profile) {
+ if (loading) {
return (
<View style={ui.centered}>
<Text>Chargement du compte…</Text>
const handleSave = async () => {
setInfo(null);
+ setError(null);
- const normalizedEmail = email.trim().toLowerCase();
- const isValidEmail = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(normalizedEmail);
-
- if (!isValidEmail) {
- setInfo("Email invalide.");
- return;
- }
+ // Mot de passe : optionnel, mais si rempli => validations
+ const wantsPasswordChange = password.trim().length > 0 || password2.trim().length > 0;
- // 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).");
+ if (wantsPasswordChange) {
+ if (password.trim().length < 6) {
+ setError("Mot de passe trop court (min 6).");
return;
}
if (password !== password2) {
- setInfo("Les mots de passe ne correspondent pas.");
+ setError("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(),
- };
+ const res = await updateCurrentUserProfile({
+ displayName,
+ newPassword: wantsPasswordChange ? password : undefined,
+ });
- await saveAccountProfile(updated);
- setProfile(updated);
+ if (!res.ok) {
+ setError(res.message);
+ return;
+ }
+
+ // reset password fields
setPassword("");
setPassword2("");
- setInfo("Profil sauvegardé ✅");
+
+ setInfo("Profil sauvegardé ✅ (local, en attendant l’API).");
};
return (
<View style={ui.card}>
<Text style={ui.title}>Informations</Text>
- <Text style={ui.muted}>UserId (session)</Text>
+ <Text style={ui.muted}>UserId</Text>
<Text style={styles.mono}>{userId || "—"}</Text>
+ <Text style={[ui.muted, { marginTop: 10 }]}>Email (non modifiable)</Text>
+ <TextInput value={email} editable={false} style={[styles.input, styles.inputDisabled]} />
+
+ <Text style={[ui.muted, { marginTop: 10 }]}>Nom d’utilisateur (non modifiable)</Text>
+ <TextInput value={username} editable={false} style={[styles.input, styles.inputDisabled]} />
+
<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}
- />
+ <TextInput value={displayName} onChangeText={setDisplayName} placeholder="ex: Thibaud M." style={styles.input} />
+
+ <Text style={[ui.muted, { marginTop: 12 }]}>
+ ⚠️ Email/username seront modifiables uniquement quand l’API serveur/DB sera en place (et avec validation).
+ </Text>
</View>
<View style={ui.card}>
- <Text style={ui.title}>Mot de passe (UI prête)</Text>
+ <Text style={ui.title}>Mot de passe</Text>
<Text style={ui.muted}>
- Le changement réel du mot de passe sera branché quand l’API auth sera disponible.
+ Change optionnel. (Local pour l’instant, sera relié à l’API plus tard.)
</Text>
<Text style={[ui.muted, { marginTop: 10 }]}>Nouveau mot de passe</Text>
- <TextInput value={password} onChangeText={setPassword} secureTextEntry style={styles.input} />
+ <TextInput value={password} onChangeText={setPassword} secureTextEntry placeholder="min 6 caractères" style={styles.input} />
<Text style={[ui.muted, { marginTop: 10 }]}>Confirmation</Text>
- <TextInput value={password2} onChangeText={setPassword2} secureTextEntry style={styles.input} />
+ <TextInput value={password2} onChangeText={setPassword2} secureTextEntry placeholder="retaper le mot de passe" style={styles.input} />
</View>
+ {!!error && <Text style={styles.errorText}>{error}</Text>}
{!!info && <Text style={[ui.muted, { marginBottom: 10 }]}>{info}</Text>}
<TouchableOpacity style={[ui.button, styles.fullButton]} onPress={handleSave}>
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,
backgroundColor: "#fff",
color: "#0f172a",
},
+
+ inputDisabled: {
+ backgroundColor: "#f3f4f6",
+ color: "#111827",
+ opacity: 0.8,
+ },
+
+ fullButton: { flexGrow: 0, flexBasis: "auto", width: "100%", marginBottom: 10 },
+
+ errorText: { color: "#dc2626", fontWeight: "900", marginBottom: 10 },
});
\ No newline at end of file
import { View, Text, StyleSheet, TextInput, TouchableOpacity } from "react-native";
-import { useMemo, useState } from "react";
import { SafeAreaView } from "react-native-safe-area-context";
+import { useMemo, useState } from "react";
import { ui } from "../components/ui/uiStyles";
import { saveSession } from "../utils/sessionStorage";
+import { createUser, verifyLogin } from "../utils/authUsersStorage";
/**
- * AuthScreen (Step 4 - sans API)
- * ------------------------------
- * Simule un login local :
- * - Email -> userId stable
- * - Session stockée en AsyncStorage
+ * AuthScreen (Step 4 - local, sans API)
+ * ------------------------------------
+ * Mode Connexion + Mode Création de compte.
+ * - Identifiant: email OU username
+ * - Mot de passe obligatoire
*
- * Plus tard : on remplace par une auth REST réelle.
+ * Plus tard : on remplacera createUser/verifyLogin par des appels REST.
*/
export default function AuthScreen({ onAuthenticated }: { onAuthenticated: () => void }) {
- const [email, setEmail] = useState<string>("demo@example.com");
+ const [mode, setMode] = useState<"login" | "register">("login");
const [error, setError] = useState<string | null>(null);
- const normalizedEmail = useMemo(() => email.trim().toLowerCase(), [email]);
+ // Connexion
+ const [login, setLogin] = useState("demo@example.com");
+ const [password, setPassword] = useState("");
+
+ // Register
+ const [email, setEmail] = useState("demo@example.com");
+ const [username, setUsername] = useState("demo");
+ const [displayName, setDisplayName] = useState("");
+ const [password2, setPassword2] = useState("");
- const isValidEmail = useMemo(() => {
- return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(normalizedEmail);
- }, [normalizedEmail]);
+ const isValidEmail = (val: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(val.trim().toLowerCase());
+ const isValidUsername = (val: string) => /^[a-zA-Z0-9_]{3,20}$/.test(val.trim());
- const makeUserId = (mail: string) => `user_${mail.replace(/[^a-z0-9]/g, "_")}`;
+ const title = useMemo(() => (mode === "login" ? "Connexion" : "Créer un compte"), [mode]);
const handleLogin = async () => {
setError(null);
- if (!isValidEmail) {
- setError("Email invalide.");
- return;
- }
+ const l = login.trim();
+ const p = password;
- const userId = makeUserId(normalizedEmail);
+ if (!l) return setError("Veuillez entrer un email ou un nom d’utilisateur.");
+ if (!p || p.length < 6) return setError("Mot de passe invalide (min 6).");
+
+ const res = await verifyLogin({ login: l, password: p });
+ if (!res.ok) return setError(res.message);
await saveSession({
- userId,
- email: normalizedEmail,
+ userId: res.user.userId,
+ email: res.user.email,
+ createdAtMs: Date.now(),
+ });
+
+ onAuthenticated();
+ };
+
+ const handleRegister = async () => {
+ setError(null);
+
+ const e = email.trim().toLowerCase();
+ const u = username.trim();
+ const d = displayName.trim();
+ const p1 = password;
+ const p2 = password2;
+
+ if (!isValidEmail(e)) return setError("Email invalide.");
+ if (!isValidUsername(u)) return setError("Username invalide (3-20, lettres/chiffres/_).");
+ if (!p1 || p1.length < 6) return setError("Mot de passe trop court (min 6).");
+ if (p1 !== p2) return setError("Les mots de passe ne correspondent pas.");
+
+ const res = await createUser({
+ email: e,
+ username: u,
+ displayName: d || undefined,
+ password: p1,
+ });
+
+ if (!res.ok) return setError(res.message);
+
+ // auto-login après création
+ await saveSession({
+ userId: res.user.userId,
+ email: res.user.email,
createdAtMs: Date.now(),
});
return (
<SafeAreaView style={styles.safeArea}>
<View style={ui.container}>
- <Text style={styles.title}>Connexion</Text>
+ <Text style={styles.title}>{title}</Text>
+
+ {/* Tabs */}
+ <View style={styles.tabsRow}>
+ <TouchableOpacity
+ style={[styles.tab, mode === "login" && styles.tabActive]}
+ onPress={() => {
+ setMode("login");
+ setError(null);
+ }}
+ >
+ <Text style={[styles.tabText, mode === "login" && styles.tabTextActive]}>
+ Connexion
+ </Text>
+ </TouchableOpacity>
+
+ <TouchableOpacity
+ style={[styles.tab, mode === "register" && styles.tabActive]}
+ onPress={() => {
+ setMode("register");
+ setError(null);
+ }}
+ >
+ <Text style={[styles.tabText, mode === "register" && styles.tabTextActive]}>
+ Créer un compte
+ </Text>
+ </TouchableOpacity>
+ </View>
<View style={ui.card}>
- <Text style={ui.title}>Email</Text>
+ {mode === "login" ? (
+ <>
+ <Text style={ui.muted}>Email ou nom d’utilisateur</Text>
+ <TextInput
+ value={login}
+ onChangeText={setLogin}
+ autoCapitalize="none"
+ placeholder="ex: demo@example.com ou demo"
+ style={styles.input}
+ />
- <TextInput
- value={email}
- onChangeText={setEmail}
- autoCapitalize="none"
- keyboardType="email-address"
- placeholder="ex: demo@example.com"
- style={styles.input}
- />
+ <Text style={[ui.muted, { marginTop: 10 }]}>Mot de passe</Text>
+ <TextInput
+ value={password}
+ onChangeText={setPassword}
+ secureTextEntry
+ placeholder="min 6 caractères"
+ style={styles.input}
+ />
- {!!error && <Text style={styles.errorText}>{error}</Text>}
+ {!!error && <Text style={styles.errorText}>{error}</Text>}
- <TouchableOpacity style={[ui.button, styles.fullButton]} onPress={handleLogin}>
- <Text style={ui.buttonText}>Se connecter</Text>
- </TouchableOpacity>
+ <TouchableOpacity style={[ui.button, styles.fullButton]} onPress={handleLogin}>
+ <Text style={ui.buttonText}>Se connecter</Text>
+ </TouchableOpacity>
+
+ <Text style={[ui.muted, { marginTop: 10 }]}>
+ (Mode local sans API : destiné au développement / démo.)
+ </Text>
+ </>
+ ) : (
+ <>
+ <Text style={ui.muted}>Email</Text>
+ <TextInput
+ value={email}
+ onChangeText={setEmail}
+ autoCapitalize="none"
+ keyboardType="email-address"
+ placeholder="ex: demo@example.com"
+ style={styles.input}
+ />
+
+ <Text style={[ui.muted, { marginTop: 10 }]}>Nom d’utilisateur</Text>
+ <TextInput
+ value={username}
+ onChangeText={setUsername}
+ autoCapitalize="none"
+ placeholder="ex: demo_123"
+ style={styles.input}
+ />
+
+ <Text style={[ui.muted, { marginTop: 10 }]}>Nom / Prénom (optionnel)</Text>
+ <TextInput
+ value={displayName}
+ onChangeText={setDisplayName}
+ placeholder="ex: Thibaud M."
+ style={styles.input}
+ />
+
+ <Text style={[ui.muted, { marginTop: 10 }]}>Mot de passe</Text>
+ <TextInput
+ value={password}
+ onChangeText={setPassword}
+ secureTextEntry
+ placeholder="min 6 caractères"
+ style={styles.input}
+ />
+
+ <Text style={[ui.muted, { marginTop: 10 }]}>Confirmer</Text>
+ <TextInput
+ value={password2}
+ onChangeText={setPassword2}
+ secureTextEntry
+ placeholder="retaper le mot de passe"
+ style={styles.input}
+ />
- <Text style={[ui.muted, { marginTop: 10 }]}>
- Step 4 (sans API) : session locale. Plus tard, auth réelle via serveur.
- </Text>
+ {!!error && <Text style={styles.errorText}>{error}</Text>}
+
+ <TouchableOpacity style={[ui.button, styles.fullButton]} onPress={handleRegister}>
+ <Text style={ui.buttonText}>Créer le compte</Text>
+ </TouchableOpacity>
+
+ <Text style={[ui.muted, { marginTop: 10 }]}>
+ (Sans API : stockage local + hash SHA-256 pour éviter le mot de passe en clair.)
+ </Text>
+ </>
+ )}
</View>
</View>
</SafeAreaView>
}
const styles = StyleSheet.create({
- safeArea: {
+ safeArea: { flex: 1, backgroundColor: ui.screen.backgroundColor },
+
+ title: { fontSize: 22, fontWeight: "900", marginBottom: 12, color: "#0f172a" },
+
+ tabsRow: {
+ flexDirection: "row",
+ gap: 10,
+ marginBottom: 12,
+ },
+ tab: {
flex: 1,
- backgroundColor: ui.screen.backgroundColor,
+ paddingVertical: 10,
+ borderRadius: 12,
+ borderWidth: 1,
+ borderColor: "#e5e7eb",
+ backgroundColor: "#fff",
+ alignItems: "center",
},
- title: {
- fontSize: 22,
- fontWeight: "900",
- marginBottom: 12,
- color: "#0f172a",
+ tabActive: {
+ borderColor: "#0f172a",
},
+ tabText: { fontWeight: "900", color: "#0f172a", opacity: 0.7 },
+ tabTextActive: { opacity: 1 },
+
input: {
borderWidth: 1,
borderColor: "#e5e7eb",
backgroundColor: "#fff",
color: "#0f172a",
},
- fullButton: {
- flexGrow: 0,
- flexBasis: "auto",
- width: "100%",
- marginTop: 12,
- },
- errorText: {
- marginTop: 10,
- color: "#dc2626",
- fontWeight: "900",
- },
+
+ fullButton: { flexGrow: 0, flexBasis: "auto", width: "100%", marginTop: 12 },
+
+ errorText: { marginTop: 10, color: "#dc2626", fontWeight: "900" },
});
\ No newline at end of file
-import { View, Text, StyleSheet, TouchableOpacity } from "react-native";
+import { View, Text, StyleSheet, TouchableOpacity, Alert as RNAlert } from "react-native";
import { useEffect, useState } from "react";
import { SafeAreaView } from "react-native-safe-area-context";
import type { UserSettings } from "../models/UserSettings";
import { requestNotificationPermission } from "../services/notificationService";
-import { clearSession, loadSession } from "../utils/sessionStorage";
import { ui } from "../components/ui/uiStyles";
+import { setSeenTutorial } from "../utils/tutorialStorage";
/**
- * SettingsScreen (Step 4)
- * ----------------------
- * Paramètres stockés par userId (via settingsStorage).
- * Ajout : bouton Déconnexion.
+ * SettingsScreen
+ * --------------
+ * - Settings par userId (via settingsStorage)
+ * - Notifications (local)
+ * - Revoir le tutoriel (remet hasSeenTutorial à false)
*/
-export default function SettingsScreen({ onLogout }: { onLogout?: () => void }) {
+export default function SettingsScreen({
+ onRequestTutorial,
+}: {
+ onRequestTutorial?: () => 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, session] = await Promise.all([loadSettings(), loadSession()]);
+ const data = await loadSettings();
setSettings(data);
- setSessionLabel(session?.email ?? "—");
}
init();
}, []);
if (settings.notificationsEnabled) {
setSettings({ ...settings, notificationsEnabled: false });
+ setInfoMessage("Notifications désactivées (pense à sauvegarder).");
return;
}
const granted = await requestNotificationPermission();
-
if (!granted) {
- setInfoMessage("Permission refusée : notifications désactivées.");
setSettings({ ...settings, notificationsEnabled: false });
+ setInfoMessage("Permission refusée : notifications désactivées.");
return;
}
- setInfoMessage("Notifications activées ✅ (pense à sauvegarder)");
setSettings({ ...settings, notificationsEnabled: true });
+ setInfoMessage("Notifications activées ✅ (pense à sauvegarder).");
};
const handleSave = async () => {
setInfoMessage("Paramètres sauvegardés ✅");
};
- const handleLogout = async () => {
- await clearSession();
- onLogout?.(); // App.tsx repasse sur Auth
+ const handleReplayTutorial = () => {
+ RNAlert.alert(
+ "Revoir le tutoriel ?",
+ "Le tutoriel sera relancé maintenant (comme au premier lancement).",
+ [
+ { text: "Annuler", style: "cancel" },
+ {
+ text: "Continuer",
+ onPress: async () => {
+ await setSeenTutorial(false);
+ onRequestTutorial?.();
+ },
+ },
+ ]
+ );
};
return (
<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>
{!!infoMessage && <Text style={[ui.muted, { marginTop: 10 }]}>{infoMessage}</Text>}
</View>
+ {/* Carte : Tutoriel */}
+ <View style={ui.card}>
+ <Text style={ui.title}>Tutoriel</Text>
+ <Text style={ui.muted}>
+ Le tutoriel explique la crypto, l’objectif de l’app et les réglages principaux.
+ </Text>
+
+ <TouchableOpacity style={[styles.secondaryButton]} onPress={handleReplayTutorial}>
+ <Text style={styles.secondaryButtonText}>Revoir le tutoriel</Text>
+ </TouchableOpacity>
+ </View>
+
{/* Bouton Save */}
<TouchableOpacity style={[ui.button, styles.fullButton, styles.saveButton]} onPress={handleSave}>
<Text style={ui.buttonText}>Sauvegarder</Text>
marginBottom: 12,
color: "#0f172a",
},
- boldInline: {
- fontWeight: "900",
- color: "#0f172a",
- },
-
fullButton: {
flexGrow: 0,
flexBasis: "auto",
marginTop: 4,
marginBottom: 10,
},
-
secondaryButton: {
marginTop: 10,
- paddingVertical: 10,
- borderRadius: 10,
+ paddingVertical: 12,
+ borderRadius: 12,
borderWidth: 1,
borderColor: "#e5e7eb",
alignItems: "center",
},
secondaryButtonText: {
fontWeight: "900",
- color: "#dc2626",
+ color: "#0f172a",
+ opacity: 0.85,
},
});
\ No newline at end of file
--- /dev/null
+import { View, Text, StyleSheet, TouchableOpacity } from "react-native";
+import { SafeAreaView } from "react-native-safe-area-context";
+import { useMemo, useState } from "react";
+import { Ionicons } from "@expo/vector-icons";
+
+import { ui } from "../components/ui/uiStyles";
+import { setSeenTutorial } from "../utils/tutorialStorage";
+
+type Slide = {
+ key: string;
+ title: string;
+ text: string;
+ icon: any;
+};
+
+export default function TutorialScreen({ onDone }: { onDone: () => void }) {
+ const slides: Slide[] = useMemo(
+ () => [
+ {
+ key: "intro",
+ title: "La crypto, c’est quoi ?",
+ text:
+ "Une cryptomonnaie est une monnaie numérique. Son prix varie selon l’offre et la demande, et peut bouger très vite.",
+ icon: "sparkles-outline",
+ },
+ {
+ key: "types",
+ title: "Types de cryptos",
+ text:
+ "Exemples :\n• BTC : la plus connue\n• ETH : écosystème de contrats\n• Stablecoins (USDT/USDC) : valeur ≈ 1 USD\n\nLes stablecoins servent souvent d’intermédiaire pour convertir.",
+ icon: "layers-outline",
+ },
+ {
+ key: "role",
+ title: "Rôle de Wall-e-tte",
+ text:
+ "Wall-e-tte est un assistant : il aide à suivre le marché, les signaux et les alertes.\n\nCe n’est PAS un conseiller financier.",
+ icon: "shield-checkmark-outline",
+ },
+ {
+ key: "app",
+ title: "Comment utiliser l’app",
+ text:
+ "• Dashboard : résumé (prix, stratégie, urgence)\n• Portefeuille : ajouter des cryptos + quantités\n• Alertes : tri + filtres (CRITICAL en priorité)\n• Historique : signaux récents",
+ icon: "apps-outline",
+ },
+ {
+ key: "settings",
+ title: "Paramètres importants",
+ text:
+ "• Stratégie : choisir la méthode d’analyse\n• Notifications : recevoir les alertes\n• Devise : EUR/USD\n\nLe tutoriel reste accessible dans Paramètres.",
+ icon: "settings-outline",
+ },
+ ],
+ []
+ );
+
+ const [index, setIndex] = useState(0);
+ const isFirst = index === 0;
+ const isLast = index === slides.length - 1;
+ const slide = slides[index];
+
+ const finish = async () => {
+ await setSeenTutorial(true);
+ onDone();
+ };
+
+ const next = () => {
+ if (!isLast) setIndex((i) => i + 1);
+ };
+
+ const prev = () => {
+ if (!isFirst) setIndex((i) => i - 1);
+ };
+
+ const skip = () => {
+ void finish();
+ };
+
+ return (
+ <SafeAreaView style={styles.safeArea} edges={["top", "bottom"]}>
+ <View style={styles.page}>
+ {/* TOP BAR */}
+ <View style={[styles.topBar, ui.container]}>
+ <Text style={styles.small}>
+ {index + 1}/{slides.length}
+ </Text>
+
+ <TouchableOpacity onPress={skip}>
+ <Text style={styles.skip}>Passer</Text>
+ </TouchableOpacity>
+ </View>
+
+ {/* CONTENT (on laisse de la place en bas pour la barre fixe) */}
+ <View style={[styles.content, ui.container]}>
+ <View style={styles.iconWrap}>
+ <Ionicons name={slide.icon} size={38} color="#0f172a" style={{ opacity: 0.85 }} />
+ </View>
+
+ <Text style={styles.title}>{slide.title}</Text>
+ <Text style={styles.text}>{slide.text}</Text>
+ </View>
+
+ {/* BOTTOM BAR FIXE */}
+ <View style={styles.bottomFixed}>
+ <View style={styles.dotsRow}>
+ {slides.map((_, i) => (
+ <View key={i} style={[styles.dot, i === index && styles.dotActive]} />
+ ))}
+ </View>
+
+ <View style={styles.bottomRow}>
+ <TouchableOpacity
+ style={[styles.secondaryBtn, isFirst && styles.disabledBtn]}
+ onPress={prev}
+ disabled={isFirst}
+ >
+ <Text style={[styles.secondaryText, isFirst && styles.disabledText]}>
+ Précédent
+ </Text>
+ </TouchableOpacity>
+
+ <TouchableOpacity
+ style={[styles.primaryBtn]}
+ onPress={isLast ? finish : next}
+ >
+ <Text style={styles.primaryText}>
+ {isLast ? "Terminer" : "Suivant"}
+ </Text>
+ </TouchableOpacity>
+ </View>
+ </View>
+ </View>
+ </SafeAreaView>
+ );
+}
+
+const styles = StyleSheet.create({
+ safeArea: { flex: 1, backgroundColor: ui.screen.backgroundColor },
+ page: { flex: 1 },
+
+ topBar: {
+ flexDirection: "row",
+ justifyContent: "space-between",
+ alignItems: "center",
+ paddingTop: 6,
+ paddingBottom: 8,
+ },
+
+ small: { fontWeight: "900", color: "#0f172a", opacity: 0.6 },
+ skip: { fontWeight: "900", color: "#0f172a", opacity: 0.75 },
+
+ // On réserve de la place pour la barre fixe
+ content: {
+ flex: 1,
+ alignItems: "center",
+ justifyContent: "center",
+ paddingBottom: 140, // ✅ réserve l’espace pour les boutons fixes
+ },
+
+ iconWrap: {
+ width: 76,
+ height: 76,
+ borderRadius: 20,
+ borderWidth: 1,
+ borderColor: "#e5e7eb",
+ backgroundColor: "#fff",
+ alignItems: "center",
+ justifyContent: "center",
+ marginBottom: 14,
+ },
+
+ title: { fontSize: 22, fontWeight: "900", color: "#0f172a", textAlign: "center" },
+ text: {
+ marginTop: 10,
+ fontSize: 14,
+ color: "#0f172a",
+ opacity: 0.75,
+ textAlign: "center",
+ lineHeight: 20,
+ },
+
+ // ✅ barre du bas fixée
+ bottomFixed: {
+ position: "absolute",
+ left: 0,
+ right: 0,
+ bottom: 0,
+ paddingHorizontal: 16,
+ paddingTop: 10,
+ paddingBottom: 16,
+ backgroundColor: ui.screen.backgroundColor,
+ borderTopWidth: 1,
+ borderTopColor: "#e5e7eb",
+ },
+
+ dotsRow: {
+ flexDirection: "row",
+ justifyContent: "center",
+ gap: 8,
+ marginBottom: 10,
+ },
+ dot: { width: 8, height: 8, borderRadius: 8, backgroundColor: "#0f172a", opacity: 0.15 },
+ dotActive: { opacity: 0.55 },
+
+ bottomRow: { flexDirection: "row", gap: 10, alignItems: "center" },
+
+ secondaryBtn: {
+ paddingVertical: 12,
+ paddingHorizontal: 14,
+ borderRadius: 12,
+ borderWidth: 1,
+ borderColor: "#e5e7eb",
+ backgroundColor: "#fff",
+ },
+ secondaryText: { fontWeight: "900", color: "#0f172a", opacity: 0.8 },
+
+ primaryBtn: {
+ flex: 1,
+ borderRadius: 12,
+ paddingVertical: 12,
+ alignItems: "center",
+ justifyContent: "center",
+ backgroundColor: "#16a34a", // ✅ un vert "crypto friendly"
+ },
+ primaryText: { color: "#fff", fontWeight: "900" },
+
+ disabledBtn: { opacity: 0.45 },
+ disabledText: { opacity: 0.6 },
+});
\ No newline at end of file
--- /dev/null
+import type { Alert } from "../../types/Alert";
+import { alertStore } from "../alertStore";
+
+/**
+ * alertsApi
+ * ---------
+ * Contrat futur (gateway):
+ * - GET /api/alerts/events?userId=...&limit=10
+ * - POST /api/alerts
+ * - POST /api/alerts/:id/toggle
+ *
+ * Pour l'instant : on lit ce qu'on a en local (alertStore alimenté par Socket).
+ */
+export async function getRecentAlerts(limit = 10): Promise<Alert[]> {
+ const all = alertStore.getAll?.() ?? [];
+ return all.slice(0, limit);
+}
+
+export async function clearLocalAlerts(): Promise<void> {
+ alertStore.clear?.();
+}
\ No newline at end of file
--- /dev/null
+import type { DashboardSummary } from "../../types/DashboardSummary";
+import { fetchDashboardSummary } from "../dashboardService";
+
+/**
+ * priceApi
+ * --------
+ * Contrat futur (gateway):
+ * - GET /api/price/current?pair=BTC/EUR
+ *
+ * Pour l'instant : on renvoie la donnée mock déjà utilisée par le Dashboard.
+ */
+export async function getCurrentPriceForDashboard(): Promise<Pick<DashboardSummary, "pair" | "price" | "timestamp">> {
+ const d = await fetchDashboardSummary();
+ return { pair: d.pair, price: d.price, timestamp: d.timestamp };
+}
\ No newline at end of file
--- /dev/null
+import type { Signal } from "../../types/Signal";
+import { fetchRecentSignals } from "../signalService";
+
+/**
+ * signalApi
+ * ---------
+ * Contrat futur (gateway):
+ * - GET /api/signal/current?userId=...&pair=BTC/EUR
+ * - (option) GET /api/signal/recent?userId=...&limit=20
+ *
+ * Pour l'instant : on utilise les mocks existants.
+ */
+export async function getRecentSignals(limit = 20): Promise<Signal[]> {
+ return await fetchRecentSignals(limit);
+}
\ No newline at end of file
--- /dev/null
+import { loadSettings, saveSettings } from "../../utils/settingsStorage";
+import type { UserSettings } from "../../models/UserSettings";
+
+/**
+ * strategyApi
+ * -----------
+ * Contrat futur (gateway):
+ * - POST /api/strategy/select { userId, pair, strategyKey, params... }
+ *
+ * Pour l'instant : on sauvegarde localement dans settingsStorage.
+ */
+export async function selectStrategy(strategyKey: string): Promise<UserSettings> {
+ const s = await loadSettings();
+ const next: UserSettings = { ...s, selectedStrategyKey: strategyKey };
+ await saveSettings(next);
+ return next;
+}
\ 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 * as Crypto from "expo-crypto";
+import type { AuthUser } from "../models/AuthUser";
+import { loadSession } from "./sessionStorage";
+
+const KEY = "authUsers";
+const LOCAL_SALT = "wall-e-tte_local_salt_v1";
+
+async function sha256(input: string): Promise<string> {
+ return await Crypto.digestStringAsync(Crypto.CryptoDigestAlgorithm.SHA256, input);
+}
+
+export async function hashPassword(password: string): Promise<string> {
+ const normalized = password.trim();
+ return await sha256(`${LOCAL_SALT}:${normalized}`);
+}
+
+export async function loadUsers(): Promise<AuthUser[]> {
+ const raw = await AsyncStorage.getItem(KEY);
+ if (!raw) return [];
+
+ try {
+ const parsed = JSON.parse(raw) as AuthUser[];
+ return Array.isArray(parsed) ? parsed : [];
+ } catch {
+ return [];
+ }
+}
+
+async function saveUsers(users: AuthUser[]): Promise<void> {
+ await AsyncStorage.setItem(KEY, JSON.stringify(users));
+}
+
+export async function findUserByLogin(login: string): Promise<AuthUser | null> {
+ const users = await loadUsers();
+ const key = login.trim().toLowerCase();
+
+ const found = users.find(
+ (u) => u.email.toLowerCase() === key || u.username.toLowerCase() === key
+ );
+
+ return found ?? null;
+}
+
+export async function findUserById(userId: string): Promise<AuthUser | null> {
+ const users = await loadUsers();
+ return users.find((u) => u.userId === userId) ?? null;
+}
+
+export async function createUser(params: {
+ email: string;
+ username: string;
+ displayName?: string;
+ password: string;
+}): Promise<{ ok: true; user: AuthUser } | { ok: false; message: string }> {
+ const email = params.email.trim().toLowerCase();
+ const username = params.username.trim().toLowerCase();
+ const displayName = (params.displayName ?? "").trim();
+
+ const users = await loadUsers();
+
+ if (users.some((u) => u.email.toLowerCase() === email)) {
+ return { ok: false, message: "Cet email est déjà utilisé." };
+ }
+ if (users.some((u) => u.username.toLowerCase() === username)) {
+ return { ok: false, message: "Ce nom d’utilisateur est déjà utilisé." };
+ }
+
+ const passwordHash = await hashPassword(params.password);
+
+ const user: AuthUser = {
+ userId: `user_${username}`,
+ email,
+ username,
+ displayName: displayName || undefined,
+ passwordHash,
+ createdAtMs: Date.now(),
+ };
+
+ await saveUsers([...users, user]);
+ return { ok: true, user };
+}
+
+export async function verifyLogin(params: {
+ login: string;
+ password: string;
+}): Promise<{ ok: true; user: AuthUser } | { ok: false; message: string }> {
+ const user = await findUserByLogin(params.login);
+
+ if (!user) return { ok: false, message: "Compte introuvable." };
+
+ const hash = await hashPassword(params.password);
+
+ if (hash !== user.passwordHash) {
+ return { ok: false, message: "Mot de passe incorrect." };
+ }
+
+ return { ok: true, user };
+}
+
+/**
+ * Update profil (local, Step 4 sans API)
+ * - email NON modifiable ici (on le garde tel quel)
+ * - username NON modifiable ici (sinon ça casse userId local)
+ * - displayName modifiable
+ * - password modifiable (hashé)
+ *
+ * Plus tard : remplacer par un appel REST vers le serveur.
+ */
+export async function updateCurrentUserProfile(params: {
+ displayName?: string;
+ newPassword?: string;
+}): Promise<{ ok: true; user: AuthUser } | { ok: false; message: string }> {
+ const session = await loadSession();
+ if (!session) return { ok: false, message: "Session absente." };
+
+ const users = await loadUsers();
+ const idx = users.findIndex((u) => u.userId === session.userId);
+ if (idx < 0) return { ok: false, message: "Utilisateur introuvable." };
+
+ const current = users[idx];
+
+ let next: AuthUser = { ...current };
+
+ // displayName (optionnel)
+ if (typeof params.displayName === "string") {
+ const dn = params.displayName.trim();
+ next.displayName = dn ? dn : undefined;
+ }
+
+ // mot de passe (optionnel)
+ if (params.newPassword) {
+ if (params.newPassword.trim().length < 6) {
+ return { ok: false, message: "Mot de passe trop court (min 6)." };
+ }
+ next.passwordHash = await hashPassword(params.newPassword);
+ }
+
+ users[idx] = next;
+ await saveUsers(users);
+
+ return { ok: true, user: next };
+}
\ No newline at end of file
--- /dev/null
+import AsyncStorage from "@react-native-async-storage/async-storage";
+import { loadSession } from "./sessionStorage";
+
+/**
+ * Tutoriel par utilisateur (Step 4)
+ * - clé = hasSeenTutorial:<userId>
+ * - permet d'afficher le tuto au premier lancement ET par compte
+ */
+function keyFor(userId: string) {
+ return `hasSeenTutorial:${userId}`;
+}
+
+export async function hasSeenTutorial(): Promise<boolean> {
+ const session = await loadSession();
+ if (!session) return true; // sécurité : si pas de session, on ne bloque pas l'app
+
+ const raw = await AsyncStorage.getItem(keyFor(session.userId));
+ return raw === "1";
+}
+
+export async function setSeenTutorial(value: boolean): Promise<void> {
+ const session = await loadSession();
+ if (!session) return;
+
+ await AsyncStorage.setItem(keyFor(session.userId), value ? "1" : "0");
+}
\ No newline at end of file