feat: iphone app

This commit is contained in:
leon
2026-09-09 10:12:49 +02:00
parent 8666f6eb8f
commit 4f4f6f4c71
30 changed files with 13379 additions and 23 deletions
+30
View File
@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>API_KEY</key>
<string>AIzaSyCcoyvOKXmZisfcjiEYkNl3o5-_5ajiiN4</string>
<key>GCM_SENDER_ID</key>
<string>167940133580</string>
<key>PLIST_VERSION</key>
<string>1</string>
<key>BUNDLE_ID</key>
<string>com.haptika.mobile.dev</string>
<key>PROJECT_ID</key>
<string>haptika-d13d3</string>
<key>STORAGE_BUCKET</key>
<string>haptika-d13d3.firebasestorage.app</string>
<key>IS_ADS_ENABLED</key>
<false></false>
<key>IS_ANALYTICS_ENABLED</key>
<false></false>
<key>IS_APPINVITE_ENABLED</key>
<true></true>
<key>IS_GCM_ENABLED</key>
<true></true>
<key>IS_SIGNIN_ENABLED</key>
<true></true>
<key>GOOGLE_APP_ID</key>
<string>1:167940133580:ios:0dfa5b8ff3f4b682896869</string>
</dict>
</plist>
+7 -1
View File
@@ -41,7 +41,13 @@ Le choix iPhone / iPhone + Apple Watch se fait sur l'étape « Comment allez vou
## Limites fonctionnelles
Cette livraison implémente le front du Figma. Les valeurs biométriques, profils, historiques et états « Connecté » affichés sont ceux de la maquette ; ils ne constituent pas des mesures ou connexions réelles. Le front n'est connecté ni à Firebase, ni à Apple Santé, ni à la Watch. Les mesures caméra, protocoles thérapeutiques, alarmes système, authentification et achats ne sont pas implémentés dans ce nouveau projet. Le code natif existant du dépôt parent n'a pas été transféré ni modifié.
Le SDK natif Firebase est configuré sur `haptika-d13d3`. L'écran `haptika-mobile://account`, accessible en touchant « COMPTE ET ABONNEMENT » dans Profil, permet la création de compte, la connexion e-mail/mot de passe, la réinitialisation du mot de passe et la déconnexion. Cet écran technique de compte est un ajout fonctionnel, pas une frame Figma.
Le prénom, l'année de naissance valide, le genre, le choix téléphone/Watch, la fin d'onboarding et le mode écran noir se synchronisent après connexion. Les autres curseurs restent locaux. Les changements non envoyés sont conservés dans une file locale séparée par UID ; ils repartent au retour du réseau (réessai toutes les 10 secondes) ou avec « Réessayer ». Les données du mode local ne sont pas importées à la connexion.
Les valeurs biométriques, historiques, abonnements et états « Connecté » des frames restent des exemples ; aucune de ces valeurs n'est envoyée à Firestore. Apple Santé, l'acquisition caméra, WatchConnectivity dans Expo, les alarmes système et les achats ne sont pas branchés. Les repositories de mesures et de séances sont disponibles pour leur futur branchement. Le code natif de l'ancienne application Watch reste distinct.
Les règles et index Firestore sont déployés. Les fonctions serveur de suppression sont écrites et testées sur Emulator ; leur déploiement nécessite Blaze, actuellement désactivé. Voir `../firebase/README.md`.
Les destinations de profil sans frame correspondante (abonnement, support, etc.) n'ouvrent pas d'écran inventé. Les panneaux explicatifs définis dans Figma sont associés aux boutons d'information disponibles.
+39 -5
View File
@@ -7,9 +7,43 @@
"orientation": "portrait",
"userInterfaceStyle": "dark",
"newArchEnabled": true,
"ios": { "supportsTablet": false, "bundleIdentifier": "com.haptika.mobile.dev" },
"android": { "package": "com.haptika.mobile.dev", "edgeToEdgeEnabled": true },
"plugins": ["expo-router", "expo-font", ["expo-dev-client", {"launchMode":"most-recent"}]],
"experiments": {"typedRoutes": true}
"ios": {
"supportsTablet": false,
"bundleIdentifier": "com.haptika.mobile.dev",
"googleServicesFile": "./GoogleService-Info.plist"
},
"android": {
"package": "com.haptika.mobile.dev",
"edgeToEdgeEnabled": true,
"googleServicesFile": "./google-services.json"
},
"plugins": [
"expo-router",
"expo-font",
[
"expo-dev-client",
{
"launchMode": "most-recent"
}
],
"@react-native-firebase/app",
"@react-native-firebase/auth",
[
"expo-build-properties",
{
"ios": {
"useFrameworks": "static",
"forceStaticLinking": [
"RNFBApp",
"RNFBAuth",
"RNFBFirestore"
]
}
}
]
],
"experiments": {
"typedRoutes": true
}
}
}
}
+2 -1
View File
@@ -2,11 +2,12 @@ import { Stack } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
import { useFonts } from 'expo-font';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import {AuthProvider} from '../src/firebase/auth-state';
import { AppProvider } from '../src/state/app-state';
import { theme } from '../src/design/theme';
export default function Layout() {
const [loaded, error] = useFonts({Inter: require('../assets/fonts/Inter.ttf')});
if (!loaded && !error) return null;
return <SafeAreaProvider><AppProvider><StatusBar style="light" /><Stack screenOptions={{headerShown: false, contentStyle: {backgroundColor: theme.background}, animation: 'fade', gestureEnabled: true}} /></AppProvider></SafeAreaProvider>;
return <SafeAreaProvider><AuthProvider><AppProvider><StatusBar style="light" /><Stack screenOptions={{headerShown: false, contentStyle: {backgroundColor: theme.background}, animation: 'fade', gestureEnabled: true}} /></AppProvider></AuthProvider></SafeAreaProvider>;
}
+20
View File
@@ -0,0 +1,20 @@
import React,{useState} from 'react';
import {Alert,ActivityIndicator,Pressable,ScrollView,Text,TextInput,View} from 'react-native';
import {useRouter} from 'expo-router';
import {SafeAreaView} from 'react-native-safe-area-context';
import {createUserWithEmailAndPassword,signInWithEmailAndPassword,sendPasswordResetEmail,signOut} from '@react-native-firebase/auth';
import {auth} from '../src/firebase/client';
import {useAuth} from '../src/firebase/auth-state';
import {useAppState} from '../src/state/app-state';
import {theme} from '../src/design/theme';
export default function AccountScreen(){
const router=useRouter();const {user}=useAuth();const {syncStatus,syncError,retry}=useAppState();
const [email,setEmail]=useState(''),[password,setPassword]=useState(''),[register,setRegister]=useState(false),[busy,setBusy]=useState(false),[error,setError]=useState<string|null>(null);
async function submit(){setBusy(true);setError(null);try{if(register)await createUserWithEmailAndPassword(auth,email.trim(),password);else await signInWithEmailAndPassword(auth,email.trim(),password);setPassword('');}catch(e){const code=(e as {code?:string}).code;setError(code==='auth/network-request-failed'?'Connexion indisponible. Réessaie lorsque le réseau est disponible.':code==='auth/weak-password'?'Choisis un mot de passe d’au moins 6 caractères.':code==='auth/email-already-in-use'?'Un compte utilise déjà cette adresse.':'Connexion impossible. Vérifie ton adresse et ton mot de passe.');}finally{setBusy(false);}}
const button=(title:string,action:()=>void)=> <Pressable accessibilityRole="button" disabled={busy} onPress={action} style={{backgroundColor:theme.accent,borderRadius:28,padding:16,marginTop:16}}><Text style={{color:'#06101d',fontSize:16,textAlign:'center',fontWeight:'600'}}>{title}</Text></Pressable>;
return <SafeAreaView style={{flex:1,backgroundColor:theme.background}}><ScrollView keyboardShouldPersistTaps="handled" contentContainerStyle={{padding:24,gap:18}}><Pressable accessibilityRole="button" onPress={()=>router.canGoBack()?router.back():router.replace('/')}><Text style={{color:theme.accent}}>Retour</Text></Pressable><Text style={{fontSize:28,color:theme.text,fontWeight:'600'}}>Mon compte</Text>
{user?<><Text selectable style={{color:theme.text}}>{user.email}</Text><Text accessibilityLiveRegion="polite" style={{color:theme.muted}}>{syncStatus==='synced'?'Profil synchronisé':syncStatus==='pending'?'Modifications en attente':syncStatus==='loading'?'Chargement…':'Synchronisation indisponible'}</Text>{syncError&&<Text style={{color:'#ffb4ab'}}>{syncError}</Text>}{syncStatus==='error'&&button('Réessayer',retry)}{button('Se déconnecter',()=>{if(syncStatus==='pending'||syncStatus==='error'){Alert.alert('Modifications en attente','Elles resteront sur cet appareil, liées à ce compte.',[{text:'Annuler',style:'cancel'},{text:'Se déconnecter',onPress:()=>void signOut(auth).catch(()=>setError('Déconnexion impossible.'))}]);}else void signOut(auth).catch(()=>setError('Déconnexion impossible.'));})}</>:<><Text style={{color:theme.muted}}>Connecte-toi pour retrouver ton profil et tes préférences. Les données du mode local restent sur cet appareil.</Text><TextInput accessibilityLabel="Adresse e-mail" placeholder="Adresse e-mail" placeholderTextColor={theme.muted} autoCapitalize="none" autoCorrect={false} keyboardType="email-address" textContentType="emailAddress" value={email} onChangeText={setEmail} style={{color:theme.text,borderWidth:1,borderColor:theme.muted,borderRadius:14,padding:16}}/><TextInput accessibilityLabel="Mot de passe" placeholder="Mot de passe" placeholderTextColor={theme.muted} secureTextEntry textContentType={register?'newPassword':'password'} value={password} onChangeText={setPassword} style={{color:theme.text,borderWidth:1,borderColor:theme.muted,borderRadius:14,padding:16}}/>{button(register?'Créer mon compte':'Se connecter',()=>void submit())}<Pressable disabled={busy} onPress={()=>{setRegister(!register);setError(null);}}><Text style={{color:theme.accent}}>{register?'J’ai déjà un compte':'Créer un compte'}</Text></Pressable><Pressable disabled={busy} onPress={()=>{if(!email.trim()){setError('Renseigne ton adresse e-mail.');return;}setBusy(true);void sendPasswordResetEmail(auth,email.trim()).then(()=>Alert.alert('Demande envoyée','Si un compte correspond à cette adresse, tu recevras un lien de réinitialisation.')).catch(()=>setError('Impossible d’envoyer la demande. Vérifie ton adresse et réessaie.')).finally(()=>setBusy(false));}}><Text style={{color:theme.accent}}>Mot de passe oublié</Text></Pressable></>}
{busy&&<ActivityIndicator color={theme.accent}/>}{error&&<Text accessibilityRole="alert" style={{color:'#ffb4ab'}}>{error}</Text>}
</ScrollView></SafeAreaView>;
}
+2 -2
View File
@@ -1,7 +1,7 @@
import { Redirect } from 'expo-router';
import { useAppState } from '../src/state/app-state';
export default function Index() {
const {state, ready} = useAppState();
if (!ready) return null;
const {state, ready, syncStatus} = useAppState();
if (!ready || syncStatus === 'loading') return null;
return <Redirect href={{pathname: '/screen/[id]', params: {id: state.onboarded ? (state.variant === 'watch' ? '560-251' : '431-2') : '2-2'}}} />;
}
+29
View File
@@ -0,0 +1,29 @@
{
"project_info": {
"project_number": "167940133580",
"project_id": "haptika-d13d3",
"storage_bucket": "haptika-d13d3.firebasestorage.app"
},
"client": [
{
"client_info": {
"mobilesdk_app_id": "1:167940133580:android:e5308e6482d55752896869",
"android_client_info": {
"package_name": "com.haptika.mobile.dev"
}
},
"oauth_client": [],
"api_key": [
{
"current_key": "AIzaSyC8SHKvK24Pjom5dUbBzGePrHilRXx-O48"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": []
}
}
}
],
"configuration_version": "1"
}
+898
View File
@@ -10,7 +10,11 @@
"dependencies": {
"@expo/metro-runtime": "~6.1.2",
"@react-native-async-storage/async-storage": "2.2.0",
"@react-native-firebase/app": "23.8.8",
"@react-native-firebase/auth": "23.8.8",
"@react-native-firebase/firestore": "23.8.8",
"expo": "~54.0.36",
"expo-build-properties": "~1.0.10",
"expo-constants": "~18.0.14",
"expo-dev-client": "~6.0.21",
"expo-font": "~14.0.12",
@@ -2437,6 +2441,645 @@
"node": ">=8"
}
},
"node_modules/@firebase/ai": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/@firebase/ai/-/ai-2.9.0.tgz",
"integrity": "sha512-NPvBBuvdGo9x3esnABAucFYmqbBmXvyTMimBq2PCuLZbdANZoHzGlx7vfzbwNDaEtCBq4RGGNMliLIv6bZ+PtA==",
"license": "Apache-2.0",
"dependencies": {
"@firebase/app-check-interop-types": "0.3.3",
"@firebase/component": "0.7.1",
"@firebase/logger": "0.5.0",
"@firebase/util": "1.14.0",
"tslib": "^2.1.0"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"@firebase/app": "0.x",
"@firebase/app-types": "0.x"
}
},
"node_modules/@firebase/analytics": {
"version": "0.10.20",
"resolved": "https://registry.npmjs.org/@firebase/analytics/-/analytics-0.10.20.tgz",
"integrity": "sha512-adGTNVUWH5q66tI/OQuKLSN6mamPpfYhj0radlH2xt+3eL6NFPtXoOs+ulvs+UsmK27vNFx5FjRDfWk+TyduHg==",
"license": "Apache-2.0",
"dependencies": {
"@firebase/component": "0.7.1",
"@firebase/installations": "0.6.20",
"@firebase/logger": "0.5.0",
"@firebase/util": "1.14.0",
"tslib": "^2.1.0"
},
"peerDependencies": {
"@firebase/app": "0.x"
}
},
"node_modules/@firebase/analytics-compat": {
"version": "0.2.26",
"resolved": "https://registry.npmjs.org/@firebase/analytics-compat/-/analytics-compat-0.2.26.tgz",
"integrity": "sha512-0j2ruLOoVSwwcXAF53AMoniJKnkwiTjGVfic5LDzqiRkR13vb5j6TXMeix787zbLeQtN/m1883Yv1TxI0gItbA==",
"license": "Apache-2.0",
"dependencies": {
"@firebase/analytics": "0.10.20",
"@firebase/analytics-types": "0.8.3",
"@firebase/component": "0.7.1",
"@firebase/util": "1.14.0",
"tslib": "^2.1.0"
},
"peerDependencies": {
"@firebase/app-compat": "0.x"
}
},
"node_modules/@firebase/analytics-types": {
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/@firebase/analytics-types/-/analytics-types-0.8.3.tgz",
"integrity": "sha512-VrIp/d8iq2g501qO46uGz3hjbDb8xzYMrbu8Tp0ovzIzrvJZ2fvmj649gTjge/b7cCCcjT0H37g1gVtlNhnkbg==",
"license": "Apache-2.0"
},
"node_modules/@firebase/app": {
"version": "0.14.9",
"resolved": "https://registry.npmjs.org/@firebase/app/-/app-0.14.9.tgz",
"integrity": "sha512-3gtUX0e584MYkKBQMgSECMvE1Dwzg+eONefDQ0wxVSe5YMBsZwdN5pL7UapwWBlV8+i8QCztF9TP947tEjZAGA==",
"license": "Apache-2.0",
"dependencies": {
"@firebase/component": "0.7.1",
"@firebase/logger": "0.5.0",
"@firebase/util": "1.14.0",
"idb": "7.1.1",
"tslib": "^2.1.0"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@firebase/app-check": {
"version": "0.11.1",
"resolved": "https://registry.npmjs.org/@firebase/app-check/-/app-check-0.11.1.tgz",
"integrity": "sha512-gmKfwQ2k8aUQlOyRshc+fOQLq0OwUmibIZvpuY1RDNu2ho0aTMlwxOuEiJeYOs7AxzhSx7gnXPFNsXCFbnvXUQ==",
"license": "Apache-2.0",
"dependencies": {
"@firebase/component": "0.7.1",
"@firebase/logger": "0.5.0",
"@firebase/util": "1.14.0",
"tslib": "^2.1.0"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"@firebase/app": "0.x"
}
},
"node_modules/@firebase/app-check-compat": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/@firebase/app-check-compat/-/app-check-compat-0.4.1.tgz",
"integrity": "sha512-yjSvSl5B1u4CirnxhzirN1uiTRCRfx+/qtfbyeyI+8Cx8Cw1RWAIO/OqytPSVwLYbJJ1vEC3EHfxazRaMoWKaA==",
"license": "Apache-2.0",
"dependencies": {
"@firebase/app-check": "0.11.1",
"@firebase/app-check-types": "0.5.3",
"@firebase/component": "0.7.1",
"@firebase/logger": "0.5.0",
"@firebase/util": "1.14.0",
"tslib": "^2.1.0"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"@firebase/app-compat": "0.x"
}
},
"node_modules/@firebase/app-check-interop-types": {
"version": "0.3.3",
"resolved": "https://registry.npmjs.org/@firebase/app-check-interop-types/-/app-check-interop-types-0.3.3.tgz",
"integrity": "sha512-gAlxfPLT2j8bTI/qfe3ahl2I2YcBQ8cFIBdhAQA4I2f3TndcO+22YizyGYuttLHPQEpWkhmpFW60VCFEPg4g5A==",
"license": "Apache-2.0"
},
"node_modules/@firebase/app-check-types": {
"version": "0.5.3",
"resolved": "https://registry.npmjs.org/@firebase/app-check-types/-/app-check-types-0.5.3.tgz",
"integrity": "sha512-hyl5rKSj0QmwPdsAxrI5x1otDlByQ7bvNvVt8G/XPO2CSwE++rmSVf3VEhaeOR4J8ZFaF0Z0NDSmLejPweZ3ng==",
"license": "Apache-2.0"
},
"node_modules/@firebase/app-compat": {
"version": "0.5.9",
"resolved": "https://registry.npmjs.org/@firebase/app-compat/-/app-compat-0.5.9.tgz",
"integrity": "sha512-e5LzqjO69/N2z7XcJeuMzIp4wWnW696dQeaHAUpQvGk89gIWHAIvG6W+mA3UotGW6jBoqdppEJ9DnuwbcBByug==",
"license": "Apache-2.0",
"dependencies": {
"@firebase/app": "0.14.9",
"@firebase/component": "0.7.1",
"@firebase/logger": "0.5.0",
"@firebase/util": "1.14.0",
"tslib": "^2.1.0"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@firebase/app-types": {
"version": "0.9.3",
"resolved": "https://registry.npmjs.org/@firebase/app-types/-/app-types-0.9.3.tgz",
"integrity": "sha512-kRVpIl4vVGJ4baogMDINbyrIOtOxqhkZQg4jTq3l8Lw6WSk0xfpEYzezFu+Kl4ve4fbPl79dvwRtaFqAC/ucCw==",
"license": "Apache-2.0"
},
"node_modules/@firebase/auth": {
"version": "1.12.1",
"resolved": "https://registry.npmjs.org/@firebase/auth/-/auth-1.12.1.tgz",
"integrity": "sha512-nXKj7d5bMBlnq6XpcQQpmnSVwEeHBkoVbY/+Wk0P1ebLSICoH4XPtvKOFlXKfIHmcS84mLQ99fk3njlDGKSDtw==",
"license": "Apache-2.0",
"dependencies": {
"@firebase/component": "0.7.1",
"@firebase/logger": "0.5.0",
"@firebase/util": "1.14.0",
"tslib": "^2.1.0"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"@firebase/app": "0.x",
"@react-native-async-storage/async-storage": "^2.2.0"
},
"peerDependenciesMeta": {
"@react-native-async-storage/async-storage": {
"optional": true
}
}
},
"node_modules/@firebase/auth-compat": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/@firebase/auth-compat/-/auth-compat-0.6.3.tgz",
"integrity": "sha512-nHOkupcYuGVxI1AJJ/OBhLPaRokbP14Gq4nkkoVvf1yvuREEWqdnrYB/CdsSnPxHMAnn5wJIKngxBF9jNX7s/Q==",
"license": "Apache-2.0",
"dependencies": {
"@firebase/auth": "1.12.1",
"@firebase/auth-types": "0.13.0",
"@firebase/component": "0.7.1",
"@firebase/util": "1.14.0",
"tslib": "^2.1.0"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"@firebase/app-compat": "0.x"
}
},
"node_modules/@firebase/auth-interop-types": {
"version": "0.2.4",
"resolved": "https://registry.npmjs.org/@firebase/auth-interop-types/-/auth-interop-types-0.2.4.tgz",
"integrity": "sha512-JPgcXKCuO+CWqGDnigBtvo09HeBs5u/Ktc2GaFj2m01hLarbxthLNm7Fk8iOP1aqAtXV+fnnGj7U28xmk7IwVA==",
"license": "Apache-2.0"
},
"node_modules/@firebase/auth-types": {
"version": "0.13.0",
"resolved": "https://registry.npmjs.org/@firebase/auth-types/-/auth-types-0.13.0.tgz",
"integrity": "sha512-S/PuIjni0AQRLF+l9ck0YpsMOdE8GO2KU6ubmBB7P+7TJUCQDa3R1dlgYm9UzGbbePMZsp0xzB93f2b/CgxMOg==",
"license": "Apache-2.0",
"peerDependencies": {
"@firebase/app-types": "0.x",
"@firebase/util": "1.x"
}
},
"node_modules/@firebase/component": {
"version": "0.7.1",
"resolved": "https://registry.npmjs.org/@firebase/component/-/component-0.7.1.tgz",
"integrity": "sha512-mFzsm7CLHR60o08S23iLUY8m/i6kLpOK87wdEFPLhdlCahaxKmWOwSVGiWoENYSmFJJoDhrR3gKSCxz7ENdIww==",
"license": "Apache-2.0",
"dependencies": {
"@firebase/util": "1.14.0",
"tslib": "^2.1.0"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@firebase/data-connect": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/@firebase/data-connect/-/data-connect-0.4.0.tgz",
"integrity": "sha512-vLXM6WHNIR3VtEeYNUb/5GTsUOyl3Of4iWNZHBe1i9f88sYFnxybJNWVBjvJ7flhCyF8UdxGpzWcUnv6F5vGfg==",
"license": "Apache-2.0",
"dependencies": {
"@firebase/auth-interop-types": "0.2.4",
"@firebase/component": "0.7.1",
"@firebase/logger": "0.5.0",
"@firebase/util": "1.14.0",
"tslib": "^2.1.0"
},
"peerDependencies": {
"@firebase/app": "0.x"
}
},
"node_modules/@firebase/database": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@firebase/database/-/database-1.1.1.tgz",
"integrity": "sha512-LwIXe8+mVHY5LBPulWECOOIEXDiatyECp/BOlu0gOhe+WOcKjWHROaCbLlkFTgHMY7RHr5MOxkLP/tltWAH3dA==",
"license": "Apache-2.0",
"dependencies": {
"@firebase/app-check-interop-types": "0.3.3",
"@firebase/auth-interop-types": "0.2.4",
"@firebase/component": "0.7.1",
"@firebase/logger": "0.5.0",
"@firebase/util": "1.14.0",
"faye-websocket": "0.11.4",
"tslib": "^2.1.0"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@firebase/database-compat": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@firebase/database-compat/-/database-compat-2.1.1.tgz",
"integrity": "sha512-heAEVZ9Z8c8PnBUcmGh91JHX0cXcVa1yESW/xkLuwaX7idRFyLiN8sl73KXpR8ZArGoPXVQDanBnk6SQiekRCQ==",
"license": "Apache-2.0",
"dependencies": {
"@firebase/component": "0.7.1",
"@firebase/database": "1.1.1",
"@firebase/database-types": "1.0.17",
"@firebase/logger": "0.5.0",
"@firebase/util": "1.14.0",
"tslib": "^2.1.0"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@firebase/database-types": {
"version": "1.0.17",
"resolved": "https://registry.npmjs.org/@firebase/database-types/-/database-types-1.0.17.tgz",
"integrity": "sha512-4eWaM5fW3qEIHjGzfi3cf0Jpqi1xQsAdT6rSDE1RZPrWu8oGjgrq6ybMjobtyHQFgwGCykBm4YM89qDzc+uG/w==",
"license": "Apache-2.0",
"dependencies": {
"@firebase/app-types": "0.9.3",
"@firebase/util": "1.14.0"
}
},
"node_modules/@firebase/firestore": {
"version": "4.12.0",
"resolved": "https://registry.npmjs.org/@firebase/firestore/-/firestore-4.12.0.tgz",
"integrity": "sha512-PM47OyiiAAoAMB8kkq4Je14mTciaRoAPDd3ng3Ckqz9i2TX9D9LfxIRcNzP/OxzNV4uBKRq6lXoOggkJBQR3Gw==",
"license": "Apache-2.0",
"dependencies": {
"@firebase/component": "0.7.1",
"@firebase/logger": "0.5.0",
"@firebase/util": "1.14.0",
"@firebase/webchannel-wrapper": "1.0.5",
"@grpc/grpc-js": "~1.9.0",
"@grpc/proto-loader": "^0.7.8",
"tslib": "^2.1.0"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"@firebase/app": "0.x"
}
},
"node_modules/@firebase/firestore-compat": {
"version": "0.4.6",
"resolved": "https://registry.npmjs.org/@firebase/firestore-compat/-/firestore-compat-0.4.6.tgz",
"integrity": "sha512-NgVyR4hHHN2FvSNQOtbgBOuVsEdD/in30d9FKbEvvITiAChrBN2nBstmhfjI4EOTnHaP8zigwvkNYFI9yKGAkQ==",
"license": "Apache-2.0",
"dependencies": {
"@firebase/component": "0.7.1",
"@firebase/firestore": "4.12.0",
"@firebase/firestore-types": "3.0.3",
"@firebase/util": "1.14.0",
"tslib": "^2.1.0"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"@firebase/app-compat": "0.x"
}
},
"node_modules/@firebase/firestore-types": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@firebase/firestore-types/-/firestore-types-3.0.3.tgz",
"integrity": "sha512-hD2jGdiWRxB/eZWF89xcK9gF8wvENDJkzpVFb4aGkzfEaKxVRD1kjz1t1Wj8VZEp2LCB53Yx1zD8mrhQu87R6Q==",
"license": "Apache-2.0",
"peerDependencies": {
"@firebase/app-types": "0.x",
"@firebase/util": "1.x"
}
},
"node_modules/@firebase/functions": {
"version": "0.13.2",
"resolved": "https://registry.npmjs.org/@firebase/functions/-/functions-0.13.2.tgz",
"integrity": "sha512-tHduUD+DeokM3NB1QbHCvEMoL16e8Z8JSkmuVA4ROoJKPxHn8ibnecHPO2e3nVCJR1D9OjuKvxz4gksfq92/ZQ==",
"license": "Apache-2.0",
"dependencies": {
"@firebase/app-check-interop-types": "0.3.3",
"@firebase/auth-interop-types": "0.2.4",
"@firebase/component": "0.7.1",
"@firebase/messaging-interop-types": "0.2.3",
"@firebase/util": "1.14.0",
"tslib": "^2.1.0"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"@firebase/app": "0.x"
}
},
"node_modules/@firebase/functions-compat": {
"version": "0.4.2",
"resolved": "https://registry.npmjs.org/@firebase/functions-compat/-/functions-compat-0.4.2.tgz",
"integrity": "sha512-YNxgnezvZDkqxqXa6cT7/oTeD4WXbxgIP7qZp4LFnathQv5o2omM6EoIhXiT9Ie5AoQDcIhG9Y3/dj+DFJGaGQ==",
"license": "Apache-2.0",
"dependencies": {
"@firebase/component": "0.7.1",
"@firebase/functions": "0.13.2",
"@firebase/functions-types": "0.6.3",
"@firebase/util": "1.14.0",
"tslib": "^2.1.0"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"@firebase/app-compat": "0.x"
}
},
"node_modules/@firebase/functions-types": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/@firebase/functions-types/-/functions-types-0.6.3.tgz",
"integrity": "sha512-EZoDKQLUHFKNx6VLipQwrSMh01A1SaL3Wg6Hpi//x6/fJ6Ee4hrAeswK99I5Ht8roiniKHw4iO0B1Oxj5I4plg==",
"license": "Apache-2.0"
},
"node_modules/@firebase/installations": {
"version": "0.6.20",
"resolved": "https://registry.npmjs.org/@firebase/installations/-/installations-0.6.20.tgz",
"integrity": "sha512-LOzvR7XHPbhS0YB5ANXhqXB5qZlntPpwU/4KFwhSNpXNsGk/sBQ9g5hepi0y0/MfenJLe2v7t644iGOOElQaHQ==",
"license": "Apache-2.0",
"dependencies": {
"@firebase/component": "0.7.1",
"@firebase/util": "1.14.0",
"idb": "7.1.1",
"tslib": "^2.1.0"
},
"peerDependencies": {
"@firebase/app": "0.x"
}
},
"node_modules/@firebase/installations-compat": {
"version": "0.2.20",
"resolved": "https://registry.npmjs.org/@firebase/installations-compat/-/installations-compat-0.2.20.tgz",
"integrity": "sha512-9C9pL/DIEGucmoPj8PlZTnztbX3nhNj5RTYVpUM7wQq/UlHywaYv99969JU/WHLvi9ptzIogXYS9d1eZ6XFe9g==",
"license": "Apache-2.0",
"dependencies": {
"@firebase/component": "0.7.1",
"@firebase/installations": "0.6.20",
"@firebase/installations-types": "0.5.3",
"@firebase/util": "1.14.0",
"tslib": "^2.1.0"
},
"peerDependencies": {
"@firebase/app-compat": "0.x"
}
},
"node_modules/@firebase/installations-types": {
"version": "0.5.3",
"resolved": "https://registry.npmjs.org/@firebase/installations-types/-/installations-types-0.5.3.tgz",
"integrity": "sha512-2FJI7gkLqIE0iYsNQ1P751lO3hER+Umykel+TkLwHj6plzWVxqvfclPUZhcKFVQObqloEBTmpi2Ozn7EkCABAA==",
"license": "Apache-2.0",
"peerDependencies": {
"@firebase/app-types": "0.x"
}
},
"node_modules/@firebase/logger": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/@firebase/logger/-/logger-0.5.0.tgz",
"integrity": "sha512-cGskaAvkrnh42b3BA3doDWeBmuHFO/Mx5A83rbRDYakPjO9bJtRL3dX7javzc2Rr/JHZf4HlterTW2lUkfeN4g==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.1.0"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@firebase/messaging": {
"version": "0.12.24",
"resolved": "https://registry.npmjs.org/@firebase/messaging/-/messaging-0.12.24.tgz",
"integrity": "sha512-UtKoubegAhHyehcB7iQjvQ8OVITThPbbWk3g2/2ze42PrQr6oe6OmCElYQkBrE5RDCeMTNucXejbdulrQ2XwVg==",
"license": "Apache-2.0",
"dependencies": {
"@firebase/component": "0.7.1",
"@firebase/installations": "0.6.20",
"@firebase/messaging-interop-types": "0.2.3",
"@firebase/util": "1.14.0",
"idb": "7.1.1",
"tslib": "^2.1.0"
},
"peerDependencies": {
"@firebase/app": "0.x"
}
},
"node_modules/@firebase/messaging-compat": {
"version": "0.2.24",
"resolved": "https://registry.npmjs.org/@firebase/messaging-compat/-/messaging-compat-0.2.24.tgz",
"integrity": "sha512-wXH8FrKbJvFuFe6v98TBhAtvgknxKIZtGM/wCVsfpOGmaAE80bD8tBxztl+uochjnFb9plihkd6mC4y7sZXSpA==",
"license": "Apache-2.0",
"dependencies": {
"@firebase/component": "0.7.1",
"@firebase/messaging": "0.12.24",
"@firebase/util": "1.14.0",
"tslib": "^2.1.0"
},
"peerDependencies": {
"@firebase/app-compat": "0.x"
}
},
"node_modules/@firebase/messaging-interop-types": {
"version": "0.2.3",
"resolved": "https://registry.npmjs.org/@firebase/messaging-interop-types/-/messaging-interop-types-0.2.3.tgz",
"integrity": "sha512-xfzFaJpzcmtDjycpDeCUj0Ge10ATFi/VHVIvEEjDNc3hodVBQADZ7BWQU7CuFpjSHE+eLuBI13z5F/9xOoGX8Q==",
"license": "Apache-2.0"
},
"node_modules/@firebase/performance": {
"version": "0.7.10",
"resolved": "https://registry.npmjs.org/@firebase/performance/-/performance-0.7.10.tgz",
"integrity": "sha512-8nRFld+Ntzp5cLKzZuG9g+kBaSn8Ks9dmn87UQGNFDygbmR6ebd8WawauEXiJjMj1n70ypkvAOdE+lzeyfXtGA==",
"license": "Apache-2.0",
"dependencies": {
"@firebase/component": "0.7.1",
"@firebase/installations": "0.6.20",
"@firebase/logger": "0.5.0",
"@firebase/util": "1.14.0",
"tslib": "^2.1.0",
"web-vitals": "^4.2.4"
},
"peerDependencies": {
"@firebase/app": "0.x"
}
},
"node_modules/@firebase/performance-compat": {
"version": "0.2.23",
"resolved": "https://registry.npmjs.org/@firebase/performance-compat/-/performance-compat-0.2.23.tgz",
"integrity": "sha512-c7qOAGBUAOpIuUlHu1axWcrCVtIYKPMhH0lMnoCDWnPwn1HcPuPUBVTWETbC7UWw71RMJF8DpirfWXzMWJQfgA==",
"license": "Apache-2.0",
"dependencies": {
"@firebase/component": "0.7.1",
"@firebase/logger": "0.5.0",
"@firebase/performance": "0.7.10",
"@firebase/performance-types": "0.2.3",
"@firebase/util": "1.14.0",
"tslib": "^2.1.0"
},
"peerDependencies": {
"@firebase/app-compat": "0.x"
}
},
"node_modules/@firebase/performance-types": {
"version": "0.2.3",
"resolved": "https://registry.npmjs.org/@firebase/performance-types/-/performance-types-0.2.3.tgz",
"integrity": "sha512-IgkyTz6QZVPAq8GSkLYJvwSLr3LS9+V6vNPQr0x4YozZJiLF5jYixj0amDtATf1X0EtYHqoPO48a9ija8GocxQ==",
"license": "Apache-2.0"
},
"node_modules/@firebase/remote-config": {
"version": "0.8.1",
"resolved": "https://registry.npmjs.org/@firebase/remote-config/-/remote-config-0.8.1.tgz",
"integrity": "sha512-L86TReBnPiiJOWd7k9iaiE9f7rHtMpjAoYN0fH2ey2ZRzsOChHV0s5sYf1+IIUYzplzsE46pjlmAUNkRRKwHSQ==",
"license": "Apache-2.0",
"dependencies": {
"@firebase/component": "0.7.1",
"@firebase/installations": "0.6.20",
"@firebase/logger": "0.5.0",
"@firebase/util": "1.14.0",
"tslib": "^2.1.0"
},
"peerDependencies": {
"@firebase/app": "0.x"
}
},
"node_modules/@firebase/remote-config-compat": {
"version": "0.2.22",
"resolved": "https://registry.npmjs.org/@firebase/remote-config-compat/-/remote-config-compat-0.2.22.tgz",
"integrity": "sha512-uW/eNKKtRBot2gnCC5mnoy5Voo2wMzZuQ7dwqqGHU176fO9zFgMwKiRzk+aaC99NLrFk1KOmr0ZVheD+zdJmjQ==",
"license": "Apache-2.0",
"dependencies": {
"@firebase/component": "0.7.1",
"@firebase/logger": "0.5.0",
"@firebase/remote-config": "0.8.1",
"@firebase/remote-config-types": "0.5.0",
"@firebase/util": "1.14.0",
"tslib": "^2.1.0"
},
"peerDependencies": {
"@firebase/app-compat": "0.x"
}
},
"node_modules/@firebase/remote-config-types": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/@firebase/remote-config-types/-/remote-config-types-0.5.0.tgz",
"integrity": "sha512-vI3bqLoF14L/GchtgayMiFpZJF+Ao3uR8WCde0XpYNkSokDpAKca2DxvcfeZv7lZUqkUwQPL2wD83d3vQ4vvrg==",
"license": "Apache-2.0"
},
"node_modules/@firebase/storage": {
"version": "0.14.1",
"resolved": "https://registry.npmjs.org/@firebase/storage/-/storage-0.14.1.tgz",
"integrity": "sha512-uIpYgBBsv1vIET+5xV20XT7wwqV+H4GFp6PBzfmLUcEgguS4SWNFof56Z3uOC2lNDh0KDda1UflYq2VwD9Nefw==",
"license": "Apache-2.0",
"dependencies": {
"@firebase/component": "0.7.1",
"@firebase/util": "1.14.0",
"tslib": "^2.1.0"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"@firebase/app": "0.x"
}
},
"node_modules/@firebase/storage-compat": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/@firebase/storage-compat/-/storage-compat-0.4.1.tgz",
"integrity": "sha512-bgl3FHHfXAmBgzIK/Fps6Xyv2HiAQlSTov07CBL+RGGhrC5YIk4lruS8JVIC+UkujRdYvnf8cpQFGn2RCilJ/A==",
"license": "Apache-2.0",
"dependencies": {
"@firebase/component": "0.7.1",
"@firebase/storage": "0.14.1",
"@firebase/storage-types": "0.8.3",
"@firebase/util": "1.14.0",
"tslib": "^2.1.0"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"@firebase/app-compat": "0.x"
}
},
"node_modules/@firebase/storage-types": {
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/@firebase/storage-types/-/storage-types-0.8.3.tgz",
"integrity": "sha512-+Muk7g9uwngTpd8xn9OdF/D48uiQ7I1Fae7ULsWPuKoCH3HU7bfFPhxtJYzyhjdniowhuDpQcfPmuNRAqZEfvg==",
"license": "Apache-2.0",
"peerDependencies": {
"@firebase/app-types": "0.x",
"@firebase/util": "1.x"
}
},
"node_modules/@firebase/util": {
"version": "1.14.0",
"resolved": "https://registry.npmjs.org/@firebase/util/-/util-1.14.0.tgz",
"integrity": "sha512-/gnejm7MKkVIXnSJGpc9L2CvvvzJvtDPeAEq5jAwgVlf/PeNxot+THx/bpD20wQ8uL5sz0xqgXy1nisOYMU+mw==",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.1.0"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@firebase/webchannel-wrapper": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/@firebase/webchannel-wrapper/-/webchannel-wrapper-1.0.5.tgz",
"integrity": "sha512-+uGNN7rkfn41HLO0vekTFhTxk61eKa8mTpRGLO0QSqlQdKvIoGAvLp3ppdVIWbTGYJWM6Kp0iN+PjMIOcnVqTw==",
"license": "Apache-2.0"
},
"node_modules/@grpc/grpc-js": {
"version": "1.9.16",
"resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.9.16.tgz",
"integrity": "sha512-wE4Ut/olIzfKqp631XrG+wbF0v1vWFN4YL9FyXC2LJiG33DsV7PLzURjrCvY/6je2ntdRkeLpPDluzSRGaVltQ==",
"license": "Apache-2.0",
"dependencies": {
"@grpc/proto-loader": "^0.7.8",
"@types/node": ">=12.12.47"
},
"engines": {
"node": "^8.13.0 || >=10.10.0"
}
},
"node_modules/@grpc/proto-loader": {
"version": "0.7.15",
"resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.7.15.tgz",
"integrity": "sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==",
"license": "Apache-2.0",
"dependencies": {
"lodash.camelcase": "^4.3.0",
"long": "^5.0.0",
"protobufjs": "^7.2.5",
"yargs": "^17.7.2"
},
"bin": {
"proto-loader-gen-types": "build/bin/proto-loader-gen-types.js"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@isaacs/fs-minipass": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",
@@ -2786,6 +3429,63 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@protobufjs/aspromise": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
"integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/base64": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz",
"integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/codegen": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz",
"integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/eventemitter": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz",
"integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/fetch": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz",
"integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==",
"license": "BSD-3-Clause",
"dependencies": {
"@protobufjs/aspromise": "^1.1.1"
}
},
"node_modules/@protobufjs/float": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz",
"integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/path": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz",
"integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/pool": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz",
"integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/utf8": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.2.tgz",
"integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==",
"license": "BSD-3-Clause"
},
"node_modules/@radix-ui/primitive": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.7.tgz",
@@ -2983,6 +3683,55 @@
"react-native": "^0.0.0-0 || >=0.65 <1.0"
}
},
"node_modules/@react-native-firebase/app": {
"version": "23.8.8",
"resolved": "https://registry.npmjs.org/@react-native-firebase/app/-/app-23.8.8.tgz",
"integrity": "sha512-9KeKToqsEPhaL+KlTYIdUB0bsXYAcKk0Zzpu608zf2qsXbyTscuYs1caLWxk8AdGtt3+j7ZsoOFt/8qnlo4Ugg==",
"license": "Apache-2.0",
"dependencies": {
"firebase": "12.10.0"
},
"peerDependencies": {
"expo": ">=47.0.0",
"react": "*",
"react-native": "*"
},
"peerDependenciesMeta": {
"expo": {
"optional": true
}
}
},
"node_modules/@react-native-firebase/auth": {
"version": "23.8.8",
"resolved": "https://registry.npmjs.org/@react-native-firebase/auth/-/auth-23.8.8.tgz",
"integrity": "sha512-2X067hMAW07O1vKvQh5LtnvlCNfgoKOFXeStTurhnA3+BslkASJwKzjs0qUvnE8RJ7R2YltiF/E+nJfplOKDuQ==",
"license": "Apache-2.0",
"dependencies": {
"plist": "^3.1.0"
},
"peerDependencies": {
"@react-native-firebase/app": "23.8.8",
"expo": ">=47.0.0"
},
"peerDependenciesMeta": {
"expo": {
"optional": true
}
}
},
"node_modules/@react-native-firebase/firestore": {
"version": "23.8.8",
"resolved": "https://registry.npmjs.org/@react-native-firebase/firestore/-/firestore-23.8.8.tgz",
"integrity": "sha512-oGD7EN/NHEz/U5GJNJVZtUF17G4hCp36uB4kjdJ6+UCFn+vgfGS/uMio2kXCMVBO6bmgI2qbfClWryirZ3xmcQ==",
"license": "Apache-2.0",
"dependencies": {
"react-native-url-polyfill": "3.0.0"
},
"peerDependencies": {
"@react-native-firebase/app": "23.8.8"
}
},
"node_modules/@react-native/assets-registry": {
"version": "0.81.5",
"resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.81.5.tgz",
@@ -5002,6 +5751,19 @@
}
}
},
"node_modules/expo-build-properties": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/expo-build-properties/-/expo-build-properties-1.0.10.tgz",
"integrity": "sha512-mFCZbrbrv0AP5RB151tAoRzwRJelqM7bCJzCkxpu+owOyH+p/rFC/q7H5q8B9EpVWj8etaIuszR+gKwohpmu1Q==",
"license": "MIT",
"dependencies": {
"ajv": "^8.11.0",
"semver": "^7.6.0"
},
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo-constants": {
"version": "18.0.14",
"resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.14.tgz",
@@ -6027,6 +6789,18 @@
],
"license": "BSD-3-Clause"
},
"node_modules/faye-websocket": {
"version": "0.11.4",
"resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.4.tgz",
"integrity": "sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==",
"license": "Apache-2.0",
"dependencies": {
"websocket-driver": ">=0.5.1"
},
"engines": {
"node": ">=0.8.0"
}
},
"node_modules/fb-watchman": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz",
@@ -6133,6 +6907,42 @@
"node": ">=8"
}
},
"node_modules/firebase": {
"version": "12.10.0",
"resolved": "https://registry.npmjs.org/firebase/-/firebase-12.10.0.tgz",
"integrity": "sha512-tAjHnEirksqWpa+NKDUSUMjulOnsTcsPC1X1rQ+gwPtjlhJS572na91CwaBXQJHXharIrfj7sw/okDkXOsphjA==",
"license": "Apache-2.0",
"dependencies": {
"@firebase/ai": "2.9.0",
"@firebase/analytics": "0.10.20",
"@firebase/analytics-compat": "0.2.26",
"@firebase/app": "0.14.9",
"@firebase/app-check": "0.11.1",
"@firebase/app-check-compat": "0.4.1",
"@firebase/app-compat": "0.5.9",
"@firebase/app-types": "0.9.3",
"@firebase/auth": "1.12.1",
"@firebase/auth-compat": "0.6.3",
"@firebase/data-connect": "0.4.0",
"@firebase/database": "1.1.1",
"@firebase/database-compat": "2.1.1",
"@firebase/firestore": "4.12.0",
"@firebase/firestore-compat": "0.4.6",
"@firebase/functions": "0.13.2",
"@firebase/functions-compat": "0.4.2",
"@firebase/installations": "0.6.20",
"@firebase/installations-compat": "0.2.20",
"@firebase/messaging": "0.12.24",
"@firebase/messaging-compat": "0.2.24",
"@firebase/performance": "0.7.10",
"@firebase/performance-compat": "0.2.23",
"@firebase/remote-config": "0.8.1",
"@firebase/remote-config-compat": "0.2.22",
"@firebase/storage": "0.14.1",
"@firebase/storage-compat": "0.4.1",
"@firebase/util": "1.14.0"
}
},
"node_modules/flow-enums-runtime": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/flow-enums-runtime/-/flow-enums-runtime-0.0.6.tgz",
@@ -6343,6 +7153,12 @@
"node": ">= 0.8"
}
},
"node_modules/http-parser-js": {
"version": "0.5.10",
"resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.10.tgz",
"integrity": "sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA==",
"license": "MIT"
},
"node_modules/https-proxy-agent": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
@@ -6362,6 +7178,12 @@
"integrity": "sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw==",
"license": "BSD-3-Clause"
},
"node_modules/idb": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz",
"integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==",
"license": "ISC"
},
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
@@ -7357,6 +8179,12 @@
"node": ">=8"
}
},
"node_modules/lodash.camelcase": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz",
"integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==",
"license": "MIT"
},
"node_modules/lodash.debounce": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
@@ -7381,6 +8209,12 @@
"node": ">=4"
}
},
"node_modules/long": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
"license": "Apache-2.0"
},
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
@@ -8466,6 +9300,29 @@
"node": ">= 6"
}
},
"node_modules/protobufjs": {
"version": "7.6.6",
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.6.tgz",
"integrity": "sha512-dYDWdjSl5RNb7SgPxGQcRU+GtvP7s2fpkrY0r432PcOIaZ0/rBcxEZnQN67iJhFuQiVw754JDoPruPCNdGsbjg==",
"hasInstallScript": true,
"license": "BSD-3-Clause",
"dependencies": {
"@protobufjs/aspromise": "^1.1.2",
"@protobufjs/base64": "^1.1.2",
"@protobufjs/codegen": "^2.0.5",
"@protobufjs/eventemitter": "^1.1.1",
"@protobufjs/fetch": "^1.1.1",
"@protobufjs/float": "^1.0.2",
"@protobufjs/path": "^1.1.2",
"@protobufjs/pool": "^1.1.0",
"@protobufjs/utf8": "^1.1.1",
"@types/node": ">=13.7.0",
"long": "^5.3.2"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@@ -8696,6 +9553,18 @@
"react-native": "*"
}
},
"node_modules/react-native-url-polyfill": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/react-native-url-polyfill/-/react-native-url-polyfill-3.0.0.tgz",
"integrity": "sha512-aA5CiuUCUb/lbrliVCJ6lZ17/RpNJzvTO/C7gC/YmDQhTUoRD5q5HlJfwLWcxz4VgAhHwXKzhxH+wUN24tAdqg==",
"license": "MIT",
"dependencies": {
"whatwg-url-without-unicode": "8.0.0-3"
},
"peerDependencies": {
"react-native": "*"
}
},
"node_modules/react-native-web": {
"version": "0.21.2",
"resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.21.2.tgz",
@@ -10376,6 +11245,12 @@
"defaults": "^1.0.3"
}
},
"node_modules/web-vitals": {
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-4.2.4.tgz",
"integrity": "sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw==",
"license": "Apache-2.0"
},
"node_modules/webidl-conversions": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-5.0.0.tgz",
@@ -10385,6 +11260,29 @@
"node": ">=8"
}
},
"node_modules/websocket-driver": {
"version": "0.7.5",
"resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.5.tgz",
"integrity": "sha512-ZL2+3c7kMBdIRCMz6l8jQMHyGVxj+UL+xVk74Ombiciboca8rHa15L86B19E5oh1pL9Ii/uj54gtsIrZGMo6zA==",
"license": "Apache-2.0",
"dependencies": {
"http-parser-js": ">=0.5.1",
"safe-buffer": ">=5.1.0",
"websocket-extensions": ">=0.1.1"
},
"engines": {
"node": ">=0.8.0"
}
},
"node_modules/websocket-extensions": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/websocket-extensions/-/websocket-extensions-0.1.4.tgz",
"integrity": "sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8.0"
}
},
"node_modules/whatwg-fetch": {
"version": "3.6.20",
"resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz",
+4
View File
@@ -12,7 +12,11 @@
"dependencies": {
"@expo/metro-runtime": "~6.1.2",
"@react-native-async-storage/async-storage": "2.2.0",
"@react-native-firebase/app": "23.8.8",
"@react-native-firebase/auth": "23.8.8",
"@react-native-firebase/firestore": "23.8.8",
"expo": "~54.0.36",
"expo-build-properties": "~1.0.10",
"expo-constants": "~18.0.14",
"expo-dev-client": "~6.0.21",
"expo-font": "~14.0.12",
Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

+9
View File
@@ -0,0 +1,9 @@
const {test}=require('node:test');const assert=require('node:assert/strict');const ts=require('typescript');const fs=require('node:fs');const vm=require('node:vm');
const source=ts.transpileModule(fs.readFileSync('src/firebase/state-mapping.ts','utf8'),{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2022}}).outputText;
const sandbox={exports:{},Date};vm.runInNewContext(source,sandbox);const {initialState,toUserPatch,mergePatch,fromAccount}=sandbox.exports;
const plain=v=>JSON.parse(JSON.stringify(v));
test('only business edits cross cloud boundary',()=>{const state={...initialState,values:{'565:123':'72',duration:'5 MIN'},toggles:{vibration:true}};assert.deepEqual(plain(toUserPatch(initialState,state)),{});});
test('explicit profile edit maps to stable fields',()=>{assert.deepEqual(plain(toUserPatch(initialState,{...initialState,values:{firstName:'Léon',birthYear:'1998',gender:'Homme'}})),{profile:{firstName:'Léon',birthYear:1998,gender:'male'}});});
test('partial invalid year is not uploaded',()=>{assert.deepEqual(plain(toUserPatch(initialState,{...initialState,values:{birthYear:'19'}})),{});});
test('queue merges fields without losing unsent edits',()=>{assert.deepEqual(plain(mergePatch({profile:{firstName:'A'},onboarding:{experience:'watch'}},{profile:{birthYear:2000}})),{profile:{firstName:'A',birthYear:2000},onboarding:{experience:'watch'}});});
test('cloud hydration does not use mock profile',()=>{const s=fromAccount({profile:{firstName:'',birthYear:null,gender:null},onboarding:{completed:false,experience:'phone'},preferences:{blackScreenEnabled:false}},{...initialState,values:{firstName:'Alexandre'}});assert.equal(s.values.firstName,'');});
+3 -1
View File
@@ -33,6 +33,7 @@ export function DesignScreen({id, preview=false}: {id:string; preview?:boolean})
const scale = Math.min(width,600)/402;
const header = scene?.layers.find(l=>/Header - TopAppBar|Header - Top App Bar/.test(l.name));
const footer = scene?.layers.find(l=>/Bottom Navigation/.test(l.name));
const accountLayers = scene?.layers.filter(l=>l.text === 'COMPTE ET ABONNEMENT'||l.text==='Se déconnecter')??[];
const actionLayers = useMemo(()=>scene?.layers.filter(l=>l.action && !scene.layers.some(parent=>parent.action&&l.path.includes(parent.id)))??[],[scene]);
const timerLayer = scene?.layers.find(l=>l.text && /^\d{1,2}:\d{2}$/.test(l.text.trim()));
const navigate = (target:string, replace=false) => {const href={pathname:'/screen/[id]' as const,params:{id:target.replace(':','-')}}; replace?router.replace(href):router.push(href);};
@@ -66,6 +67,7 @@ export function DesignScreen({id, preview=false}: {id:string; preview?:boolean})
}
function replacement(layer:Layer) {
if(preview)return undefined;
if(layer.text==='Alexandre'&&['565:3818','580:119'].includes(id))return state.values.firstName||'—';
if(layer.text?.includes('[Prénom]')&&state.values.firstName)return layer.text.replace('[Prénom]',state.values.firstName);
if(isSession&&layer.id===timerLayer?.id)return time(remaining);
const slider=scene?.layers.find(l=>l.action?.type==='slider'&&l.action.valueNode===layer.id)?.action;
@@ -95,7 +97,7 @@ export function DesignScreen({id, preview=false}: {id:string; preview?:boolean})
if(!preview&&toggleTracks.includes(layer)){const on=!!state.toggles[layer.action?.key??''];return <View key={layer.id} pointerEvents="none" style={{position:'absolute',left:layer.x*scale,top:(layer.y-offsetY)*scale,width:layer.w*scale,height:layer.h*scale,borderRadius:999,backgroundColor:on?theme.accent:'#3e484e'}}><View style={{position:'absolute',top:4*scale,left:(on?layer.w-20:4)*scale,width:16*scale,height:16*scale,borderRadius:8*scale,backgroundColor:on?'#003548':theme.muted}} /></View>;}
const element=<DesignLayer key={layer.id} layer={layer} scale={scale} offsetY={offsetY} replacement={replacement(layer)} />;
return isSession&&!preview&&layer.t==='ELLIPSE' ? <Animated.View key={layer.id} pointerEvents="none" style={{position:'absolute',inset:0,opacity:pulse.interpolate({inputRange:[1,1.035],outputRange:[.75,1]})}}>{element}</Animated.View> : element;
})}{!preview&&controls(layers,offsetY,scaleOverride)}</>;
})}{!preview&&controls(layers,offsetY,scaleOverride)}{!preview&&accountLayers.filter(l=>layers.includes(l)).map(accountLayer=><Pressable key={'account-'+accountLayer.id} accessibilityRole="button" accessibilityLabel="Mon compte" onPress={()=>router.push('/account')} hitSlop={12} style={{position:'absolute',left:accountLayer.x*scale,top:(accountLayer.y-offsetY)*scale,width:accountLayer.w*scale,height:Math.max(32,accountLayer.h*scale)}}/>)}</>;
}
const top=preview?0:insets.top;
const bottom=preview?0:insets.bottom;
+10
View File
@@ -0,0 +1,10 @@
import React,{createContext,use,useEffect,useState} from 'react';
import {onAuthStateChanged,type FirebaseAuthTypes} from '@react-native-firebase/auth';
import {auth} from './client';
const Context=createContext<{user:FirebaseAuthTypes.User|null;ready:boolean}>({user:null,ready:false});
export function AuthProvider({children}:{children:React.ReactNode}) {
const [value,setValue]=useState<{user:FirebaseAuthTypes.User|null;ready:boolean}>({user:null,ready:false});
useEffect(()=>onAuthStateChanged(auth,user=>setValue({user,ready:true})),[]);
return <Context value={value}>{children}</Context>;
}
export const useAuth=()=>use(Context);
+11
View File
@@ -0,0 +1,11 @@
import {getApp} from '@react-native-firebase/app';
import {getAuth} from '@react-native-firebase/auth';
import {getFirestore} from '@react-native-firebase/firestore';
// Default app is configured by the native GoogleService-Info.plist.
export const firebaseApp = getApp();
export const auth = getAuth(firebaseApp);
export const database = getFirestore(firebaseApp);
if (firebaseApp.options.projectId !== 'haptika-d13d3') {
throw new Error('Configuration Firebase Haptika incorrecte.');
}
+66
View File
@@ -0,0 +1,66 @@
import {doc, getDoc, getDocs, collection, query, orderBy, limit, runTransaction, serverTimestamp, Timestamp, type FirebaseFirestoreTypes} from '@react-native-firebase/firestore';
import {auth, database} from './client';
export type Profile = {firstName:string;birthYear:number|null;gender:'female'|'male'|null;locale:string;timeZone:string};
export type Onboarding = {stress:'low'|'medium'|'high'|null;sleep:'low'|'medium'|'high'|null;energy:'low'|'medium'|'high'|null;experience:'phone'|'watch';completed:boolean;flowVersion:1};
export type Preferences = {notificationsEnabled:boolean;saveMindAndBodyWorkouts:boolean;blackScreenEnabled:boolean};
export type UserPatch = {profile?:Partial<Profile>;onboarding?:Partial<Onboarding>;preferences?:Partial<Preferences>};
export const defaults = {
profile:{firstName:'',birthYear:null,gender:null,locale:'fr',timeZone:Intl.DateTimeFormat().resolvedOptions().timeZone || 'Europe/Paris'} as Profile,
onboarding:{stress:null,sleep:null,energy:null,experience:'phone',completed:false,flowVersion:1} as Onboarding,
preferences:{notificationsEnabled:false,saveMindAndBodyWorkouts:false,blackScreenEnabled:false} as Preferences,
};
function checkOwner(uid:string) {if(auth.currentUser?.uid!==uid)throw new Error('Le compte a changé.');}
export async function ensureProfile(uid:string) {
checkOwner(uid);
const ref=doc(database,'users',uid);
await runTransaction(database,async tx=>{const snapshot=await tx.get(ref);checkOwner(uid);if(!snapshot.exists())tx.set(ref,{...defaults.profile,schemaVersion:1,accountState:'active',createdAt:serverTimestamp(),updatedAt:serverTimestamp()});else if(snapshot.data()?.accountState!=='active')throw new Error('Ce compte est en cours de suppression.');});
}
export async function readAccount(uid:string) {
checkOwner(uid);
const refs=[doc(database,'users',uid),doc(database,'users',uid,'onboarding','current'),doc(database,'users',uid,'preferences','general')];
const [p,o,s]=await Promise.all(refs.map(ref=>getDoc(ref)));
checkOwner(uid);
if(p.data()?.accountState!=='active')throw new Error('Ce compte est indisponible.');
return {profile:{...defaults.profile,...p.data()} as Profile,onboarding:{...defaults.onboarding,...o.data()} as Onboarding,preferences:{...defaults.preferences,...s.data()} as Preferences};
}
export async function applyUserPatch(uid:string, patch:UserPatch) {
checkOwner(uid);
const entries=([
['profile',doc(database,'users',uid)],['onboarding',doc(database,'users',uid,'onboarding','current')],['preferences',doc(database,'users',uid,'preferences','general')],
] as const).filter(([kind])=>patch[kind]);
await runTransaction(database,async tx=>{
const snapshots=await Promise.all(entries.map(([,ref])=>tx.get(ref)));
checkOwner(uid);
entries.forEach(([kind,ref],i)=>{const snap=snapshots[i];const previous=snap.data();if(kind==='profile' && previous?.accountState!=='active')throw new Error('Compte indisponible.');tx.set(ref,{...defaults[kind],...previous,...patch[kind],schemaVersion:1,createdAt:previous?.createdAt??serverTimestamp(),updatedAt:serverTimestamp()});});
});
}
export type Measurement = {source:'iphoneCameraPPG'|'appleWatchOptical';deviceId:string;startedAt:FirebaseFirestoreTypes.Timestamp;endedAt:FirebaseFirestoreTypes.Timestamp;durationSeconds:number;averageBpm:number|null;minimumBpm:number|null;maximumBpm:number|null;estimatedRmssdMs:number|null;sdnnMs:number|null;quality:number;coverage:number;valid:boolean;algorithmVersion:string};
export async function saveMeasurement(uid:string,id:string,value:Measurement) {
checkOwner(uid);
if(!/^[a-zA-Z0-9-]{1,100}$/.test(id))throw new Error('Identifiant de mesure invalide.');
const ref=doc(database,'users',uid,'measurements',id);
return runTransaction(database,async tx=>{const [existing,deleted]=await Promise.all([tx.get(ref),tx.get(doc(database,'users',uid,'tombstones','measurement_'+id))]);checkOwner(uid);if(deleted.exists())throw new Error('Mesure supprimée.');if(existing.exists())return 'already-saved';tx.set(ref,{...value,schemaVersion:1,createdAt:serverTimestamp(),updatedAt:serverTimestamp()});return 'saved';});
}
export async function recentMeasurements(uid:string) {checkOwner(uid);const snapshot=await getDocs(query(collection(database,'users',uid,'measurements'),orderBy('startedAt','desc'),limit(30)));checkOwner(uid);return snapshot.docs.map((d:FirebaseFirestoreTypes.QueryDocumentSnapshot)=>({id:d.id,...d.data()}));}
export {Timestamp};
export type Session = {
practiceId:'breathing'|'anchoring'|'meditation'|'sleep'|'wake'|'coherence';protocolVersion:number;deviceId:string;source:Measurement['source'];
startedAt:FirebaseFirestoreTypes.Timestamp;endedAt:FirebaseFirestoreTypes.Timestamp|null;plannedDurationSeconds:number;actualDurationSeconds:number;
status:'inProgress'|'awaitingAfter'|'completed'|'cancelled';beforeMeasurementId:string|null;afterMeasurementId:string|null;feelingBefore:number|null;feelingAfter:number|null;
};
export async function saveSession(uid:string,id:string,value:Session,expectedRevision:number) {
checkOwner(uid);
if(!/^[a-zA-Z0-9-]{1,100}$/.test(id))throw new Error('Identifiant de séance invalide.');
const ref=doc(database,'users',uid,'sessions',id);
return runTransaction(database,async tx=>{
const [snapshot,deleted]=await Promise.all([tx.get(ref),tx.get(doc(database,'users',uid,'tombstones','session_'+id))]);checkOwner(uid);
if(deleted.exists())throw new Error('Séance supprimée.');
const previous=snapshot.data();
if((previous?.revision??0)!==expectedRevision)throw new Error('Cette séance a été modifiée. Recharge-la avant de réessayer.');
tx.set(ref,{...value,schemaVersion:1,revision:expectedRevision+1,createdAt:previous?.createdAt??serverTimestamp(),updatedAt:serverTimestamp()});
return expectedRevision+1;
});
}
export async function recentSessions(uid:string) {checkOwner(uid);const snapshot=await getDocs(query(collection(database,'users',uid,'sessions'),orderBy('startedAt','desc'),limit(30)));checkOwner(uid);return snapshot.docs.map((d:FirebaseFirestoreTypes.QueryDocumentSnapshot)=>({id:d.id,...d.data()}));}
+23
View File
@@ -0,0 +1,23 @@
import type {UserPatch,Profile,Onboarding,Preferences} from './repository';
export type LocalState={onboarded:boolean;variant:'phone'|'watch';values:Record<string,string>;toggles:Record<string,boolean>};
export const initialState:LocalState={onboarded:false,variant:'phone',values:{},toggles:{}};
// Only deliberate edits to supported business fields are synced. Source frame IDs,
// example values, timer state and unimplemented sensors never cross this boundary.
export function toUserPatch(before:LocalState,after:LocalState):UserPatch {
const p:UserPatch={};
if(before.values.firstName!==after.values.firstName)p.profile={firstName:after.values.firstName??''};
if(before.values.birthYear!==after.values.birthYear){const value=after.values.birthYear??'';if(value===''||(/^\d{4}$/.test(value)&&Number(value)>=1900&&Number(value)<=new Date().getFullYear()))p.profile={...p.profile,birthYear:value?Number(value):null};}
if(before.values.gender!==after.values.gender)p.profile={...p.profile,gender:after.values.gender==='Homme'?'male':after.values.gender==='Femme'?'female':null};
if(before.variant!==after.variant)p.onboarding={experience:after.variant};
if(before.onboarded!==after.onboarded)p.onboarding={...p.onboarding,completed:after.onboarded};
if(before.toggles['mode ecran noir']!==after.toggles['mode ecran noir'])p.preferences={blackScreenEnabled:!!after.toggles['mode ecran noir']};
return p;
}
export function mergePatch(a:UserPatch,b:UserPatch):UserPatch {
const result={...a};
for(const key of ['profile','onboarding','preferences'] as const)if(b[key])result[key]={...a[key],...b[key]} as never;
return result;
}
export function fromAccount(account:{profile:Profile;onboarding:Onboarding;preferences:Preferences},local:LocalState):LocalState {
return {...local,onboarded:account.onboarding.completed,variant:account.onboarding.experience,values:{...local.values,firstName:account.profile.firstName,birthYear:account.profile.birthYear?.toString()??'',gender:account.profile.gender==='male'?'Homme':account.profile.gender==='female'?'Femme':''},toggles:{...local.toggles,'mode ecran noir':account.preferences.blackScreenEnabled}};
}
+59 -13
View File
@@ -1,15 +1,61 @@
import React, {createContext, use, useEffect, useState} from 'react';
import React,{createContext,use,useEffect,useRef,useState} from 'react';
import AsyncStorage from '@react-native-async-storage/async-storage';
type State = {onboarded: boolean; variant: 'phone' | 'watch'; values: Record<string, string>; toggles: Record<string, boolean>};
const initial: State = {onboarded: false, variant: 'phone', values: {}, toggles: {}};
const key = 'haptika.mobile.preferences.v1';
const Context = createContext<{state: State; ready: boolean; update: (patch: Partial<State>) => void}>({state: initial, ready: false, update: () => {}});
export function AppProvider({children}: {children: React.ReactNode}) {
const [state, setState] = useState(initial);
const [ready, setReady] = useState(false);
useEffect(() => {let active = true; AsyncStorage.getItem(key).then(value => {if (active && value) {const saved = JSON.parse(value);setState({...initial, ...saved});}}).catch(() => {}).finally(() => {if (active) setReady(true);});return () => {active = false;};}, []);
useEffect(() => {if (ready) AsyncStorage.setItem(key, JSON.stringify(state)).catch(() => {});}, [state, ready]);
const update = (patch: Partial<State>) => setState(previous => ({...previous, ...patch}));
return <Context value={{state, ready, update}}>{children}</Context>;
import {useAuth} from '../firebase/auth-state';
import {ensureProfile,readAccount,applyUserPatch,type UserPatch} from '../firebase/repository';
import {initialState,toUserPatch,mergePatch,fromAccount,type LocalState} from '../firebase/state-mapping';
import {auth} from '../firebase/client';
type SyncStatus='local'|'loading'|'pending'|'synced'|'error';
const Context=createContext<{state:LocalState;ready:boolean;syncStatus:SyncStatus;syncError:string|null;retry:()=>void;update:(patch:Partial<LocalState>)=>void}>({state:initialState,ready:false,syncStatus:'local',syncError:null,retry:()=>{},update:()=>{}});
export function AppProvider({children}:{children:React.ReactNode}) {
const {user,ready}=useAuth();
if(!ready)return null;
return <AccountState key={user?.uid??'local'} uid={user?.uid}>{children}</AccountState>;
}
export const useAppState = () => use(Context);
function AccountState({children,uid}:{children:React.ReactNode;uid?:string}) {
const key=uid?`haptika.mobile.account.v1.${uid}`:'haptika.mobile.preferences.v1';
const [state,setState]=useState<LocalState>(initialState);
const [ready,setReady]=useState(false);
const [syncStatus,setStatus]=useState<SyncStatus>(uid?'loading':'local');
const [syncError,setError]=useState<string|null>(null);
const current=useRef(initialState),pending=useRef<UserPatch>({}),busy=useRef(false),active=useRef(true),storageChain=useRef(Promise.resolve());
const hydrated=useRef(false),remoteLoaded=useRef(false),version=useRef(0);
const persist=()=>{const value=JSON.stringify(uid?{state:current.current,pending:pending.current}:current.current);storageChain.current=storageChain.current.catch(()=>{}).then(()=>AsyncStorage.setItem(key,value));return storageChain.current;};
async function flush() {
if(!uid||busy.current||!active.current||!hydrated.current||auth.currentUser?.uid!==uid)return;
busy.current=true;
try {
await storageChain.current;
await ensureProfile(uid);
const sent=pending.current, sentVersion=version.current;
if(Object.keys(sent).length)await applyUserPatch(uid,sent);
if(!active.current)return;
if(version.current===sentVersion){pending.current={};await persist();if(!remoteLoaded.current){const account=await readAccount(uid);if(!active.current)return;if(version.current===sentVersion){current.current=fromAccount(account,current.current);setState(current.current);remoteLoaded.current=true;await persist();}}setStatus(Object.keys(pending.current).length?'pending':'synced');}else setStatus('pending');
setError(null);
}catch(error){if(active.current){setStatus('error');setError(error instanceof Error?error.message:'Synchronisation indisponible.');}}
finally{busy.current=false;}
}
useEffect(()=>{
active.current=true;
void (async()=>{
try {
const raw=await AsyncStorage.getItem(key);if(!active.current)return;
if(raw){const saved=JSON.parse(raw);current.current={...initialState,...(uid?saved.state:saved)};pending.current=uid?(saved.pending??{}):{};setState(current.current);}
hydrated.current=true;setReady(true);
if(uid)await flush();
}catch(error){if(active.current){setStatus('error');setError(error instanceof Error?error.message:'Stockage indisponible.');}}
finally{if(active.current){hydrated.current=true;setReady(true);}}
})();
const timer=setInterval(()=>{if(Object.keys(pending.current).length)void flush();},10000);
return()=>{active.current=false;clearInterval(timer);};
},[key]);
const update=(patch:Partial<LocalState>)=>{
if(!ready)return;
const next={...current.current,...patch};
if(uid){pending.current=mergePatch(pending.current,toUserPatch(current.current,next));version.current++;if(Object.keys(pending.current).length)setStatus('pending');}
current.current=next;setState(next);
void persist().then(()=>flush()).catch(()=>{setStatus('error');setError('Impossible de sauvegarder les modifications sur cet appareil.');});
};
return <Context value={{state,ready,syncStatus,syncError,retry:()=>{void persist().then(()=>flush()).catch(()=>setError('Stockage indisponible.'));},update}}>{children}</Context>;
}
export const useAppState=()=>use(Context);