From: Thibaud Moustier Date: Wed, 25 Feb 2026 17:00:38 +0000 (+0100) Subject: Mobile : Portfolio created X-Git-Url: https://git.digitality.be/?a=commitdiff_plain;h=3826a9fd2b8c54570507f490fdae9391e116cfb2;p=pdw25-26 Mobile : Portfolio created --- diff --git a/Wallette/mobile/src/mocks/prices.mock.ts b/Wallette/mobile/src/mocks/prices.mock.ts new file mode 100644 index 0000000..d93941c --- /dev/null +++ b/Wallette/mobile/src/mocks/prices.mock.ts @@ -0,0 +1,16 @@ +/** + * Mock prices (Step 3) + * -------------------- + * En attendant l'API REST /api/price/current. + */ +export const mockPrices: Record = { + BTC: 42150.23, + ETH: 2300.55, + SOL: 105.12, + ADA: 0.48, +}; + +export function getMockPrice(symbol: string): number | null { + const key = symbol.toUpperCase().trim(); + return typeof mockPrices[key] === "number" ? mockPrices[key] : null; +} \ No newline at end of file diff --git a/Wallette/mobile/src/models/Portfolio.ts b/Wallette/mobile/src/models/Portfolio.ts new file mode 100644 index 0000000..52f0f34 --- /dev/null +++ b/Wallette/mobile/src/models/Portfolio.ts @@ -0,0 +1,15 @@ +/** + * Portfolio (Step 3) + * ------------------ + * Mono-user / multi-cryptos. + * Une ligne = un asset (BTC, ETH, etc.) + quantité. + */ +export interface PortfolioAsset { + symbol: string; // ex: "BTC" + quantity: number; // ex: 0.25 +} + +export interface PortfolioState { + assets: PortfolioAsset[]; + updatedAtMs: number; +} \ No newline at end of file diff --git a/Wallette/mobile/src/screens/DashboardScreen.tsx b/Wallette/mobile/src/screens/DashboardScreen.tsx index 8bd123a..f165513 100644 --- a/Wallette/mobile/src/screens/DashboardScreen.tsx +++ b/Wallette/mobile/src/screens/DashboardScreen.tsx @@ -24,12 +24,16 @@ import type { Alert } from "../types/Alert"; import { alertStore } from "../services/alertStore"; import { showAlertNotification } from "../services/notificationService"; -import { loadWallet } from "../utils/walletStorage"; -import type { WalletState } from "../models/Wallet"; +// ✅ Step 3 portfolio +import { loadPortfolio } from "../utils/portfolioStorage"; +import type { PortfolioState, PortfolioAsset } from "../models/Portfolio"; +import { getMockPrice } from "../mocks/prices.mock"; /** - * DashboardScreen (WF-01) — Responsive + No-scroll goal - * ---------------------------------------------------- + * DashboardScreen (WF-01) — Step 3 + * -------------------------------- + * - Portefeuille multi-cryptos (portfolioStep3, AsyncStorage) + * - Valeur globale = somme(quantity * prix mock) * - Cartes cliquables (chevron subtil) : * * Portefeuille -> Wallet * * Urgence -> Alertes @@ -43,7 +47,9 @@ export default function DashboardScreen() { const [summary, setSummary] = useState(null); const [settings, setSettings] = useState(null); - const [wallet, setWallet] = useState(null); + + // ✅ Step 3 portfolio + const [portfolio, setPortfolio] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -58,6 +64,10 @@ export default function DashboardScreen() { 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; @@ -67,17 +77,18 @@ export default function DashboardScreen() { setError(null); setLoading(true); - const [dashboardData, userSettings, walletData] = await Promise.all([ + const [dashboardData, userSettings, portfolioData] = await Promise.all([ fetchDashboardSummary(), loadSettings(), - loadWallet(), + loadPortfolio(), ]); if (!isActive) return; setSummary(dashboardData); setSettings(userSettings); - setWallet(walletData); + setPortfolio(portfolioData); + setLastRefreshMs(Date.now()); } catch { if (isActive) setError("Impossible de charger le dashboard."); @@ -94,6 +105,9 @@ export default function DashboardScreen() { }, []) ); + /** + * Refresh auto (si activé) + */ useEffect(() => { if (!settings) return; if (settings.refreshMode !== "auto") return; @@ -118,6 +132,9 @@ export default function DashboardScreen() { }; }, [settings]); + /** + * Refresh manuel + */ const handleManualRefresh = async () => { try { setRefreshing(true); @@ -132,6 +149,10 @@ export default function DashboardScreen() { } }; + /** + * Socket.IO (non bloquant) + * Step 1/2/3 : userId de test + */ useEffect(() => { if (!settings) return; @@ -172,6 +193,9 @@ export default function DashboardScreen() { }; }, [settings]); + /** + * Urgence : 1 seule alerte, la plus importante. + */ const urgentAlert: Alert | null = useMemo(() => { if (liveAlerts.length === 0) return null; @@ -184,11 +208,44 @@ export default function DashboardScreen() { return liveAlerts[0]; }, [liveAlerts]); - const walletTotalValue = useMemo(() => { - if (!wallet || !summary) return null; - return wallet.quantity * summary.price; - }, [wallet, summary]); + /** + * Valeur globale du portefeuille (Step 3) + * total = somme(quantity * prix(mock)) + */ + const portfolioTotalValue = useMemo(() => { + if (!portfolio) return 0; + + return portfolio.assets.reduce((sum, a) => { + const price = getMockPrice(a.symbol); + if (price === null) return sum; + return sum + a.quantity * price; + }, 0); + }, [portfolio]); + + /** + * 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]); + + const remainingCount = useMemo(() => { + if (!portfolio) return 0; + return Math.max(0, portfolio.assets.length - topAssets.length); + }, [portfolio, topAssets]); + + // Loading / erreurs bloquantes if (loading) { return ( @@ -205,7 +262,7 @@ export default function DashboardScreen() { ); } - if (!summary || !settings || !wallet) { + if (!summary || !settings || !portfolio) { return ( Initialisation… @@ -214,7 +271,12 @@ export default function DashboardScreen() { } const Chevron = () => ( - + ); return ( @@ -253,7 +315,7 @@ export default function DashboardScreen() { - {/* 2) PORTEFEUILLE — cliquable => Wallet */} + {/* 2) PORTEFEUILLE — cliquable => Wallet (Step 3) */} navigation.navigate("Wallet" as never)}> @@ -262,19 +324,37 @@ export default function DashboardScreen() { - Quantité BTC : - {wallet.quantity.toFixed(6)} BTC - - - - Valeur Totale : + Valeur globale : - {walletTotalValue !== null ? `${walletTotalValue.toFixed(2)} ${settings.currency}` : "—"} + {portfolioTotalValue.toFixed(2)} {settings.currency} - - BTC @ {summary.price.toFixed(2)} {settings.currency} + {/* Résumé assets */} + {portfolio.assets.length === 0 ? ( + + Aucun asset (ajoute BTC/ETH/SOL dans Portefeuille) + + ) : ( + + {topAssets.map((a) => ( + + {a.symbol} + {a.quantity.toFixed(6)} + + ))} + + {remainingCount > 0 && ( + + +{remainingCount} autre(s) asset(s) + + )} + + )} + + {/* Ligne informative BTC @ prix (mock) */} + + BTC @ {summary.price.toFixed(2)} {settings.currency} (source mock) @@ -317,7 +397,11 @@ export default function DashboardScreen() { @@ -404,7 +488,6 @@ const styles = StyleSheet.create({ marginTop: 10, }, - // ✅ header row + chevron icon headerRow: { flexDirection: "row", justifyContent: "space-between", diff --git a/Wallette/mobile/src/screens/WalletScreen.tsx b/Wallette/mobile/src/screens/WalletScreen.tsx index 0768379..0368f01 100644 --- a/Wallette/mobile/src/screens/WalletScreen.tsx +++ b/Wallette/mobile/src/screens/WalletScreen.tsx @@ -1,57 +1,55 @@ -import { View, Text, StyleSheet, TouchableOpacity, TextInput, Alert as RNAlert } from "react-native"; -import { useMemo, useState } from "react"; +import { + View, + Text, + StyleSheet, + TouchableOpacity, + TextInput, + Alert as RNAlert, + FlatList, +} from "react-native"; +import { useCallback, useMemo, useState } from "react"; import { SafeAreaView } from "react-native-safe-area-context"; import { useFocusEffect } from "@react-navigation/native"; -import { useCallback } from "react"; import { ui } from "../components/ui/uiStyles"; -import { loadWallet, saveWallet, clearWallet } from "../utils/walletStorage"; -import type { WalletState } from "../models/Wallet"; -import { fetchDashboardSummary } from "../services/dashboardService"; +import type { PortfolioAsset, PortfolioState } from "../models/Portfolio"; +import { loadPortfolio, savePortfolio, clearPortfolio } from "../utils/portfolioStorage"; +import { getMockPrice } from "../mocks/prices.mock"; import { loadSettings } from "../utils/settingsStorage"; import type { UserSettings } from "../models/UserSettings"; /** - * WalletScreen (WF-03 Step 1) - * --------------------------- - * Mono-utilisateur / mono-crypto : BTC uniquement. - * - L'utilisateur encode la quantité de BTC qu'il possède - * - On calcule la valeur estimée via le prix BTC du dashboard - * - Stockage local (AsyncStorage) pour ne pas dépendre de l'API + * WalletScreen (Step 3) + * --------------------- + * Mono-user / multi-cryptos + * - liste d'assets (BTC, ETH, SOL...) + * - quantité par asset + * - valeur globale du portefeuille + * + * Aujourd'hui : prix mock + * Demain : prix via GET /api/price/current?pair=XXX/EUR */ export default function WalletScreen() { - const [wallet, setWallet] = useState(null); + const [portfolio, setPortfolio] = useState(null); const [settings, setSettings] = useState(null); - // Prix BTC actuel (mock aujourd'hui, API demain) - const [btcPrice, setBtcPrice] = useState(null); - - // input texte (évite les bugs de virgule/points) + // Ajout asset + const [symbolInput, setSymbolInput] = useState("BTC"); const [qtyInput, setQtyInput] = useState("0"); const [info, setInfo] = useState(null); - // Recharge quand l’écran reprend le focus (retour depuis autre page) useFocusEffect( useCallback(() => { let active = true; async function init() { setInfo(null); - - const [w, s, dash] = await Promise.all([ - loadWallet(), - loadSettings(), - fetchDashboardSummary(), - ]); - + const [p, s] = await Promise.all([loadPortfolio(), loadSettings()]); if (!active) return; - setWallet(w); + setPortfolio(p); setSettings(s); - setBtcPrice(dash.price); - - setQtyInput(String(w.quantity)); } init(); @@ -62,6 +60,11 @@ export default function WalletScreen() { }, []) ); + const lastUpdatedLabel = useMemo(() => { + if (!portfolio) return "—"; + return new Date(portfolio.updatedAtMs).toLocaleString(); + }, [portfolio]); + const parsedQty = useMemo(() => { const normalized = qtyInput.replace(",", ".").trim(); const val = Number(normalized); @@ -70,49 +73,101 @@ export default function WalletScreen() { return val; }, [qtyInput]); + const normalizedSymbol = useMemo(() => symbolInput.toUpperCase().trim(), [symbolInput]); + const totalValue = useMemo(() => { - if (parsedQty === null || btcPrice === null) return null; - return parsedQty * btcPrice; - }, [parsedQty, btcPrice]); + if (!portfolio) return 0; - const lastUpdatedLabel = useMemo(() => { - if (!wallet) return "—"; - return new Date(wallet.updatedAtMs).toLocaleString(); - }, [wallet]); + return portfolio.assets.reduce((sum, a) => { + const price = getMockPrice(a.symbol); + if (price === null) return sum; + return sum + a.quantity * price; + }, 0); + }, [portfolio]); - const handleSave = async () => { - if (!wallet) return; + const handleAddOrUpdate = async () => { + if (!portfolio) return; + + setInfo(null); + + if (!normalizedSymbol || normalizedSymbol.length < 2) { + setInfo("Symbole invalide (ex: BTC)."); + return; + } if (parsedQty === null) { setInfo("Quantité invalide. Exemple : 0.25"); return; } - const updated: WalletState = { - ...wallet, - quantity: parsedQty, + const price = getMockPrice(normalizedSymbol); + if (price === null) { + setInfo("Prix inconnu (mock). Essayez: BTC, ETH, SOL, ADA."); + return; + } + + const existingIndex = portfolio.assets.findIndex((a) => a.symbol === normalizedSymbol); + + let updatedAssets: PortfolioAsset[]; + if (existingIndex >= 0) { + // update + updatedAssets = portfolio.assets.map((a) => + a.symbol === normalizedSymbol ? { ...a, quantity: parsedQty } : a + ); + } else { + // add + updatedAssets = [...portfolio.assets, { symbol: normalizedSymbol, quantity: parsedQty }]; + } + + const updated: PortfolioState = { + assets: updatedAssets, updatedAtMs: Date.now(), }; - await saveWallet(updated); - setWallet(updated); - setInfo("Portefeuille sauvegardé ✅"); + await savePortfolio(updated); + setPortfolio(updated); + + setInfo(existingIndex >= 0 ? "Asset mis à jour ✅" : "Asset ajouté ✅"); + }; + + const handleDelete = (symbol: string) => { + if (!portfolio) return; + + RNAlert.alert( + `Supprimer ${symbol} ?`, + "Cette action retire l’asset du portefeuille local.", + [ + { text: "Annuler", style: "cancel" }, + { + text: "Supprimer", + style: "destructive", + onPress: async () => { + const updated: PortfolioState = { + assets: portfolio.assets.filter((a) => a.symbol !== symbol), + updatedAtMs: Date.now(), + }; + await savePortfolio(updated); + setPortfolio(updated); + setInfo(`${symbol} supprimé ✅`); + }, + }, + ] + ); }; const handleClear = () => { RNAlert.alert( "Réinitialiser le portefeuille ?", - "Cela remet la quantité BTC à 0 (stockage local).", + "Cela supprime tous les assets du stockage local.", [ { text: "Annuler", style: "cancel" }, { text: "Réinitialiser", style: "destructive", onPress: async () => { - await clearWallet(); - const fresh = await loadWallet(); - setWallet(fresh); - setQtyInput("0"); + await clearPortfolio(); + const fresh = await loadPortfolio(); + setPortfolio(fresh); setInfo("Portefeuille réinitialisé ✅"); }, }, @@ -120,7 +175,7 @@ export default function WalletScreen() { ); }; - if (!wallet || !settings) { + if (!portfolio || !settings) { return ( Chargement du portefeuille… @@ -130,62 +185,113 @@ export default function WalletScreen() { return ( - - Portefeuille - - {/* Carte BTC */} - - BTC - - Quantité détenue - - - - - Prix BTC actuel :{" "} - - {btcPrice !== null ? `${btcPrice.toFixed(2)} ${settings.currency}` : "—"} + it.symbol} + ListHeaderComponent={ + + Portefeuille + + {/* Résumé global */} + + Résumé + + + Valeur globale : + + {totalValue.toFixed(2)} {settings.currency} + + + + + Dernière mise à jour :{" "} + {lastUpdatedLabel} + + + + Réinitialiser + + + + {/* Ajouter / modifier */} + + Ajouter / Modifier un asset + + Symbole (ex: BTC, ETH, SOL, ADA) + + + Quantité + + + + Enregistrer + + + {!!info && {info}} + + Prix utilisés = mock pour Step 3 (API à brancher plus tard). + + + + + Liste des assets : - - - - Valeur estimée :{" "} - - {totalValue !== null ? `${totalValue.toFixed(2)} ${settings.currency}` : "—"} - - - - {/* ✅ Dernière mise à jour */} - - Dernière mise à jour :{" "} - {lastUpdatedLabel} - - - - Enregistrer - - - - Réinitialiser - - - {!!info && {info}} - - - {/* Carte info Step */} - - Step 1 - - Mono-utilisateur / mono-crypto (BTC). Step 3 : portefeuille multi-cryptos + valeur globale. - - - + + } + ListEmptyComponent={ + + Aucun asset + Ajoutez BTC/ETH/SOL… pour commencer. + + } + renderItem={({ item }) => { + const price = getMockPrice(item.symbol); + const value = price !== null ? item.quantity * price : null; + + return ( + + + {item.symbol} + handleDelete(item.symbol)}> + Supprimer + + + + + Quantité + {item.quantity.toFixed(6)} + + + + Prix (mock) + + {price !== null ? `${price.toFixed(2)} ${settings.currency}` : "—"} + + + + + Valeur + + {value !== null ? `${value.toFixed(2)} ${settings.currency}` : "—"} + + + + ); + }} + /> ); } @@ -237,4 +343,9 @@ const styles = StyleSheet.create({ fontWeight: "900", color: "#dc2626", }, + + deleteText: { + fontWeight: "900", + color: "#dc2626", + }, }); \ No newline at end of file diff --git a/Wallette/mobile/src/utils/portfolioStorage.ts b/Wallette/mobile/src/utils/portfolioStorage.ts new file mode 100644 index 0000000..887ed8c --- /dev/null +++ b/Wallette/mobile/src/utils/portfolioStorage.ts @@ -0,0 +1,40 @@ +import AsyncStorage from "@react-native-async-storage/async-storage"; +import type { PortfolioState } from "../models/Portfolio"; + +const KEY = "portfolioStep3"; + +/** + * Portfolio par défaut : vide + */ +const DEFAULT_PORTFOLIO: PortfolioState = { + assets: [], + updatedAtMs: Date.now(), +}; + +export async function loadPortfolio(): Promise { + const raw = await AsyncStorage.getItem(KEY); + if (!raw) return DEFAULT_PORTFOLIO; + + try { + const parsed = JSON.parse(raw) as Partial; + return { + ...DEFAULT_PORTFOLIO, + ...parsed, + assets: Array.isArray(parsed.assets) ? parsed.assets : [], + }; + } catch { + return DEFAULT_PORTFOLIO; + } +} + +export async function savePortfolio(portfolio: PortfolioState): Promise { + const safe: PortfolioState = { + assets: Array.isArray(portfolio.assets) ? portfolio.assets : [], + updatedAtMs: Date.now(), + }; + await AsyncStorage.setItem(KEY, JSON.stringify(safe)); +} + +export async function clearPortfolio(): Promise { + await AsyncStorage.removeItem(KEY); +} \ No newline at end of file