From a6b18a81695cbc18682f9747dedc6ec72ef34858 Mon Sep 17 00:00:00 2001 From: Thibaud Moustier Date: Fri, 27 Feb 2026 20:01:32 +0100 Subject: [PATCH] "Mobile : Ajout d'un tutoriel user friendly" --- Wallette/mobile/App.tsx | 118 +++++++++-- Wallette/mobile/src/screens/AboutScreen.tsx | 184 ++++++------------ .../mobile/src/screens/SettingsScreen.tsx | 82 ++++---- .../mobile/src/screens/TutorialScreen.tsx | 184 ++++++++++++++++++ Wallette/mobile/src/utils/tutorialStorage.ts | 26 +++ 5 files changed, 421 insertions(+), 173 deletions(-) create mode 100644 Wallette/mobile/src/screens/TutorialScreen.tsx create mode 100644 Wallette/mobile/src/utils/tutorialStorage.ts diff --git a/Wallette/mobile/App.tsx b/Wallette/mobile/App.tsx index d33c4c3..1eb6981 100644 --- a/Wallette/mobile/App.tsx +++ b/Wallette/mobile/App.tsx @@ -1,4 +1,7 @@ -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"; @@ -10,13 +13,18 @@ import HistoryScreen from "./src/screens/HistoryScreen"; import AlertsScreen from "./src/screens/AlertsScreen"; import StrategyScreen from "./src/screens/StrategyScreen"; import WalletScreen from "./src/screens/WalletScreen"; + import AuthScreen from "./src/screens/AuthScreen"; +import 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; @@ -30,15 +38,21 @@ export type RootStackParamList = { const Stack = createNativeStackNavigator(); -// ✅ navigationRef (permet de naviguer hors des screens, ex: depuis un Modal) +// ✅ navigationRef (navigate depuis le Modal AccountMenu) export const navigationRef = createNavigationContainerRef(); export default function App() { const [ready, setReady] = useState(false); + + // Auth state const [isAuthed, setIsAuthed] = useState(false); + const [sessionEmail, setSessionEmail] = useState("—"); + + // Tutorial gate + const [needsTutorial, setNeedsTutorial] = useState(false); + // Account menu const [menuVisible, setMenuVisible] = useState(false); - const [sessionEmail, setSessionEmail] = useState("—"); useEffect(() => { let active = true; @@ -47,8 +61,18 @@ export default function App() { 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); } @@ -69,13 +93,13 @@ export default function App() { await clearSession(); setIsAuthed(false); setSessionEmail("—"); + setNeedsTutorial(false); }, }, ]); }; const go = (route: keyof RootStackParamList) => { - // ✅ safe guard : navigation prête ? if (!navigationRef.isReady()) return; navigationRef.navigate(route); }; @@ -88,7 +112,7 @@ export default function App() { ); } - // Pas connecté -> écran Auth + // 1) Pas connecté -> Auth (hors stack) if (!isAuthed) { return ( + ); + } + + // 2) Connecté mais tuto pas vu -> Tutoriel (hors stack) + if (needsTutorial) { + return ( + { + setNeedsTutorial(false); }} /> ); } + // 3) Connecté + tuto vu -> App normale return ( - {/* ✅ Modal menu Compte (navigue grâce au navigationRef) */} {/* 👤 Menu Compte */} setMenuVisible(true)}> - + {/* ⚙️ Paramètres */} - navigation.navigate("Settings")}> - + navigation.navigate("Settings")} + > + ), })} /> - - - - + + + + - + + {() => ( + { + // Force le tuto à s'afficher hors stack + setNeedsTutorial(true); + }} + /> + )} + - - + + ); diff --git a/Wallette/mobile/src/screens/AboutScreen.tsx b/Wallette/mobile/src/screens/AboutScreen.tsx index ab72035..f6ddadc 100644 --- a/Wallette/mobile/src/screens/AboutScreen.tsx +++ b/Wallette/mobile/src/screens/AboutScreen.tsx @@ -1,138 +1,77 @@ -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"; - -/** - * AccountScreen (Step 4 - sans API) - * -------------------------------- - * UI prête pour email + mot de passe. - * En réalité : - * - on ne stocke pas le mdp - * - on sauvegarde seulement displayName + email (local) - * - * Quand l'API arrive : POST /auth/update-profile + update password. - */ -export default function AccountScreen() { - const [profile, setProfile] = useState(null); - const [userId, setUserId] = useState(""); - - const [displayName, setDisplayName] = useState(""); - const [email, setEmail] = useState(""); +import { loadAccountProfile } from "../utils/accountStorage"; - // Champs UI (non persistés) pour mot de passe - const [password, setPassword] = useState(""); - const [password2, setPassword2] = useState(""); - - const [info, setInfo] = useState(null); +export default function AboutScreen() { + const [email, setEmail] = useState("—"); + const [displayName, setDisplayName] = useState("Utilisateur"); useEffect(() => { async function init() { - const [session, p] = await Promise.all([loadSession(), loadAccountProfile()]); - setUserId(session?.userId ?? ""); - setProfile(p); - setDisplayName(p.displayName); - setEmail(p.email); + const [session, profile] = await Promise.all([loadSession(), loadAccountProfile()]); + setEmail(profile?.email ?? session?.email ?? "—"); + setDisplayName(profile?.displayName ?? "Utilisateur"); } init(); }, []); - if (!profile) { - return ( - - Chargement du compte… + const Row = ({ icon, title, subtitle }: { icon: any; title: string; subtitle?: string }) => ( + + + + {title} + {!!subtitle && {subtitle}} - ); - } - - const handleSave = async () => { - setInfo(null); - - const normalizedEmail = email.trim().toLowerCase(); - const isValidEmail = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(normalizedEmail); - - if (!isValidEmail) { - setInfo("Email invalide."); - return; - } - - // Mot de passe : UI prête, mais pas stocké sans API - if (password || password2) { - if (password.length < 6) { - setInfo("Mot de passe trop court (min 6)."); - return; - } - if (password !== password2) { - setInfo("Les mots de passe ne correspondent pas."); - return; - } - - // Sans API : on ne peut pas réellement changer le mdp - RNAlert.alert( - "Mot de passe", - "Sans API, ce changement n’est pas appliqué (UI prête).", - [{ text: "OK" }] - ); - } - - const updated: AccountProfile = { - displayName: displayName.trim() || "Utilisateur", - email: normalizedEmail, - updatedAtMs: Date.now(), - }; - - await saveAccountProfile(updated); - setProfile(updated); - setPassword(""); - setPassword2(""); - setInfo("Profil sauvegardé ✅"); - }; + + + ); return ( - Modification du compte + Détails du compte + {/* Carte profil (style WF-09) */} - Informations - - UserId (session) - {userId || "—"} - - Nom affiché - - - Email - + + + + {displayName} + {email} + + + {/* À propos */} - Mot de passe (UI prête) - - Le changement réel du mot de passe sera branché quand l’API auth sera disponible. - + À propos - Nouveau mot de passe - + + + - Confirmation - + {/* Support */} + + Support + + - {!!info && {info}} + {/* Version */} + + Version + App : 1.0.0 + Build : Step 4 (sans API) + - - Sauvegarder + {/* Bouton discret */} + + Voir les licences (optionnel) @@ -142,23 +81,28 @@ export default function AccountScreen() { 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, + profileRow: { flexDirection: "row", alignItems: "center", gap: 12 }, + profileName: { fontSize: 18, fontWeight: "900", color: "#0f172a" }, + + row: { + flexDirection: "row", + alignItems: "center", + gap: 10, + paddingVertical: 12, + borderTopWidth: 1, + borderTopColor: "#e5e7eb", }, + rowTitle: { fontWeight: "900", color: "#0f172a" }, - input: { + 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 diff --git a/Wallette/mobile/src/screens/SettingsScreen.tsx b/Wallette/mobile/src/screens/SettingsScreen.tsx index 4f753fb..aa1540a 100644 --- a/Wallette/mobile/src/screens/SettingsScreen.tsx +++ b/Wallette/mobile/src/screens/SettingsScreen.tsx @@ -1,4 +1,4 @@ -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"; @@ -6,26 +6,29 @@ import { loadSettings, saveSettings } from "../utils/settingsStorage"; import type { UserSettings } from "../models/UserSettings"; import { requestNotificationPermission } from "../services/notificationService"; -import { clearSession, loadSession } from "../utils/sessionStorage"; import { ui } from "../components/ui/uiStyles"; +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(null); const [infoMessage, setInfoMessage] = useState(null); - const [sessionLabel, setSessionLabel] = useState(""); useEffect(() => { async function init() { - const [data, session] = await Promise.all([loadSettings(), loadSession()]); + const data = await loadSettings(); setSettings(data); - setSessionLabel(session?.email ?? "—"); } init(); }, []); @@ -53,19 +56,19 @@ export default function SettingsScreen({ onLogout }: { onLogout?: () => void }) 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 () => { @@ -73,9 +76,21 @@ export default function SettingsScreen({ onLogout }: { onLogout?: () => void }) 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 ( @@ -83,16 +98,6 @@ export default function SettingsScreen({ onLogout }: { onLogout?: () => void }) Paramètres - {/* Carte session */} - - Compte - Connecté : {sessionLabel} - - - Se déconnecter - - - {/* Carte : Devise */} Devise @@ -127,6 +132,18 @@ export default function SettingsScreen({ onLogout }: { onLogout?: () => void }) {!!infoMessage && {infoMessage}} + {/* Carte : Tutoriel */} + + Tutoriel + + Le tutoriel explique la crypto, l’objectif de l’app et les réglages principaux. + + + + Revoir le tutoriel + + + {/* Bouton Save */} Sauvegarder @@ -147,11 +164,6 @@ const styles = StyleSheet.create({ marginBottom: 12, color: "#0f172a", }, - boldInline: { - fontWeight: "900", - color: "#0f172a", - }, - fullButton: { flexGrow: 0, flexBasis: "auto", @@ -162,11 +174,10 @@ const styles = StyleSheet.create({ marginTop: 4, marginBottom: 10, }, - secondaryButton: { marginTop: 10, - paddingVertical: 10, - borderRadius: 10, + paddingVertical: 12, + borderRadius: 12, borderWidth: 1, borderColor: "#e5e7eb", alignItems: "center", @@ -174,6 +185,7 @@ const styles = StyleSheet.create({ }, secondaryButtonText: { fontWeight: "900", - color: "#dc2626", + color: "#0f172a", + opacity: 0.85, }, }); \ No newline at end of file diff --git a/Wallette/mobile/src/screens/TutorialScreen.tsx b/Wallette/mobile/src/screens/TutorialScreen.tsx new file mode 100644 index 0000000..5ffe6f7 --- /dev/null +++ b/Wallette/mobile/src/screens/TutorialScreen.tsx @@ -0,0 +1,184 @@ +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 ( + + + {/* Top bar */} + + + {index + 1}/{slides.length} + + + + Passer + + + + {/* Slide content (sans swipe) */} + + + + + + {slide.title} + {slide.text} + + + {/* Dots */} + + {slides.map((_, i) => ( + + ))} + + + {/* Bottom buttons */} + + + Précédent + + + + {isLast ? "Terminer" : "Suivant"} + + + + + ); +} + +const styles = StyleSheet.create({ + safeArea: { flex: 1, backgroundColor: ui.screen.backgroundColor }, + + topRow: { flexDirection: "row", justifyContent: "space-between", alignItems: "center" }, + small: { fontWeight: "900", color: "#0f172a", opacity: 0.6 }, + skip: { fontWeight: "900", color: "#0f172a", opacity: 0.75 }, + + slide: { flex: 1, alignItems: "center", justifyContent: "center", paddingHorizontal: 22 }, + 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, + }, + + dotsRow: { flexDirection: "row", justifyContent: "center", gap: 8, marginBottom: 12, marginTop: 6 }, + dot: { width: 8, height: 8, borderRadius: 8, backgroundColor: "#0f172a", opacity: 0.15 }, + dotActive: { opacity: 0.55 }, + + bottomRow: { flexDirection: "row", gap: 10, alignItems: "center", paddingBottom: 8 }, + + secondaryBtn: { + paddingVertical: 12, + paddingHorizontal: 14, + borderRadius: 12, + borderWidth: 1, + borderColor: "#e5e7eb", + backgroundColor: "#fff", + }, + secondaryText: { fontWeight: "900", color: "#0f172a", opacity: 0.8 }, + + primaryBtn: { flex: 1, flexGrow: 0, flexBasis: "auto" }, + + disabledBtn: { opacity: 0.45 }, + disabledText: { opacity: 0.6 }, +}); \ No newline at end of file diff --git a/Wallette/mobile/src/utils/tutorialStorage.ts b/Wallette/mobile/src/utils/tutorialStorage.ts new file mode 100644 index 0000000..a828708 --- /dev/null +++ b/Wallette/mobile/src/utils/tutorialStorage.ts @@ -0,0 +1,26 @@ +import AsyncStorage from "@react-native-async-storage/async-storage"; +import { loadSession } from "./sessionStorage"; + +/** + * Tutoriel par utilisateur (Step 4) + * - clé = hasSeenTutorial: + * - permet d'afficher le tuto au premier lancement ET par compte + */ +function keyFor(userId: string) { + return `hasSeenTutorial:${userId}`; +} + +export async function hasSeenTutorial(): Promise { + 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 { + const session = await loadSession(); + if (!session) return; + + await AsyncStorage.setItem(keyFor(session.userId), value ? "1" : "0"); +} \ No newline at end of file -- 2.50.1