feat: iphone app
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
{"projects":{"default":"haptika-d13d3"}}
|
||||||
@@ -77,3 +77,9 @@ yarn-error.log
|
|||||||
!.yarn/releases
|
!.yarn/releases
|
||||||
!.yarn/sdks
|
!.yarn/sdks
|
||||||
!.yarn/versions
|
!.yarn/versions
|
||||||
|
|
||||||
|
# Firebase / Expo local tooling
|
||||||
|
/.expo/
|
||||||
|
.firebase/
|
||||||
|
*-debug.log
|
||||||
|
firebase-debug.*.log
|
||||||
|
|||||||
@@ -0,0 +1,190 @@
|
|||||||
|
# Architecture Firestore Haptika
|
||||||
|
|
||||||
|
État vérifié le 8 septembre 2026 : accès CLI au projet `haptika-d13d3`
|
||||||
|
(numéro `167940133580`), base `(default)` Firestore Native en `eur3`.
|
||||||
|
Mise en place engagée : application iOS enregistrée, SDK natifs Auth/Firestore intégrés
|
||||||
|
dans Expo, règles et index déployés. Le périmètre livré et les étapes restantes
|
||||||
|
sont décrits dans `firebase/README.md`. Les sections ci-dessous restent la cible
|
||||||
|
d'architecture ; elles ne signifient pas que tous les capteurs et calculs sont branchés.
|
||||||
|
|
||||||
|
## Organisation retenue
|
||||||
|
|
||||||
|
Deux applications, un même compte Firebase Authentication et un historique commun.
|
||||||
|
L'UID Firebase est la frontière d'accès ; ni l'adresse e-mail ni l'identifiant
|
||||||
|
matériel ne servent de clé utilisateur. La Watch passe par le compagnon iPhone
|
||||||
|
avec WatchConnectivity ; son fonctionnement et ses vibrations restent locaux.
|
||||||
|
L'intégration WatchConnectivity dans le nouveau projet Expo reste à construire.
|
||||||
|
|
||||||
|
```text
|
||||||
|
users/{uid} profil et état du compte
|
||||||
|
onboarding/current réponses et étape d'onboarding
|
||||||
|
preferences/general préférences communes
|
||||||
|
practiceSettings/{practiceId} réglages respiration, ancrage, méditation…
|
||||||
|
devices/{installationId} installations et état de synchronisation
|
||||||
|
measurements/{measurementId} résumé d'une acquisition
|
||||||
|
measurementAnnotations/{measurementId} ressenti et contexte déclaratif
|
||||||
|
sessions/{sessionId} séance et références avant/après
|
||||||
|
reminders/{reminderId} intention de rappel/réveil
|
||||||
|
dailyStats/{dayKey} agrégats reconstruisibles, serveur
|
||||||
|
baselines/{baselineKey} référence personnelle versionnée, serveur
|
||||||
|
entitlements/current abonnement vérifié, serveur
|
||||||
|
tombstones/{entityKey} suppressions anti-résurrection, serveur
|
||||||
|
|
||||||
|
protocols/{protocolId}/versions/{version} paramètres publiés, serveur
|
||||||
|
```
|
||||||
|
|
||||||
|
Les sous-collections sont créées à la première écriture ; pas de documents vides
|
||||||
|
pour simuler des tables. Une mesure ou une séance est un document indépendant.
|
||||||
|
Aucun tableau d'historique croissant dans `users/{uid}`.
|
||||||
|
|
||||||
|
## Contrat des documents
|
||||||
|
|
||||||
|
Tous les documents persistés portent `schemaVersion: 1`. Les instants sont des
|
||||||
|
Firestore Timestamp UTC ; `createdAt` et `updatedAt` sont des timestamps serveur.
|
||||||
|
`startedAt`, `endedAt`, `recordedAt` décrivent le moment sur l'appareil et sont
|
||||||
|
validés indépendamment. Les IDs d'événements sont des UUID aléatoires créés hors
|
||||||
|
ligne puis réutilisés à chaque tentative. Les durées sont en secondes, le rythme
|
||||||
|
cardiaque en bpm et les intervalles en millisecondes. Une valeur indisponible est
|
||||||
|
`null`, jamais un zéro inventé.
|
||||||
|
|
||||||
|
| Document | Champs métier principaux |
|
||||||
|
| --- | --- |
|
||||||
|
| Profil | `firstName`, `birthYear` nullable, `gender` nullable, `locale`, `timeZone` IANA. `accountState` (`active`, `deleting`) est contrôlé par le serveur. E-mail et fournisseurs de connexion restent dans Auth. |
|
||||||
|
| Onboarding | `stress`, `sleep`, `energy` (`low`, `medium`, `high`), `experience` (`phone`, `watch`), `completedAt`, `flowVersion`. L'étape courante utilise une clé métier stable. |
|
||||||
|
| Préférences | Préférences de notification et de sauvegarde Santé. Une préférence ne prouve jamais une autorisation iOS ; les permissions sont relues sur l'appareil. |
|
||||||
|
| Réglages de pratique | `durationSeconds`, paramètres de respiration, intensité, son et écran éteint selon la pratique. Schéma fermé différent par pratique ; pas de dictionnaire arbitraire contenant tous les IDs Figma. |
|
||||||
|
| Installation | `platform`, `appVersion`, `pairedInstallationId` nullable, `lastSeenAt`, `lastSyncedAt`. Identifiant aléatoire d'installation, pas de numéro de série. L'état « connecté » vient de WatchConnectivity, pas de Firestore. |
|
||||||
|
| Mesure | `source`, `deviceId`, `startedAt`, `endedAt`, `durationSeconds`, `averageBpm`, `minimumBpm`, `maximumBpm`, `estimatedRmssdMs`, `sdnnMs`, `quality`, `coverage`, `valid`, `invalidReasons`, `algorithmVersion`, `stressResult` nullable. `stressResult` garde statut, confiance, limitations et version de référence utilisée. |
|
||||||
|
| Annotation | `feeling` entier 1–5 nullable, `posture`, `atRest`, `recentExercise`, `recentCaffeine`, `recentMeal`, `recordedAt`, `revision`. Modification indépendante du résultat acquis. |
|
||||||
|
| Séance | `practiceId`, `protocolVersion`, `deviceId`, `source`, `startedAt`, `endedAt` nullable, `plannedDurationSeconds`, `actualDurationSeconds`, `status`, `beforeMeasurementId`, `afterMeasurementId`, `feelingBefore`, `feelingAfter`, résumé de cohérence nullable, `revision`. |
|
||||||
|
| Rappel | `kind`, `localTime` (HH:mm), `weekdays` (1=lundi…7=dimanche), `timeZone`, `enabled`, `targetDeviceId`. Planification réelle par l'OS, avec gestion explicite des changements de fuseau et d'heure d'été. |
|
||||||
|
| Abonnement | `plan`, `status`, `expiresAt`, `provider`, `verifiedAt`. Écrit uniquement après validation serveur des achats ; choisir le parcours Watch ne donne aucun droit payant. |
|
||||||
|
|
||||||
|
Les sources canoniques de mesure sont `iphoneCameraPPG` et `appleWatchOptical`,
|
||||||
|
déjà utilisées dans le contrat Watch. Un éventuel import HealthKit doit avoir sa
|
||||||
|
propre source, provenance et méthode. Ne pas confondre PRV estimée par caméra,
|
||||||
|
mesure optique de BPM et HRV HealthKit ; ne pas remplir des métriques absentes
|
||||||
|
de la Watch à partir des exemples Figma. Qualité et couverture sont normalisées
|
||||||
|
entre 0 et 1. Chaque algorithme conserve sa version et son statut de disponibilité.
|
||||||
|
|
||||||
|
Les séances utilisent `inProgress`, `awaitingAfter`, `completed`, `cancelled`.
|
||||||
|
`beforeMeasurementId` et `afterMeasurementId` restent des IDs relatifs au même
|
||||||
|
utilisateur ; la transition finale valide leur existence et leur provenance.
|
||||||
|
Une séance sans mesure après ne reçoit pas de comparaison fabriquée.
|
||||||
|
|
||||||
|
## Synchronisation et conflits
|
||||||
|
|
||||||
|
1. Acquisition et minuteur fonctionnent localement ; aucune écriture par battement
|
||||||
|
ni par seconde. Résumé à la fin, puis mises à jour métier limitées.
|
||||||
|
2. La file d'envoi locale est persistante et partitionnée par UID. Les anciennes
|
||||||
|
données sans propriétaire restent locales jusqu'à une association explicite.
|
||||||
|
3. La Watch envoie son UUID original ; l'iPhone le conserve en Firestore. Les
|
||||||
|
renvois créent au plus un résultat. Le résumé d'acquisition devient immuable ;
|
||||||
|
une annotation utilise son document séparé.
|
||||||
|
4. Les modifications de séances/annotations utilisent une `revision` monotone
|
||||||
|
validée en transaction. Une révision périmée provoque relecture et résolution,
|
||||||
|
jamais un écrasement silencieux basé sur l'horloge de la Watch.
|
||||||
|
5. L'accusé de réception Watch distingue « enregistré durablement sur l'iPhone »
|
||||||
|
et « synchronisé dans le cloud ». La disparition du réseau ne perd pas la file.
|
||||||
|
6. Une suppression passe par une opération serveur idempotente : tombstone
|
||||||
|
déterministe (`measurement_UUID` ou `session_UUID`), suppression du contenu
|
||||||
|
et invalidation des agrégats. Toute recréation avec cet ID est refusée.
|
||||||
|
Les tombstones restent jusqu'à suppression du compte ; pas de TTL qui
|
||||||
|
permettrait à une vieille Watch hors ligne de ressusciter une mesure.
|
||||||
|
7. À la déconnexion/changement de compte, isoler caches, listeners et files ; la
|
||||||
|
Watch doit être réassociée avant d'envoyer au nouvel UID. Un message Watch
|
||||||
|
ne peut pas choisir arbitrairement l'UID de destination.
|
||||||
|
|
||||||
|
L'intégration Expo privilégiera les SDK natifs Firebase compatibles avec le
|
||||||
|
development build pour Auth et le cache Firestore. La file métier reste nécessaire
|
||||||
|
pour WatchConnectivity et les transactions qui exigent le réseau. Aucun compte
|
||||||
|
anonyme automatique tant que le parcours de rattachement/récupération n'est pas
|
||||||
|
défini ; le mode local peut fonctionner sans connexion.
|
||||||
|
|
||||||
|
## Historique, index et coût
|
||||||
|
|
||||||
|
| Écran / besoin | Requête prévue | Index composite |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Dernières mesures | `measurements orderBy(startedAt desc) limit(30)` + curseur | Index simple |
|
||||||
|
| Mesures par appareil/source | `where(source == …) orderBy(startedAt desc)` | `source ASC, startedAt DESC` |
|
||||||
|
| Historique de pratique | `sessions where(practiceId == …) orderBy(startedAt desc)` | `practiceId ASC, startedAt DESC` |
|
||||||
|
| Séances terminées par période | `sessions where(status == completed) orderBy(startedAt desc)` + bornes | `status ASC, startedAt DESC` |
|
||||||
|
| Graphique mensuel | `dailyStats` borné par date et fuseau d'agrégation | Selon requête finale |
|
||||||
|
|
||||||
|
Les requêtes partent toujours de `users/{uid}`. Pas de listener sur tout
|
||||||
|
l'historique et pas de requête globale `collectionGroup` côté utilisateur.
|
||||||
|
Les cartes d'accueil écoutent seulement les documents nécessaires. Exclure de
|
||||||
|
l'indexation les maps de résultats et les champs jamais interrogés.
|
||||||
|
|
||||||
|
Les agrégats sont une optimisation ultérieure, pas un prérequis du premier
|
||||||
|
branchement : lire d'abord une période bornée. Lors de leur introduction,
|
||||||
|
`dailyStats` porte `localDate`, `timeZone`, `aggregationVersion`, `computedAt` et
|
||||||
|
des métriques séparées par source. La clé inclut la date et une clé de fuseau
|
||||||
|
stable ; un changement de fuseau invalide/recalcule la vue au lieu de mélanger
|
||||||
|
des journées. Les baselines sont séparées par source, méthode et version
|
||||||
|
d'algorithme. Les traitements serveur doivent être idempotents face aux événements
|
||||||
|
dupliqués et gérer modification/suppression ; un simple incrément par trigger
|
||||||
|
n'est pas suffisant. Un résultat historique garde la référence utilisée à sa date.
|
||||||
|
|
||||||
|
## Données qui restent locales
|
||||||
|
|
||||||
|
Les frames, textes, traductions, icônes et valeurs de démonstration Figma restent
|
||||||
|
dans l'application. Les données démo ne migrent jamais dans l'historique réel.
|
||||||
|
Les échantillons PPG bruts, vidéos caméra, timelines haute fréquence et données
|
||||||
|
HealthKit détaillées ne sont pas téléversés par défaut. Si une fonction justifie
|
||||||
|
ultérieurement leur conservation distante, utiliser un stockage de fichiers privé
|
||||||
|
avec durée de conservation explicite et une référence dans Firestore, plutôt
|
||||||
|
qu'une série volumineuse dans chaque document de mesure.
|
||||||
|
|
||||||
|
## Politique d'accès à implémenter et tester
|
||||||
|
|
||||||
|
- Refus par défaut. Auth obligatoire et `request.auth.uid == uid` pour les
|
||||||
|
documents privés, avec vérification que le compte est actif.
|
||||||
|
- Autoriser des collections et des champs nommés explicitement ; pas de règle
|
||||||
|
générique `users/{uid}/{document=**}` autorisant toute écriture.
|
||||||
|
- Vérifier types, enums, tailles, plages, timestamps et transitions. Les champs
|
||||||
|
imbriqués nécessitent aussi leurs propres listes de champs autorisés.
|
||||||
|
- Le client peut créer son profil avec `accountState: active`, puis ne modifier
|
||||||
|
que les champs de profil. Il ne peut pas réactiver un compte en suppression.
|
||||||
|
- Le client crée une acquisition valide au sens du schéma, puis ne peut plus
|
||||||
|
réécrire ses métriques. Les annotations et réglages sont modifiables selon
|
||||||
|
leur contrat. « Schéma valide » ne signifie pas mesure certifiée par serveur.
|
||||||
|
- Abonnements, agrégats, références calculées, tombstones et catalogue de
|
||||||
|
protocoles : écriture serveur uniquement. Protocoles publiés lisibles selon le
|
||||||
|
parcours produit ; accès aux données privées strictement individuel.
|
||||||
|
- Les références avant/après restent sous le même UID. La suppression interdit
|
||||||
|
toute recréation et corrige les séances qui référencent la mesure supprimée.
|
||||||
|
- La suppression de compte bloque d'abord les écritures, purge récursivement
|
||||||
|
les sous-collections et fichiers associés, puis supprime Auth. Effacer le seul
|
||||||
|
document `users/{uid}` ne supprime pas ses sous-collections.
|
||||||
|
- Tests Emulator requis : non-connecté refusé, utilisateur A/B isolés, écriture
|
||||||
|
d'abonnement refusée, champs inconnus refusés, timestamps falsifiés refusés,
|
||||||
|
mauvaise révision refusée, rejeu idempotent, recréation après suppression
|
||||||
|
refusée, blocage pendant suppression du compte.
|
||||||
|
|
||||||
|
## Ordre d'intégration
|
||||||
|
|
||||||
|
1. Enregistrer l'app iOS Expo (`com.haptika.mobile.dev`) dans le projet, configurer
|
||||||
|
Auth selon le parcours retenu, puis intégrer le SDK natif au development build.
|
||||||
|
2. Écrire les règles et tests Emulator du contrat minimal : profil, préférences,
|
||||||
|
mesures, annotations et séances ; valider avant déploiement.
|
||||||
|
3. Brancher l'état Expo via des repositories métier. Mapper les clés Figma
|
||||||
|
actuelles vers des noms métier stables. Ne pas synchroniser `values` et
|
||||||
|
`toggles` tels quels et ne pas importer les faux profils de maquette.
|
||||||
|
4. Porter le pont WatchConnectivity dans Expo et tester hors ligne, rejeux,
|
||||||
|
changement de compte et suppression sur simulateurs/appareils adaptés.
|
||||||
|
5. Ajouter validation d'achat serveur, agrégats et catalogue distant uniquement
|
||||||
|
lorsque leurs fonctions correspondantes sont branchées.
|
||||||
|
|
||||||
|
## Sources techniques
|
||||||
|
|
||||||
|
- [Modèle Firestore et sous-collections](https://firebase.google.com/docs/firestore/data-model)
|
||||||
|
- [Bonnes pratiques et indexation](https://firebase.google.com/docs/firestore/best-practices)
|
||||||
|
- [Contrôle des champs par les règles](https://firebase.google.com/docs/firestore/security/rules-fields)
|
||||||
|
- [Persistance hors ligne](https://firebase.google.com/docs/firestore/manage-data/enable-offline)
|
||||||
|
|
||||||
|
Contrats existants examinés : `ios/HaptikaWatch/Core/WatchModels.swift`,
|
||||||
|
`ios/HaptikaWatch/Core/WatchStore.swift`, `src/features/ppg/storage/storageTypes.ts`,
|
||||||
|
`src/features/ppg/stress/stressTypes.ts`, `src/features/coherence/coherenceTypes.ts`,
|
||||||
|
`src/features/cardiacMirror/regulationSessionTypes.ts`,
|
||||||
|
`src/features/onboarding/onboardingRepository.ts`, `mobile/src/state/app-state.tsx`.
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"firestore": {
|
||||||
|
"rules": "firebase/firestore.rules",
|
||||||
|
"indexes": "firebase/firestore.indexes.json"
|
||||||
|
},
|
||||||
|
"emulators": {
|
||||||
|
"firestore": {
|
||||||
|
"port": 8085
|
||||||
|
},
|
||||||
|
"ui": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"singleProjectMode": true
|
||||||
|
},
|
||||||
|
"functions": [
|
||||||
|
{
|
||||||
|
"source": "firebase/functions",
|
||||||
|
"codebase": "haptika"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# Firebase Haptika
|
||||||
|
|
||||||
|
Projet `haptika-d13d3`, Firestore `(default)`, région existante `eur3`.
|
||||||
|
|
||||||
|
## En service
|
||||||
|
|
||||||
|
- Règles et trois index composites déployés : mesures par source, séances par pratique/statut.
|
||||||
|
- Applications natives iOS et Android `com.haptika.mobile.dev` enregistrées, configuration embarquée dans Expo. Aucun secret administrateur embarqué.
|
||||||
|
- Auth e-mail/mot de passe (déjà activée dans le projet), création de compte, connexion, réinitialisation, déconnexion.
|
||||||
|
- Profil, choix d'équipement, fin d'onboarding et préférence écran noir synchronisés. File persistante séparée par UID pour les modifications de ces champs. Les champs non mappés restent locaux.
|
||||||
|
- Acquisition immutable et séances versionnées : contrats Firestore et repositories prêts, sans import des résultats Figma.
|
||||||
|
- Mode local sans compte ; aucune migration implicite des anciennes données vers un compte.
|
||||||
|
|
||||||
|
`users` et ses sous-collections apparaissent lorsqu'un utilisateur crée son compte ou enregistre des données. Aucun faux utilisateur ni historique n'est prérempli.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm ci --prefix firebase
|
||||||
|
npm ci --prefix firebase/functions
|
||||||
|
npm test --prefix firebase
|
||||||
|
npm run typecheck --prefix mobile
|
||||||
|
npm test --prefix mobile
|
||||||
|
```
|
||||||
|
|
||||||
|
Java 17 et Node 22+ sont nécessaires pour cette version de l'émulateur. Les tests utilisent exclusivement `demo-haptika`, port 8085. Dix tests couvrent droits d'accès, schémas, timestamps, révisions, mesures immuables, suppression et rejeux. Les tests mobiles couvrent aussi le mapping excluant les valeurs de maquette.
|
||||||
|
|
||||||
|
Un smoke test ponctuel sur le vrai projet a vérifié Auth, création/modification/lecture du profil et refus d'écriture des abonnements. Le compte temporaire et son profil ont été supprimés après le test.
|
||||||
|
|
||||||
|
Le development build iOS a compilé et a été installé sur le simulateur iPhone 16 Pro (iOS 18.6). Le lancement, l’écran de compte, le refus d’identifiants invalides et le basculement connexion/création ont été vérifiés dans le simulateur. Android est configuré, mais son build n’a pas été testé. La connexion positive native avec un vrai compte utilisateur reste à effectuer.
|
||||||
|
|
||||||
|
## Déploiement
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npx firebase-tools deploy --only firestore --project haptika-d13d3
|
||||||
|
```
|
||||||
|
|
||||||
|
Les fonctions `deleteEntity` et `deleteAccount` sont dans `functions/index.js`. Leur code est préparé ; `deleteEntity` est testé avec l'émulateur. Elles ne sont **pas déployées**, car la facturation du projet est désactivée. Après activation de Blaze par le propriétaire :
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npx firebase-tools deploy --only functions:haptika --project haptika-d13d3
|
||||||
|
```
|
||||||
|
|
||||||
|
Ne pas ouvrir les écritures client sur les tombstones pour contourner cette limite. Les fonctions s'exécutent en `europe-west1`. `deleteAccount` exige une authentification récente, bloque les écritures, purge les sous-collections, révoque les jetons et supprime Auth. Il garde un marqueur minimal `deleting` sans profil pour bloquer les anciens ID tokens. La suppression doit être testée avec Auth Emulator avant d'exposer son bouton dans l'app.
|
||||||
|
|
||||||
|
## À brancher ensuite
|
||||||
|
|
||||||
|
- Pont WatchConnectivity dans Expo et association au bon compte. La Watch existante est encore liée au compagnon natif historique ; elle ne synchronise pas encore avec ce projet.
|
||||||
|
- Capteurs réels, historique et graphiques : utiliser les repositories, jamais les valeurs des frames. Le repository de mesure garde l'UUID original. Celui de séance exige une révision attendue ; ces transactions nécessitent le réseau et leur file métier Watch reste à construire.
|
||||||
|
- Validation des achats et écriture serveur des droits : aucun plan payant n'est accordé par le choix du parcours Watch. Pas de validation d'achat fictive.
|
||||||
|
- Agrégats et baselines : réservés au serveur, sans calcul déployé pour l'instant.
|
||||||
|
- Rappels/dispositifs : collections réservées en lecture propriétaire ; écritures fermées jusqu'au branchement de leur contrat et des capacités natives.
|
||||||
|
- Paramètres avancés : les réglages par pratique ont un contrat minimal, mais les clés de sliders Figma ne sont pas téléversées automatiquement.
|
||||||
|
|
||||||
|
La maquette conserve ses exemples de santé et d'abonnement. Ils ne prouvent ni un abonnement réel ni une connexion matérielle. Le nouvel écran de compte affiche le véritable état de synchronisation du profil.
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
{
|
||||||
|
"indexes": [
|
||||||
|
{
|
||||||
|
"collectionGroup": "measurements",
|
||||||
|
"queryScope": "COLLECTION",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "source",
|
||||||
|
"order": "ASCENDING"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "startedAt",
|
||||||
|
"order": "DESCENDING"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"collectionGroup": "sessions",
|
||||||
|
"queryScope": "COLLECTION",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "practiceId",
|
||||||
|
"order": "ASCENDING"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "startedAt",
|
||||||
|
"order": "DESCENDING"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"collectionGroup": "sessions",
|
||||||
|
"queryScope": "COLLECTION",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "status",
|
||||||
|
"order": "ASCENDING"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "startedAt",
|
||||||
|
"order": "DESCENDING"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fieldOverrides": []
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
rules_version = '2';
|
||||||
|
service cloud.firestore {
|
||||||
|
match /databases/{database}/documents {
|
||||||
|
function owner(uid) { return request.auth != null && request.auth.uid == uid; }
|
||||||
|
function active(uid) { return owner(uid) && get(/databases/$(database)/documents/users/$(uid)).data.accountState == 'active'; }
|
||||||
|
function bounded(s, n) { return s is string && s.size() <= n; }
|
||||||
|
function unit(n) { return n is number && n >= 0 && n <= 1; }
|
||||||
|
function nullableNumber(n, max) { return n == null || (n is number && n >= 0 && n <= max); }
|
||||||
|
function feeling(n) { return n == null || (n is int && n >= 1 && n <= 5); }
|
||||||
|
function source(s) { return s in ['iphoneCameraPPG', 'appleWatchOptical']; }
|
||||||
|
function stamp(d) {
|
||||||
|
return d.schemaVersion == 1 && d.updatedAt == request.time &&
|
||||||
|
(resource == null ? d.createdAt == request.time : d.createdAt == resource.data.createdAt);
|
||||||
|
}
|
||||||
|
function revision(d) { return d.revision is int && (resource == null ? d.revision == 1 : d.revision == resource.data.revision + 1); }
|
||||||
|
function alive(uid, kind, id) { return !exists(/databases/$(database)/documents/users/$(uid)/tombstones/$(kind + '_' + id)); }
|
||||||
|
function profile(d) {
|
||||||
|
return d.keys().hasOnly(['schemaVersion','createdAt','updatedAt','accountState','firstName','birthYear','gender','locale','timeZone']) &&
|
||||||
|
stamp(d) && d.accountState == 'active' && bounded(d.firstName,50) &&
|
||||||
|
(d.birthYear == null || (d.birthYear is int && d.birthYear >= 1900 && d.birthYear <= request.time.year())) &&
|
||||||
|
d.gender in [null,'female','male'] && bounded(d.locale,20) && bounded(d.timeZone,80);
|
||||||
|
}
|
||||||
|
match /users/{uid} {
|
||||||
|
allow read: if owner(uid);
|
||||||
|
allow create: if owner(uid) && profile(request.resource.data);
|
||||||
|
allow update: if active(uid) && profile(request.resource.data);
|
||||||
|
match /onboarding/{id} {
|
||||||
|
allow read: if active(uid);
|
||||||
|
allow create, update: if active(uid) && id == 'current' && stamp(request.resource.data) &&
|
||||||
|
request.resource.data.keys().hasOnly(['schemaVersion','createdAt','updatedAt','stress','sleep','energy','experience','completed','flowVersion']) &&
|
||||||
|
request.resource.data.stress in [null,'low','medium','high'] && request.resource.data.sleep in [null,'low','medium','high'] &&
|
||||||
|
request.resource.data.energy in [null,'low','medium','high'] && request.resource.data.experience in ['phone','watch'] &&
|
||||||
|
request.resource.data.completed is bool && request.resource.data.flowVersion == 1;
|
||||||
|
}
|
||||||
|
match /preferences/{id} {
|
||||||
|
allow read: if active(uid);
|
||||||
|
allow create, update: if active(uid) && id == 'general' && stamp(request.resource.data) &&
|
||||||
|
request.resource.data.keys().hasOnly(['schemaVersion','createdAt','updatedAt','notificationsEnabled','saveMindAndBodyWorkouts','blackScreenEnabled']) &&
|
||||||
|
request.resource.data.notificationsEnabled is bool && request.resource.data.saveMindAndBodyWorkouts is bool && request.resource.data.blackScreenEnabled is bool;
|
||||||
|
}
|
||||||
|
match /practiceSettings/{id} {
|
||||||
|
allow read: if active(uid);
|
||||||
|
allow create, update: if active(uid) && id in ['breathing','anchoring','meditation','sleep','wake','coherence'] && stamp(request.resource.data) &&
|
||||||
|
request.resource.data.keys().hasOnly(['schemaVersion','createdAt','updatedAt','durationSeconds','intensity','soundEnabled']) &&
|
||||||
|
request.resource.data.durationSeconds is int && request.resource.data.durationSeconds >= 30 && request.resource.data.durationSeconds <= 7200 &&
|
||||||
|
unit(request.resource.data.intensity) && request.resource.data.soundEnabled is bool;
|
||||||
|
}
|
||||||
|
match /measurements/{id} {
|
||||||
|
function measurement(d) {
|
||||||
|
return stamp(d) && d.keys().hasOnly(['schemaVersion','createdAt','updatedAt','source','deviceId','startedAt','endedAt','durationSeconds','averageBpm','minimumBpm','maximumBpm','estimatedRmssdMs','sdnnMs','quality','coverage','valid','algorithmVersion']) &&
|
||||||
|
source(d.source) && bounded(d.deviceId,100) && d.startedAt is timestamp && d.endedAt is timestamp &&
|
||||||
|
d.startedAt <= d.endedAt && d.endedAt <= request.time && d.durationSeconds is number && d.durationSeconds > 0 && d.durationSeconds <= 7200 &&
|
||||||
|
nullableNumber(d.averageBpm,300) && nullableNumber(d.minimumBpm,300) && nullableNumber(d.maximumBpm,300) &&
|
||||||
|
nullableNumber(d.estimatedRmssdMs,5000) && nullableNumber(d.sdnnMs,5000) && unit(d.quality) && unit(d.coverage) && d.valid is bool && bounded(d.algorithmVersion,80);
|
||||||
|
}
|
||||||
|
allow read: if active(uid);
|
||||||
|
allow create: if active(uid) && alive(uid,'measurement',id) && measurement(request.resource.data);
|
||||||
|
}
|
||||||
|
match /measurementAnnotations/{id} {
|
||||||
|
allow read: if active(uid);
|
||||||
|
allow create, update: if active(uid) && alive(uid,'measurement',id) && exists(/databases/$(database)/documents/users/$(uid)/measurements/$(id)) &&
|
||||||
|
stamp(request.resource.data) && revision(request.resource.data) &&
|
||||||
|
request.resource.data.keys().hasOnly(['schemaVersion','createdAt','updatedAt','revision','feeling','posture','atRest','recentExercise','recentCaffeine','recentMeal','recordedAt']) &&
|
||||||
|
feeling(request.resource.data.feeling) && request.resource.data.posture in [null,'seated','standing','lying','unknown'] &&
|
||||||
|
request.resource.data.atRest is bool && request.resource.data.recentExercise is bool && request.resource.data.recentCaffeine is bool && request.resource.data.recentMeal is bool &&
|
||||||
|
request.resource.data.recordedAt is timestamp && request.resource.data.recordedAt <= request.time;
|
||||||
|
}
|
||||||
|
match /sessions/{id} {
|
||||||
|
function refValid(id) { return id == null || (id is string && exists(/databases/$(database)/documents/users/$(uid)/measurements/$(id))); }
|
||||||
|
function session(d) {
|
||||||
|
return stamp(d) && revision(d) && d.keys().hasOnly(['schemaVersion','createdAt','updatedAt','revision','practiceId','protocolVersion','deviceId','source','startedAt','endedAt','plannedDurationSeconds','actualDurationSeconds','status','beforeMeasurementId','afterMeasurementId','feelingBefore','feelingAfter']) &&
|
||||||
|
d.practiceId in ['breathing','anchoring','meditation','sleep','wake','coherence'] && d.protocolVersion is int && d.protocolVersion >= 1 &&
|
||||||
|
bounded(d.deviceId,100) && source(d.source) && d.startedAt is timestamp && d.startedAt <= request.time &&
|
||||||
|
(d.endedAt == null || (d.endedAt is timestamp && d.endedAt >= d.startedAt && d.endedAt <= request.time)) &&
|
||||||
|
d.plannedDurationSeconds is int && d.plannedDurationSeconds > 0 && d.plannedDurationSeconds <= 7200 &&
|
||||||
|
d.actualDurationSeconds is number && d.actualDurationSeconds >= 0 && d.actualDurationSeconds <= 86400 &&
|
||||||
|
d.status in ['inProgress','awaitingAfter','completed','cancelled'] &&
|
||||||
|
(d.status == 'inProgress' || d.endedAt != null) && refValid(d.beforeMeasurementId) && refValid(d.afterMeasurementId) &&
|
||||||
|
feeling(d.feelingBefore) && feeling(d.feelingAfter);
|
||||||
|
}
|
||||||
|
allow read: if active(uid);
|
||||||
|
allow create: if active(uid) && alive(uid,'session',id) && session(request.resource.data);
|
||||||
|
allow update: if active(uid) && alive(uid,'session',id) && session(request.resource.data) &&
|
||||||
|
request.resource.data.diff(resource.data).affectedKeys().hasOnly(['updatedAt','revision','endedAt','actualDurationSeconds','status','afterMeasurementId','feelingBefore','feelingAfter']) &&
|
||||||
|
(resource.data.status == 'inProgress' || (resource.data.status == 'awaitingAfter' && request.resource.data.status in ['awaitingAfter','completed','cancelled']));
|
||||||
|
}
|
||||||
|
// Reserved server-owned collections. Unimplemented client contracts stay closed.
|
||||||
|
match /{collection}/{id} {
|
||||||
|
allow read: if active(uid) && collection in ['devices','reminders','dailyStats','baselines','entitlements','tombstones'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
match /protocols/{id}/versions/{version} { allow read: if request.auth != null && resource.data.published == true; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
const {initializeApp}=require('firebase-admin/app');
|
||||||
|
const {getFirestore,FieldValue}=require('firebase-admin/firestore');
|
||||||
|
const {getAuth}=require('firebase-admin/auth');
|
||||||
|
const {onCall,HttpsError}=require('firebase-functions/v2/https');
|
||||||
|
initializeApp();
|
||||||
|
const db=getFirestore();
|
||||||
|
const options={region:'europe-west1',maxInstances:3};
|
||||||
|
function uidOf(request){if(!request.auth)throw new HttpsError('unauthenticated','Connexion requise.');return request.auth.uid;}
|
||||||
|
exports.deleteEntity=onCall(options,async request=>{
|
||||||
|
const uid=uidOf(request),{kind,id}=request.data??{};
|
||||||
|
if(!['measurement','session'].includes(kind)||typeof id!=='string'||!/^[a-zA-Z0-9-]{1,100}$/.test(id))throw new HttpsError('invalid-argument','Entité invalide.');
|
||||||
|
const user=db.doc(`users/${uid}`),ref=user.collection(kind==='measurement'?'measurements':'sessions').doc(id);
|
||||||
|
// Tombstone and deletion are atomic. It remains even if cleanup below retries.
|
||||||
|
await db.runTransaction(async tx=>{const profile=await tx.get(user);if(profile.data()?.accountState!=='active')throw new HttpsError('failed-precondition','Compte indisponible.');const tombstone=user.collection('tombstones').doc(`${kind}_${id}`);const existing=await tx.get(tombstone);if(!existing.exists)tx.create(tombstone,{schemaVersion:1,entityId:id,entityKind:kind,createdAt:FieldValue.serverTimestamp(),updatedAt:FieldValue.serverTimestamp()});tx.delete(ref);if(kind==='measurement')tx.delete(user.collection('measurementAnnotations').doc(id));});
|
||||||
|
if(kind==='measurement'){
|
||||||
|
// Bounded batches; repeated calls finish cleanup after a transient failure.
|
||||||
|
for(const field of ['beforeMeasurementId','afterMeasurementId']){
|
||||||
|
let page;
|
||||||
|
do {page=await user.collection('sessions').where(field,'==',id).limit(200).get();if(page.empty)break;const batch=db.batch();for(const d of page.docs)batch.update(d.ref,{[field]:null,revision:FieldValue.increment(1),updatedAt:FieldValue.serverTimestamp()});await batch.commit();}while(!page.empty);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Derived data is disposable. Recompute before exposing it again.
|
||||||
|
await db.recursiveDelete(user.collection('dailyStats'));
|
||||||
|
await db.recursiveDelete(user.collection('baselines'));
|
||||||
|
return {deleted:true};
|
||||||
|
});
|
||||||
|
exports.deleteAccount=onCall(options,async request=>{
|
||||||
|
const uid=uidOf(request);
|
||||||
|
if(Date.now()/1000-request.auth.token.auth_time>300)throw new HttpsError('failed-precondition','Reconnecte-toi avant de supprimer ton compte.');
|
||||||
|
const user=db.doc(`users/${uid}`);
|
||||||
|
await user.set({accountState:'deleting',updatedAt:FieldValue.serverTimestamp()},{merge:true});
|
||||||
|
// Keep blocking profile until all child collections have been removed.
|
||||||
|
for(const collection of await user.listCollections())await db.recursiveDelete(collection);
|
||||||
|
await getAuth().revokeRefreshTokens(uid);
|
||||||
|
try{await getAuth().deleteUser(uid);}catch(e){if(e.code!=='auth/user-not-found')throw e;}
|
||||||
|
// Retain a minimal deleting marker to deny old ID-token replay until expiry.
|
||||||
|
// No profile or health information remains in the marker.
|
||||||
|
await user.set({schemaVersion:1,accountState:'deleting',updatedAt:FieldValue.serverTimestamp()});
|
||||||
|
return {deleted:true};
|
||||||
|
});
|
||||||
Generated
+2971
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
|||||||
|
{"name":"haptika-server","private":true,"main":"index.js","engines":{"node":"22"},"dependencies":{"firebase-admin":"^13.0.0","firebase-functions":"^6.0.0"}}
|
||||||
Generated
+8701
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
|||||||
|
{"name":"haptika-firestore","private":true,"scripts":{"test":"firebase emulators:exec --project demo-haptika --only firestore 'node --test tests/*.test.cjs'"},"devDependencies":{"@firebase/rules-unit-testing":"4.0.1","firebase":"11.10.0","firebase-tools":"13.12.0"}}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
const {before, after, test} = require('node:test');
|
||||||
|
const {readFileSync} = require('node:fs');
|
||||||
|
const {initializeTestEnvironment, assertSucceeds:ok, assertFails:no} = require('@firebase/rules-unit-testing');
|
||||||
|
const {doc, setDoc, updateDoc, getDoc, deleteDoc, serverTimestamp, Timestamp} = require('firebase/firestore');
|
||||||
|
let env, a, b;
|
||||||
|
const meta=()=>({schemaVersion:1,createdAt:serverTimestamp(),updatedAt:serverTimestamp()});
|
||||||
|
const profile=()=>({...meta(),accountState:'active',firstName:'Test',birthYear:null,gender:null,locale:'fr',timeZone:'Europe/Paris'});
|
||||||
|
const measure=()=>({...meta(),source:'appleWatchOptical',deviceId:'watch-1',startedAt:Timestamp.fromMillis(1000),endedAt:Timestamp.fromMillis(61000),durationSeconds:60,averageBpm:70,minimumBpm:65,maximumBpm:80,estimatedRmssdMs:null,sdnnMs:null,quality:0.9,coverage:1,valid:true,algorithmVersion:'1'});
|
||||||
|
const session=()=>({...meta(),revision:1,practiceId:'breathing',protocolVersion:1,deviceId:'phone-1',source:'iphoneCameraPPG',startedAt:Timestamp.fromMillis(1000),endedAt:null,plannedDurationSeconds:300,actualDurationSeconds:0,status:'inProgress',beforeMeasurementId:null,afterMeasurementId:null,feelingBefore:null,feelingAfter:null});
|
||||||
|
before(async()=>{env=await initializeTestEnvironment({projectId:'demo-haptika',firestore:{host:'127.0.0.1',port:8085,rules:readFileSync('firestore.rules','utf8')}});a=env.authenticatedContext('a').firestore();b=env.authenticatedContext('b').firestore();await setDoc(doc(a,'users/a'),profile());await setDoc(doc(b,'users/b'),profile());});
|
||||||
|
after(async()=>env?.cleanup());
|
||||||
|
test('authentication and ownership',async()=>{await no(getDoc(doc(env.unauthenticatedContext().firestore(),'users/a')));await no(getDoc(doc(b,'users/a')));await no(setDoc(doc(b,'users/a/measurements/cross'),measure()));await ok(getDoc(doc(a,'users/a')));});
|
||||||
|
test('profile rejects privilege and invalid fields',async()=>{for(const patch of [{plan:'premium'},{birthYear:3000},{accountState:'deleting'}])await no(updateDoc(doc(a,'users/a'),{...patch,updatedAt:serverTimestamp()}));});
|
||||||
|
test('measurements immutable and validated',async()=>{const ref=doc(a,'users/a/measurements/valid');await ok(setDoc(ref,measure()));await no(updateDoc(ref,{averageBpm:95,updatedAt:serverTimestamp()}));await no(deleteDoc(ref));for(const [id,patch] of [['bad',{quality:2}],['time',{updatedAt:Timestamp.fromMillis(0)}],['raw',{samples:[70]}]])await no(setDoc(doc(a,`users/a/measurements/${id}`),{...measure(),...patch}));});
|
||||||
|
test('client cannot grant server-owned data',async()=>{for(const c of ['entitlements','baselines','dailyStats','tombstones'])await no(setDoc(doc(a,`users/a/${c}/current`),{plan:'premium'}));await no(setDoc(doc(a,'protocols/breathing/versions/1'),{published:true}));});
|
||||||
|
test('settings closed schema',async()=>{const p={...meta(),notificationsEnabled:false,saveMindAndBodyWorkouts:false,blackScreenEnabled:true};await ok(setDoc(doc(a,'users/a/preferences/general'),p));await no(setDoc(doc(a,'users/a/preferences/other'),p));await no(setDoc(doc(a,'users/a/practiceSettings/breathing'),{...meta(),durationSeconds:0,intensity:0.5,soundEnabled:false}));});
|
||||||
|
test('session revision, references and terminal state',async()=>{const ref=doc(a,'users/a/sessions/s1');await ok(setDoc(ref,session()));await no(updateDoc(ref,{revision:3,updatedAt:serverTimestamp()}));await no(updateDoc(ref,{revision:2,afterMeasurementId:'missing',updatedAt:serverTimestamp()}));await ok(updateDoc(ref,{revision:2,status:'completed',endedAt:Timestamp.fromMillis(301000),actualDurationSeconds:300,updatedAt:serverTimestamp()}));await no(updateDoc(ref,{revision:3,status:'inProgress',updatedAt:serverTimestamp()}));});
|
||||||
|
test('annotations require parent, revision and feeling',async()=>{const v={...meta(),revision:1,feeling:3,posture:'seated',atRest:true,recentExercise:false,recentCaffeine:false,recentMeal:false,recordedAt:Timestamp.fromMillis(61000)};await no(setDoc(doc(a,'users/a/measurementAnnotations/missing'),v));await setDoc(doc(a,'users/a/measurements/annotated'),measure());await ok(setDoc(doc(a,'users/a/measurementAnnotations/annotated'),v));await no(updateDoc(doc(a,'users/a/measurementAnnotations/annotated'),{feeling:6,revision:2,updatedAt:serverTimestamp()}));});
|
||||||
|
test('tombstones block resurrection',async()=>{await env.withSecurityRulesDisabled(async c=>setDoc(doc(c.firestore(),'users/a/tombstones/measurement_deleted'),{deletedAt:Timestamp.now()}));await no(setDoc(doc(a,'users/a/measurements/deleted'),measure()));});
|
||||||
|
test('deleting account is blocked',async()=>{await env.withSecurityRulesDisabled(async c=>setDoc(doc(c.firestore(),'users/deleting'),{...profile(),accountState:'deleting'}));const db=env.authenticatedContext('deleting').firestore();await no(setDoc(doc(db,'users/deleting/measurements/m'),measure()));await no(updateDoc(doc(db,'users/deleting'),{accountState:'active',updatedAt:serverTimestamp()}));});
|
||||||
|
test('server deletion is idempotent and clears same-user references',async()=>{
|
||||||
|
process.env.GCLOUD_PROJECT='demo-haptika';process.env.FIRESTORE_EMULATOR_HOST='127.0.0.1:8085';
|
||||||
|
const {deleteEntity}=require('../functions');
|
||||||
|
await env.withSecurityRulesDisabled(async c=>{
|
||||||
|
const db=c.firestore();await setDoc(doc(db,'users/delete-test'),{...profile()});
|
||||||
|
await setDoc(doc(db,'users/delete-test/measurements/target'),measure());
|
||||||
|
await setDoc(doc(db,'users/delete-test/measurementAnnotations/target'),{feeling:3});
|
||||||
|
await setDoc(doc(db,'users/delete-test/sessions/linked'),{...session(),beforeMeasurementId:'target'});
|
||||||
|
});
|
||||||
|
const request={auth:{uid:'delete-test',token:{}},data:{kind:'measurement',id:'target'}};
|
||||||
|
await deleteEntity.run(request);await deleteEntity.run(request);
|
||||||
|
await env.withSecurityRulesDisabled(async c=>{
|
||||||
|
const db=c.firestore();require('node:assert/strict').equal((await getDoc(doc(db,'users/delete-test/measurements/target'))).exists(),false);
|
||||||
|
require('node:assert/strict').equal((await getDoc(doc(db,'users/delete-test/measurementAnnotations/target'))).exists(),false);
|
||||||
|
require('node:assert/strict').equal((await getDoc(doc(db,'users/delete-test/tombstones/measurement_target'))).exists(),true);
|
||||||
|
require('node:assert/strict').equal((await getDoc(doc(db,'users/delete-test/sessions/linked'))).data().beforeMeasurementId,null);
|
||||||
|
});
|
||||||
|
await require('node:assert/strict').rejects(deleteEntity.run({data:{kind:'measurement',id:'target'}}),{code:'unauthenticated'});
|
||||||
|
});
|
||||||
@@ -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
@@ -41,7 +41,13 @@ Le choix iPhone / iPhone + Apple Watch se fait sur l'étape « Comment allez vou
|
|||||||
|
|
||||||
## Limites fonctionnelles
|
## 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.
|
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.
|
||||||
|
|
||||||
|
|||||||
+38
-4
@@ -7,9 +7,43 @@
|
|||||||
"orientation": "portrait",
|
"orientation": "portrait",
|
||||||
"userInterfaceStyle": "dark",
|
"userInterfaceStyle": "dark",
|
||||||
"newArchEnabled": true,
|
"newArchEnabled": true,
|
||||||
"ios": { "supportsTablet": false, "bundleIdentifier": "com.haptika.mobile.dev" },
|
"ios": {
|
||||||
"android": { "package": "com.haptika.mobile.dev", "edgeToEdgeEnabled": true },
|
"supportsTablet": false,
|
||||||
"plugins": ["expo-router", "expo-font", ["expo-dev-client", {"launchMode":"most-recent"}]],
|
"bundleIdentifier": "com.haptika.mobile.dev",
|
||||||
"experiments": {"typedRoutes": true}
|
"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,11 +2,12 @@ import { Stack } from 'expo-router';
|
|||||||
import { StatusBar } from 'expo-status-bar';
|
import { StatusBar } from 'expo-status-bar';
|
||||||
import { useFonts } from 'expo-font';
|
import { useFonts } from 'expo-font';
|
||||||
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
||||||
|
import {AuthProvider} from '../src/firebase/auth-state';
|
||||||
import { AppProvider } from '../src/state/app-state';
|
import { AppProvider } from '../src/state/app-state';
|
||||||
import { theme } from '../src/design/theme';
|
import { theme } from '../src/design/theme';
|
||||||
|
|
||||||
export default function Layout() {
|
export default function Layout() {
|
||||||
const [loaded, error] = useFonts({Inter: require('../assets/fonts/Inter.ttf')});
|
const [loaded, error] = useFonts({Inter: require('../assets/fonts/Inter.ttf')});
|
||||||
if (!loaded && !error) return null;
|
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>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Redirect } from 'expo-router';
|
import { Redirect } from 'expo-router';
|
||||||
import { useAppState } from '../src/state/app-state';
|
import { useAppState } from '../src/state/app-state';
|
||||||
export default function Index() {
|
export default function Index() {
|
||||||
const {state, ready} = useAppState();
|
const {state, ready, syncStatus} = useAppState();
|
||||||
if (!ready) return null;
|
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'}}} />;
|
return <Redirect href={{pathname: '/screen/[id]', params: {id: state.onboarded ? (state.variant === 'watch' ? '560-251' : '431-2') : '2-2'}}} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
Generated
+898
@@ -10,7 +10,11 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@expo/metro-runtime": "~6.1.2",
|
"@expo/metro-runtime": "~6.1.2",
|
||||||
"@react-native-async-storage/async-storage": "2.2.0",
|
"@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": "~54.0.36",
|
||||||
|
"expo-build-properties": "~1.0.10",
|
||||||
"expo-constants": "~18.0.14",
|
"expo-constants": "~18.0.14",
|
||||||
"expo-dev-client": "~6.0.21",
|
"expo-dev-client": "~6.0.21",
|
||||||
"expo-font": "~14.0.12",
|
"expo-font": "~14.0.12",
|
||||||
@@ -2437,6 +2441,645 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/@isaacs/fs-minipass": {
|
||||||
"version": "4.0.1",
|
"version": "4.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",
|
||||||
@@ -2786,6 +3429,63 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@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": {
|
"node_modules/@radix-ui/primitive": {
|
||||||
"version": "1.1.7",
|
"version": "1.1.7",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.7.tgz",
|
"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"
|
"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": {
|
"node_modules/@react-native/assets-registry": {
|
||||||
"version": "0.81.5",
|
"version": "0.81.5",
|
||||||
"resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.81.5.tgz",
|
"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": {
|
"node_modules/expo-constants": {
|
||||||
"version": "18.0.14",
|
"version": "18.0.14",
|
||||||
"resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.14.tgz",
|
"resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.14.tgz",
|
||||||
@@ -6027,6 +6789,18 @@
|
|||||||
],
|
],
|
||||||
"license": "BSD-3-Clause"
|
"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": {
|
"node_modules/fb-watchman": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz",
|
||||||
@@ -6133,6 +6907,42 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/flow-enums-runtime": {
|
||||||
"version": "0.0.6",
|
"version": "0.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/flow-enums-runtime/-/flow-enums-runtime-0.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/flow-enums-runtime/-/flow-enums-runtime-0.0.6.tgz",
|
||||||
@@ -6343,6 +7153,12 @@
|
|||||||
"node": ">= 0.8"
|
"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": {
|
"node_modules/https-proxy-agent": {
|
||||||
"version": "7.0.6",
|
"version": "7.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
|
"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==",
|
"integrity": "sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw==",
|
||||||
"license": "BSD-3-Clause"
|
"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": {
|
"node_modules/ieee754": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
||||||
@@ -7357,6 +8179,12 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/lodash.debounce": {
|
||||||
"version": "4.0.8",
|
"version": "4.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
|
||||||
@@ -7381,6 +8209,12 @@
|
|||||||
"node": ">=4"
|
"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": {
|
"node_modules/loose-envify": {
|
||||||
"version": "1.4.0",
|
"version": "1.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||||
@@ -8466,6 +9300,29 @@
|
|||||||
"node": ">= 6"
|
"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": {
|
"node_modules/punycode": {
|
||||||
"version": "2.3.1",
|
"version": "2.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||||
@@ -8696,6 +9553,18 @@
|
|||||||
"react-native": "*"
|
"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": {
|
"node_modules/react-native-web": {
|
||||||
"version": "0.21.2",
|
"version": "0.21.2",
|
||||||
"resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.21.2.tgz",
|
"resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.21.2.tgz",
|
||||||
@@ -10376,6 +11245,12 @@
|
|||||||
"defaults": "^1.0.3"
|
"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": {
|
"node_modules/webidl-conversions": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-5.0.0.tgz",
|
||||||
@@ -10385,6 +11260,29 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/whatwg-fetch": {
|
||||||
"version": "3.6.20",
|
"version": "3.6.20",
|
||||||
"resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz",
|
"resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz",
|
||||||
|
|||||||
@@ -12,7 +12,11 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@expo/metro-runtime": "~6.1.2",
|
"@expo/metro-runtime": "~6.1.2",
|
||||||
"@react-native-async-storage/async-storage": "2.2.0",
|
"@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": "~54.0.36",
|
||||||
|
"expo-build-properties": "~1.0.10",
|
||||||
"expo-constants": "~18.0.14",
|
"expo-constants": "~18.0.14",
|
||||||
"expo-dev-client": "~6.0.21",
|
"expo-dev-client": "~6.0.21",
|
||||||
"expo-font": "~14.0.12",
|
"expo-font": "~14.0.12",
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 149 KiB |
@@ -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,'');});
|
||||||
@@ -33,6 +33,7 @@ export function DesignScreen({id, preview=false}: {id:string; preview?:boolean})
|
|||||||
const scale = Math.min(width,600)/402;
|
const scale = Math.min(width,600)/402;
|
||||||
const header = scene?.layers.find(l=>/Header - TopAppBar|Header - Top App Bar/.test(l.name));
|
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 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 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 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);};
|
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) {
|
function replacement(layer:Layer) {
|
||||||
if(preview)return undefined;
|
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(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);
|
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;
|
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>;}
|
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)} />;
|
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;
|
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 top=preview?0:insets.top;
|
||||||
const bottom=preview?0:insets.bottom;
|
const bottom=preview?0:insets.bottom;
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -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.');
|
||||||
|
}
|
||||||
@@ -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()}));}
|
||||||
@@ -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}};
|
||||||
|
}
|
||||||
@@ -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';
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
type State = {onboarded: boolean; variant: 'phone' | 'watch'; values: Record<string, string>; toggles: Record<string, boolean>};
|
import {useAuth} from '../firebase/auth-state';
|
||||||
const initial: State = {onboarded: false, variant: 'phone', values: {}, toggles: {}};
|
import {ensureProfile,readAccount,applyUserPatch,type UserPatch} from '../firebase/repository';
|
||||||
const key = 'haptika.mobile.preferences.v1';
|
import {initialState,toUserPatch,mergePatch,fromAccount,type LocalState} from '../firebase/state-mapping';
|
||||||
const Context = createContext<{state: State; ready: boolean; update: (patch: Partial<State>) => void}>({state: initial, ready: false, update: () => {}});
|
import {auth} from '../firebase/client';
|
||||||
export function AppProvider({children}: {children: React.ReactNode}) {
|
|
||||||
const [state, setState] = useState(initial);
|
type SyncStatus='local'|'loading'|'pending'|'synced'|'error';
|
||||||
const [ready, setReady] = useState(false);
|
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:()=>{}});
|
||||||
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;};}, []);
|
export function AppProvider({children}:{children:React.ReactNode}) {
|
||||||
useEffect(() => {if (ready) AsyncStorage.setItem(key, JSON.stringify(state)).catch(() => {});}, [state, ready]);
|
const {user,ready}=useAuth();
|
||||||
const update = (patch: Partial<State>) => setState(previous => ({...previous, ...patch}));
|
if(!ready)return null;
|
||||||
return <Context value={{state, ready, update}}>{children}</Context>;
|
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);
|
||||||
|
|||||||
Reference in New Issue
Block a user