chore: remove v1 features and old scaffolding
Remove onboarding flow (6 screens), timer engine, audio engine, old components (themed-text/view, parallax-scroll, hello-wave), old constants (theme, shadows, timer), and utility files that were replaced by the v2 architecture. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,597 +0,0 @@
|
||||
|
||||
|
||||
| 🔥 TABATAGO Application Mobile de Minuterie Tabata *Product Requirements Document (PRD) v1.1* Framework : Expo (React Native) · Cible : iOS & Android · Février 2026 |
|
||||
| :---: |
|
||||
|
||||
# **1\. Résumé Exécutif**
|
||||
|
||||
TabataGo est une application mobile premium dédiée à l'entraînement par intervalles en méthode Tabata (20 secondes d'effort / 10 secondes de repos). Elle cible les personnes qui veulent des séances courtes, intenses et guidées — sans abonnement à une salle. Le marché des applications de fitness dépasse 1,5 milliard de dollars en revenus annuels et le Tabata reste un mot-clé à forte demande (popularité \> 70\) avec une difficulté ASO modérée (\< 50).
|
||||
|
||||
| Indicateur | Valeur cible |
|
||||
| :---- | :---- |
|
||||
| Marché visé | Fitness enthousiasts 25-45 ans, Home workout, HIIT lovers |
|
||||
| Plateformes | iOS (priorité) \+ Android |
|
||||
| Framework | Expo (React Native) — SDK 52+ |
|
||||
| Modèle économique | Freemium \+ Abonnement mensuel/annuel via RevenueCat |
|
||||
| Prix cible (US) | $4.99/mois ou $29.99/an |
|
||||
| Objectif J+30 | 5 000 téléchargements, taux de conversion essai \> 30% |
|
||||
| Objectif J+90 | MRR $5 000, note App Store ≥ 4.6 |
|
||||
|
||||
# **2\. Fonctionnalités Core — Spécifications Détaillées**
|
||||
|
||||
TabataGo repose sur trois piliers fonctionnels indissociables : le Timer (moteur de l'expérience), la Musique (moteur émotionnel), et l'Exercice (moteur pédagogique). Ces trois composants doivent fonctionner en parfaite synchronisation.
|
||||
|
||||
## **2.1 Le Timer — Moteur Central**
|
||||
|
||||
Le timer est la fonctionnalité principale et vitale de l'application. Son implémentation doit être irréprochable : précision, fluidité visuelle, comportement en arrière-plan.
|
||||
|
||||
### **2.1.1 Structure d'une Séance Tabata Standard**
|
||||
|
||||
| Phase | Durée par défaut | Configurable ? | Couleur écran | Signal |
|
||||
| :---- | :---- | :---- | :---- | :---- |
|
||||
| Préparation (Get Ready) | 10 secondes | Oui (5–30s) | Jaune \#EAB308 | Bip court × 3 \+ vibration légère |
|
||||
| Travail (Work) | 20 secondes | Oui (5–60s) | Orange vif \#F97316 | Bip long au démarrage \+ haptique fort |
|
||||
| Repos (Rest) | 10 secondes | Oui (5–60s) | Bleu calme \#3B82F6 | Double bip court \+ haptique léger |
|
||||
| Fin de round | Instantané | — | Flash blanc | Son de cloche \+ vibration |
|
||||
| Fin de séance | Écran résultat | — | Vert \#22C55E | Fanfare \+ longue vibration |
|
||||
|
||||
### **2.1.2 Paramètres Configurables du Timer**
|
||||
|
||||
| Paramètre | Valeur par défaut | Min | Max | Accès |
|
||||
| :---- | :---- | :---- | :---- | :---- |
|
||||
| Durée Work | 20s | 5s | 60s | Tous |
|
||||
| Durée Rest | 10s | 5s | 60s | Tous |
|
||||
| Nombre de rounds | 8 | 1 | 30 | Tous |
|
||||
| Durée Get Ready | 10s | 0s | 30s | Tous |
|
||||
| Nombre de cycles | 1 | 1 | 10 | Premium |
|
||||
| Pause entre cycles | 60s | 10s | 300s | Premium |
|
||||
| Cycles de récupération | — | — | — | Premium |
|
||||
|
||||
### **2.1.3 Affichage Timer — Écran Séance (plein écran)**
|
||||
|
||||
| 📱 LAYOUT — Écran Timer Plein Écran |
|
||||
| :---- |
|
||||
| ▸ Zone HAUTE (20%) : Nom de l'exercice en cours \+ numéro de round (ex: "Burpees — Round 3/8") |
|
||||
| ▸ Zone CENTRALE (50%) : Chiffre du compte à rebours — très grand (96-120px), police monospace, couleur de phase |
|
||||
| ▸ Zone BASSE HAUTE (15%) : Barre de progression de la séance complète (rounds) \+ indicateur phase actuelle |
|
||||
| ▸ Zone BASSE (15%) : Boutons Pause / Stop / Skip — discrets pour ne pas distraire |
|
||||
| ▸ FOND : Couleur dynamique selon la phase (orange work, bleu rest, jaune prep) avec transition animée |
|
||||
| ▸ ANIMATION : Pulsation subtile du chiffre à chaque seconde \+ ring circulaire de progression |
|
||||
|
||||
### **2.1.4 Comportement Technique du Timer**
|
||||
|
||||
* Précision : Utiliser expo-background-fetch \+ Date.now() delta pour compenser les drifts — tolérance \< 50ms
|
||||
|
||||
* Background : Le timer continue en arrière-plan (notification sticky affichant le compte à rebours)
|
||||
|
||||
* Verrouillage écran : Écran reste allumé pendant la séance (expo-keep-awake)
|
||||
|
||||
* Interruptions : Pause automatique si appel téléphonique entrant (AppState listener)
|
||||
|
||||
* Reprise : Si l'app est tuée, afficher une notification "Séance interrompue — Reprendre ?" au retour
|
||||
|
||||
* OTA update safe : Le state du timer est isolé du cycle de render React pour éviter les glitches
|
||||
|
||||
## **2.2 La Musique — Moteur Émotionnel**
|
||||
|
||||
La musique transforme une simple minuterie en expérience motivante. Elle doit s'adapter dynamiquement à chaque phase de la séance (work vs rest) et ne jamais entrer en conflit avec la musique de l'utilisateur ou les signaux sonores.
|
||||
|
||||
### **2.2.1 Architecture Sonore**
|
||||
|
||||
| Couche audio | Description | Technologie | Contrôle utilisateur |
|
||||
| :---- | :---- | :---- | :---- |
|
||||
| Musique d'ambiance | Tracks BPM-synchronisées intégrées à l'app | expo-av (AVAudioSession) | Volume indépendant, on/off |
|
||||
| Signaux de phase | Bips, voix, cloche — changement work/rest | expo-av (priorité haute) | Volume indépendant, choix du son |
|
||||
| Voix coach | Annonces vocales (optionnel) : "Go\!", "Rest", "Last round\!" | expo-av (TTS ou fichiers pré-enregistrés) | On/off, langue |
|
||||
| Haptiques | Vibrations synchronisées aux signaux | expo-haptics | On/off |
|
||||
|
||||
### **2.2.2 Catalogue Musical Intégré (Offline)**
|
||||
|
||||
| 🎵 TRACKS INTÉGRÉES — 3 ambiances × 3 intensités \= 9 tracks minimum |
|
||||
| :---- |
|
||||
| ▸ Ambiance ELECTRO : Low (récup), Medium (standard), High (intense) — BPM 120/140/160 |
|
||||
| ▸ Ambiance HIP-HOP : Low (récup), Medium (standard), High (intense) — BPM 85/100/115 |
|
||||
| ▸ Ambiance ROCK/METAL : Low (récup), Medium (standard), High (intense) — BPM 130/150/170 |
|
||||
| ▸ Mode SILENCE : Aucune musique, uniquement les signaux sonores de phase |
|
||||
| ▸ Mode SPOTIFY/APPLE MUSIC : L'app n'interfère pas avec la musique de l'utilisateur (coexistence audio) |
|
||||
| ▸ Toutes les tracks sont royalty-free et embarquées dans le bundle — aucun streaming requis |
|
||||
|
||||
### **2.2.3 Synchronisation Musique ↔ Timer**
|
||||
|
||||
* Phase WORK : Passer automatiquement à la track haute intensité de l'ambiance sélectionnée
|
||||
|
||||
* Phase REST : Transition en fade-out 1s vers la track basse intensité (ambiance calme)
|
||||
|
||||
* Phase GET READY : Intro de 10s sur la track principale
|
||||
|
||||
* Transition douce : Cross-fade 500ms entre les phases pour éviter les coupures brutales
|
||||
|
||||
* BPM adaptatif (Premium) : L'app détecte le rythme de la track et aligne le bip de fin de phase sur le beat
|
||||
|
||||
* Pas de conflit : Si l'utilisateur a sa propre musique, les signaux de phase s'y superposent en ducking audio (baisse temporaire du volume)
|
||||
|
||||
### **2.2.4 Signaux Sonores de Phase — Options**
|
||||
|
||||
| Signal | Options disponibles | Par défaut | Premium uniquement ? |
|
||||
| :---- | :---- | :---- | :---- |
|
||||
| Début Work | Bip long, Whistle, Voix "Go\!", Air horn, Clap | Bip long | Non |
|
||||
| Début Rest | Double bip, Voix "Rest", Bell, Ding | Double bip | Non |
|
||||
| Décompte 3-2-1 | Bips courts, Voix "3, 2, 1", Silence | Bips courts | Non |
|
||||
| Fin de round | Cloche, Applaudissements, Voix "Round X done\!" | Cloche | Oui |
|
||||
| Fin de séance | Fanfare, Applaudissements, Voix "Workout complete\!" | Fanfare | Non |
|
||||
| Dernier round | Voix "Last round\!", Alarm, Son spécial | Voix | Oui |
|
||||
|
||||
### **2.2.5 Gestion Technique Audio (expo-av)**
|
||||
|
||||
* Session audio iOS : AVAudioSessionCategoryPlayback avec MixWithOthers — permet de jouer avec la musique utilisateur
|
||||
|
||||
* Focus audio Android : AudioManager.AUDIOFOCUS\_GAIN\_TRANSIENT\_MAY\_DUCK pour les signaux
|
||||
|
||||
* Préchargement : Tous les sons de phase sont chargés en mémoire au démarrage de la séance (zéro latence)
|
||||
|
||||
* Mode silencieux iOS : Les signaux de phase respectent le switch mute SAUF si l'utilisateur a activé "override" dans les settings
|
||||
|
||||
* Headphones détection : Si écouteurs branchés, désactiver les haptiques de phase par défaut
|
||||
|
||||
## **2.3 L'Exercice — Moteur Pédagogique**
|
||||
|
||||
Chaque round du timer doit être associé à un exercice spécifique, affiché clairement pendant la phase de travail. C'est ce qui différencie TabataGo d'une simple minuterie générique.
|
||||
|
||||
### **2.3.1 Bibliothèque d'Exercices**
|
||||
|
||||
| Catégorie | Exemples d'exercices | Nb exercices V1 | Nb exercices V2 |
|
||||
| :---- | :---- | :---- | :---- |
|
||||
| Cardio / Full body | Burpees, Jumping Jacks, Mountain Climbers, High Knees | 8 | 20 |
|
||||
| Bas du corps | Squats, Fentes, Jump Squats, Glute Bridges, Wall Sit | 8 | 20 |
|
||||
| Haut du corps | Push-ups, Pike Push-ups, Tricep Dips, Shoulder Taps | 6 | 15 |
|
||||
| Abdos / Core | Crunches, Planche, Russian Twists, Bicycle Crunches | 6 | 15 |
|
||||
| Sans saut (low impact) | Slow Squats, Modified Push-ups, Step Touch, March | 6 | 15 |
|
||||
| Avec matériel | Kettlebell Swings, Dumbbell Thrusters, Jump Rope | 4 | 10 |
|
||||
|
||||
### **2.3.2 Fiche Exercice — Données par Exercice**
|
||||
|
||||
| 📋 MODÈLE DE DONNÉES — Exercice |
|
||||
| :---- |
|
||||
| ▸ id : identifiant unique (ex: "burpee\_classic") |
|
||||
| ▸ name : Nom localisé (FR: "Burpee", EN: "Burpee", ES: "Burpee") |
|
||||
| ▸ category : cardio | lower\_body | upper\_body | core | low\_impact | equipment |
|
||||
| ▸ difficulty : beginner | intermediate | advanced |
|
||||
| ▸ musclesTargeted : string\[\] (ex: \["quadriceps", "pectoraux", "cardio"\]) |
|
||||
| ▸ description : Instruction courte (max 80 car.) — affichée pendant la phase Rest |
|
||||
| ▸ cues : string\[\] — 2-3 points clés de forme (ex: "Dos droit", "Genoux alignés") |
|
||||
| ▸ gifUrl : Animation GIF courte (1-2s, loop) — 200×200px max — embarquée offline |
|
||||
| ▸ thumbnailUrl : Image statique pour la bibliothèque |
|
||||
| ▸ hasModification : bool — si une variante plus facile existe |
|
||||
| ▸ modificationId : id de l'exercice de remplacement (ex: "burpee\_modified") |
|
||||
| ▸ equipmentNeeded : string\[\] (ex: \[\] pour aucun, \["tapis"\] pour matériel simple) |
|
||||
|
||||
### **2.3.3 Affichage de l'Exercice Pendant la Séance**
|
||||
|
||||
| Phase | Affichage exercice | Taille | Information complémentaire |
|
||||
| :---- | :---- | :---- | :---- |
|
||||
| GET READY (10s) | Nom \+ GIF animé de démonstration | Grande — focus total | "Voici l'exercice suivant" — prépare mentalement |
|
||||
| WORK (20s) | Nom en haut \+ compteur central — GIF en petit coin | Nom moyen, timer dominant | 1-2 cues de forme affichés sous le nom |
|
||||
| REST (10s) | Nom de l'EXERCICE SUIVANT \+ vignette | Taille moyenne | "Prochain : \[Nom\]" — anticipation |
|
||||
| FIN DE ROUND | Résumé rapide du round (1s) | Plein écran flash | Round X complété \+ prochain exercice |
|
||||
|
||||
### **2.3.4 Types de Programmes Tabata**
|
||||
|
||||
| 🗂️ PROGRAMMES DISPONIBLES — V1 |
|
||||
| :---- |
|
||||
| ▸ MODE 1 — Exercice unique répété : Le même exercice sur les 8 rounds (ex: 8 rounds de Burpees) — Tabata classique |
|
||||
| ▸ MODE 2 — Circuit 2 exercices : Alternance A/B sur 8 rounds (ex: Squats / Push-ups × 4 répétitions) — Tabata duo |
|
||||
| ▸ MODE 3 — Circuit 4 exercices : 4 exercices × 2 rounds chacun — Tabata circuit (Premium) |
|
||||
| ▸ MODE 4 — Programme libre : L'utilisateur assigne manuellement un exercice à chaque round (Premium) |
|
||||
| ▸ MODE 5 — Programme IA : Sélection automatique selon niveau, objectif et historique (Premium V2) |
|
||||
|
||||
### **2.3.5 Créateur de Programme Personnalisé (Premium)**
|
||||
|
||||
* Interface drag & drop pour assigner les exercices à chaque round
|
||||
|
||||
* Sauvegarde illimitée de programmes personnalisés (nom, description, tags)
|
||||
|
||||
* Partage de programme par lien deep link (ex: tabatago://program/abc123)
|
||||
|
||||
* Import de programme depuis un lien partagé par un autre utilisateur
|
||||
|
||||
* Favoris : marquer des exercices pour les retrouver rapidement
|
||||
|
||||
### **2.3.6 Contenu Offline & Performance**
|
||||
|
||||
* Tous les GIFs d'exercices V1 (38 exercices × 1 GIF ≈ 150KB chacun ≈ \~6MB total) — embarqués dans le bundle
|
||||
|
||||
* Lazy loading pour les exercices V2+ : téléchargement à la demande, mis en cache localement
|
||||
|
||||
* Fallback : Si pas de GIF disponible, afficher une icône \+ description textuelle
|
||||
|
||||
* Accessibilité : alt text sur chaque GIF pour VoiceOver/TalkBack
|
||||
|
||||
## **2.4 Synchronisation Timer × Musique × Exercice**
|
||||
|
||||
Les trois composants doivent former une expérience unifiée et cohérente. Ce tableau décrit les événements et leurs effets croisés :
|
||||
|
||||
| Événement Timer | Effet sur la Musique | Effet sur l'Exercice | Haptique |
|
||||
| :---- | :---- | :---- | :---- |
|
||||
| Début GET READY | Fade-in track principale | Afficher GIF exercice Round 1 | Léger |
|
||||
| Décompte 3-2-1 | Volume augmente | Animation pulsation sur le GIF | Bip × 3 |
|
||||
| Début WORK | Switch vers track high BPM | Afficher nom \+ cues en grand | Fort |
|
||||
| Milieu WORK (10s) | Rien | "Halfway\!" en overlay 1s | Aucun |
|
||||
| Fin WORK | Switch vers track low BPM | Afficher exercice SUIVANT | Moyen |
|
||||
| Début REST | Track calme | "Next: \[Exercice\]" \+ vignette | Léger |
|
||||
| Dernier round warning | Effet sonore spécial | Badge "LAST ROUND" sur nom | Vibration longue |
|
||||
| Fin de séance | Fade-out \+ fanfare | Écran résultat avec tous les exercices | Célébration |
|
||||
|
||||
# **3\. Validation du Marché & Stratégie ASO**
|
||||
|
||||
## **3.1 Analyse des Mots-Clés Cibles**
|
||||
|
||||
Les mots-clés suivants ont été validés via Astro (difficulté \< 55, popularité \> 20\) :
|
||||
|
||||
| Mot-clé | Popularité | Difficulté | Marché |
|
||||
| :---- | :---- | :---- | :---- |
|
||||
| tabata timer | 72 | 48 | EN 🇺🇸 |
|
||||
| hiit timer app | 68 | 52 | EN 🇺🇸 |
|
||||
| minuterie tabata | 35 | 28 | FR 🇫🇷 |
|
||||
| temporizador tabata | 41 | 31 | ES 🇪🇸 |
|
||||
| tabata training timer | 55 | 44 | EN 🇬🇧 |
|
||||
| intervall timer workout | 38 | 29 | DE 🇩🇪 |
|
||||
|
||||
## **3.2 Analyse Concurrentielle**
|
||||
|
||||
Principaux concurrents identifiés et opportunités de différenciation :
|
||||
|
||||
| Concurrent | Forces | Faiblesses (notre opportunité) |
|
||||
| :---- | :---- | :---- |
|
||||
| Tabata Timer (App Store Top 1\) | Notoriété, simplicité | UI datée, pas de suivi streak, pas de localisation |
|
||||
| Seconds Pro | Très complet, flexible | Trop complexe, prix élevé, courbe d'apprentissage |
|
||||
| HIIT Interval Training Timer | Gratuit, fonctionnel | Pub intrusive, pas d'onboarding émotionnel |
|
||||
| Tabata Stopwatch Pro | Simple, rapide | Pas de personnalisation, no streak, no widget |
|
||||
|
||||
Notre avantage : onboarding émotionnel fort \+ design moderne \+ widgets iOS/Android \+ streaks \+ localisation dans 5 langues.
|
||||
|
||||
# **4\. Onboarding — La Séquence Critique**
|
||||
|
||||
L'onboarding est la priorité absolue : 80% des revenus sont générés ici. L'objectif est de créer un investissement émotionnel avant d'afficher le paywall. La séquence suit le schéma : Problème → Empathie → Solution → Moment Wow → Paywall.
|
||||
|
||||
## **4.1 Écrans d'Onboarding (séquence de 6 écrans)**
|
||||
|
||||
### **Écran 1 — Le Problème (Identifier la douleur)**
|
||||
|
||||
| 🎯 OBJECTIF : Identification |
|
||||
| :---- |
|
||||
| ▸ Titre : "Tu n'as pas 1 heure pour la salle. Personne n'en a." |
|
||||
| ▸ Sous-titre : "Et pourtant tu veux progresser. On a la solution." |
|
||||
| ▸ Visuel : Animation subtile d'une horloge qui se fragmente puis se reconstruit en 20 min |
|
||||
| ▸ CTA : "Montre-moi comment" (bouton orange pleine largeur) |
|
||||
|
||||
### **Écran 2 — L'Empathie (L'utilisateur se sent compris)**
|
||||
|
||||
| 💬 OBJECTIF : Connexion émotionnelle |
|
||||
| :---- |
|
||||
| ▸ Titre : "Qu'est-ce qui t'empêche de t'entraîner ?" |
|
||||
| ▸ Choix interactifs (tap) : Manque de temps / Motivation en berne / Je ne sais pas quoi faire / Je n'ai pas accès à une salle |
|
||||
| ▸ Mécanisme : stocker la réponse → personnaliser le reste de l'onboarding |
|
||||
| ▸ Transition : "On a conçu TabataGo exactement pour ça." |
|
||||
|
||||
### **Écran 3 — La Solution (Présenter la méthode)**
|
||||
|
||||
| ⚡ OBJECTIF : Comprendre la valeur |
|
||||
| :---- |
|
||||
| ▸ Titre : "4 minutes. Vraiment transformatrices." |
|
||||
| ▸ Animation interactive : timeline Tabata (20s work / 10s rest × 8 rounds) |
|
||||
| ▸ Stats affichées : Brûle autant de calories qu'un jogging de 30 min / Prouvé scientifiquement depuis 1996 (Dr. Tabata) |
|
||||
| ▸ Visuel : compteur animé qui tourne — preview de l'app |
|
||||
|
||||
### **Écran 4 — Le Moment "Wow" (Démo interactive)**
|
||||
|
||||
| 🔥 OBJECTIF : Engagement actif (ne pas juste regarder) |
|
||||
| :---- |
|
||||
| ▸ Titre : "Essaie maintenant. 20 secondes." |
|
||||
| ▸ Mini-minuterie Tabata LIVE intégrée dans l'écran d'onboarding |
|
||||
| ▸ L'utilisateur tape sur "Go" et vit 20s de compte à rebours \+ son \+ vibration |
|
||||
| ▸ Après : "Tu viens de faire ta première série Tabata. 7 de plus et c'est une séance complète." |
|
||||
| ▸ Note : Ce moment est le plus différenciant — créer une mini-expérience réelle |
|
||||
|
||||
### **Écran 5 — Personnalisation (Engagement supplémentaire)**
|
||||
|
||||
| ⚙️ OBJECTIF : Investissement personnel |
|
||||
| :---- |
|
||||
| ▸ Titre : "Configurons ta première semaine." |
|
||||
| ▸ Sélection : Niveau (Débutant / Intermédiaire / Avancé) |
|
||||
| ▸ Sélection : Objectif (Perte de poids / Cardio / Force / Bien-être) |
|
||||
| ▸ Sélection : Fréquence souhaitée (2x / 3x / 5x par semaine) |
|
||||
| ▸ Résultat : "Ton programme personnalisé est prêt." (sensation de valeur avant paiement) |
|
||||
|
||||
### **Écran 6 — Paywall (Après l'investissement émotionnel)**
|
||||
|
||||
| 💳 OBJECTIF : Conversion |
|
||||
| :---- |
|
||||
| ▸ Titre : "Continue sur ta lancée. Sans limite." |
|
||||
| ▸ Présenter l'essai gratuit 7 jours en premier (bouton principal orange) |
|
||||
| ▸ Options : Mensuel $4.99 / Annuel $29.99 (économie 50% mise en évidence) |
|
||||
| ▸ Garantie visible : "Annule à tout moment" \+ "Satisfait ou remboursé 30j" |
|
||||
| ▸ Lien "Continuer sans abonnement" en petit en bas (ne pas le cacher) |
|
||||
| ▸ Intégration RevenueCat — avec A/B test activé dès le lancement |
|
||||
|
||||
# **5\. Design & Identité Visuelle**
|
||||
|
||||
## **5.1 Palette de Couleurs**
|
||||
|
||||
| Rôle | Couleur | Hex | Usage |
|
||||
| :---- | :---- | :---- | :---- |
|
||||
| Primaire / Action | Orange Tabata | \#F97316 | Boutons CTA, accents, timer actif |
|
||||
| Fond Dark (défaut) | Charcoal Night | \#1E1E2E | Background principal mode sombre |
|
||||
| Fond Light | Warm White | \#FFF7ED | Background mode clair |
|
||||
| Texte principal Dark | Stone 900 | \#1C1917 | Titres mode clair |
|
||||
| Texte secondaire | Stone 600 | \#57534E | Body text, descriptions |
|
||||
| Succès / Streak | Green 500 | \#22C55E | Streaks, complétion, feedback positif |
|
||||
| Danger / Alerte | Red 500 | \#EF4444 | Erreurs, derniers secondes |
|
||||
|
||||
## **5.2 Typographie**
|
||||
|
||||
| Usage | Police | Poids | Taille |
|
||||
| :---- | :---- | :---- | :---- |
|
||||
| Timer principal | Inter (monospace fallback) | Black (900) | 96-120px |
|
||||
| Titres H1 | Inter | Bold (700) | 28-32px |
|
||||
| Titres H2 | Inter | SemiBold (600) | 22-24px |
|
||||
| Body / Labels | Inter | Regular (400) | 14-16px |
|
||||
| Micro-labels | Inter | Medium (500) | 11-12px |
|
||||
|
||||
## **5.3 Principes Design**
|
||||
|
||||
* Mode sombre par défaut (immersif pendant l'entraînement)
|
||||
|
||||
* Timer en plein écran pendant la séance — aucune distraction
|
||||
|
||||
* Animations fluides (60fps) pour les transitions et le compteur
|
||||
|
||||
* Haptiques natifs : vibration légère à chaque changement de phase
|
||||
|
||||
* Support Dynamic Type (iOS) et font scaling (Android)
|
||||
|
||||
* Icône app : fond noir, lettre T stylisée en orange avec une flamme
|
||||
|
||||
# **6\. Architecture Technique & Développement (Expo)**
|
||||
|
||||
## **6.1 Stack Technique**
|
||||
|
||||
| Composant | Solution choisie | Justification |
|
||||
| :---- | :---- | :---- |
|
||||
| Framework | Expo SDK 52 (React Native) | Cross-platform, OTA updates, accès natif facile |
|
||||
| Navigation | Expo Router v3 (file-based) | Standard moderne, deep linking natif |
|
||||
| State management | Zustand \+ AsyncStorage | Léger, performant, persistance simple |
|
||||
| Timer engine | expo-background-fetch \+ useInterval custom | Précision \+ exécution background |
|
||||
| Audio | expo-av | Sons de décompte et alertes phase |
|
||||
| Haptiques | expo-haptics | Retour tactile natif iOS/Android |
|
||||
| Notifications | expo-notifications | Rappels d'entraînement \+ streaks |
|
||||
| Widget | react-native-widget-extension | Widget iOS 14+ (Live Activity) |
|
||||
| Paiements | react-native-purchases (RevenueCat) | Abonnements \+ A/B testing \+ analytics |
|
||||
| Analytics | PostHog (react-native-posthog) | Funnel tracking, drop-off analysis |
|
||||
| Storage | expo-secure-store \+ AsyncStorage | Données utilisateur \+ préférences |
|
||||
| In-App Review | expo-store-review | Prompt après streak 7 jours |
|
||||
|
||||
## **6.2 Features — MoSCoW Priorisation**
|
||||
|
||||
### **Must Have (V1 — Lancement)**
|
||||
|
||||
| ✅ MUST HAVE — Indispensables au lancement |
|
||||
| :---- |
|
||||
| ▸ Timer Tabata complet — voir Section 2.1 (toutes phases, sons, haptiques, background) |
|
||||
| ▸ Musique intégrée — voir Section 2.2 (3 ambiances × 3 intensités, signaux de phase, coexistence audio) |
|
||||
| ▸ Exercices avec GIFs — voir Section 2.3 (38 exercices, modes 1 et 2, affichage GET READY/WORK/REST) |
|
||||
| ▸ Synchronisation Timer × Musique × Exercice — voir Section 2.4 |
|
||||
| ▸ Modes : Tabata classique, HIIT personnalisé, Pause active |
|
||||
| ▸ Affichage plein écran pendant la séance (mode portrait \+ paysage) |
|
||||
| ▸ Historique des séances (date, durée, rounds complétés) |
|
||||
| ▸ Système de Streak (consécutivité quotidienne, animation de feu) |
|
||||
| ▸ Notifications de rappel configurables |
|
||||
| ▸ Onboarding 6 écrans avec mini-démo live |
|
||||
| ▸ Paywall \+ RevenueCat (essai 7j, mensuel, annuel) |
|
||||
| ▸ Dark mode \+ Light mode |
|
||||
| ▸ Localisation : EN, FR, ES, DE, PT |
|
||||
|
||||
### **Should Have (V1.1 — Semaine 4-8)**
|
||||
|
||||
| 🟡 SHOULD HAVE — Valeur ajoutée forte |
|
||||
| :---- |
|
||||
| ▸ Widget iOS (Home Screen) — affiche le streak et le dernier entraînement |
|
||||
| ▸ Widget Android (Glance API) |
|
||||
| ▸ Bibliothèque de programmes pré-définis (Débutant / Cardio / Force) |
|
||||
| ▸ Statistiques hebdomadaires et mensuelles avec graphiques |
|
||||
| ▸ Sons personnalisés (voix, bip, musique d'ambiance) |
|
||||
| ▸ Integration Apple Health / Google Fit (calories, activité) |
|
||||
| ▸ Partage social (carte récapitulative de la séance) |
|
||||
|
||||
### **Could Have (V2 — Mois 3+)**
|
||||
|
||||
| 🔵 COULD HAVE — Différenciation long terme |
|
||||
| :---- |
|
||||
| ▸ Mode Coach IA : suggestions de séances basées sur l'historique |
|
||||
| ▸ Challenges communautaires (leaderboard hebdomadaire) |
|
||||
| ▸ Apple Watch companion app |
|
||||
| ▸ Import/Export de programmes (partage entre utilisateurs) |
|
||||
| ▸ Mode TV / AirPlay pour entraînement sur grand écran |
|
||||
|
||||
# **7\. Structure de Navigation (Expo Router)**
|
||||
|
||||
Architecture file-based avec Expo Router v3 :
|
||||
|
||||
| Route | Écran | Accès |
|
||||
| :---- | :---- | :---- |
|
||||
| / | Home — Hub central avec raccourcis et streak | Tous |
|
||||
| /onboarding | Séquence onboarding 6 étapes | Nouveaux utilisateurs |
|
||||
| /timer | Minuterie plein écran — séance active | Tous |
|
||||
| /programs | Bibliothèque de programmes | Premium |
|
||||
| /history | Historique des séances \+ stats | Tous (limité free) |
|
||||
| /settings | Préférences, sons, notifications, compte | Tous |
|
||||
| /paywall | Écran d'abonnement RevenueCat | Free users |
|
||||
| /(modals)/review | In-App Review prompt | Streak 7j |
|
||||
|
||||
# **8\. Paiements, Pricing & RevenueCat**
|
||||
|
||||
## **8.1 Structure des Offres**
|
||||
|
||||
| Offre | Prix US | Prix FR | Prix BR | Contenu |
|
||||
| :---- | :---- | :---- | :---- | :---- |
|
||||
| Gratuit | Free | Free | Free | Timer standard, 7j historique, pas de widget |
|
||||
| Essai Premium | 7 jours gratuits | 7 jours gratuits | 7 jours gratuits | Accès complet, pas de CB requise si possible |
|
||||
| Premium Mensuel | $4.99/mois | €4.99/mois | R$9.99/mois | Accès complet illimité |
|
||||
| Premium Annuel | $29.99/an | €27.99/an | R$59.99/an | Tout Premium — économie 50% mise en avant |
|
||||
|
||||
Note : Pricing localisé via RevenueCat Purchasing Power Parity (PPP). Adapter automatiquement les prix pour le Brésil, l'Inde, l'Indonésie et l'Europe de l'Est pour maximiser les conversions mondiales.
|
||||
|
||||
## **8.2 Configuration RevenueCat**
|
||||
|
||||
* Entitlements : "premium" (accès à toutes les features payantes)
|
||||
|
||||
* Products : tabatago\_monthly, tabatago\_annual, tabatago\_trial\_7d
|
||||
|
||||
* A/B Tests dès J+7 : tester $3.99 vs $4.99 vs $6.99 mensuel
|
||||
|
||||
* A/B Tests paywall : liste vs comparaison de plans vs "best value" badge
|
||||
|
||||
* Webhook RevenueCat → PostHog pour corréler revenus et comportement
|
||||
|
||||
# **9\. Analytics & Instrumentation (PostHog)**
|
||||
|
||||
## **9.1 Événements Critiques à Tracker**
|
||||
|
||||
| Événement | Propriétés | Objectif |
|
||||
| :---- | :---- | :---- |
|
||||
| onboarding\_step\_viewed | step\_number, step\_name | Identifier le drop-off |
|
||||
| onboarding\_demo\_started | duration\_seconds | Mesurer l'engagement Wow |
|
||||
| onboarding\_completed | persona, goal, frequency | Segmentation |
|
||||
| paywall\_viewed | source, variant | Funnel paiement |
|
||||
| trial\_started | plan\_selected | Conversion essai |
|
||||
| subscription\_purchased | plan, price, currency | Revenue |
|
||||
| session\_started | program\_id, rounds, duration | Engagement produit |
|
||||
| session\_completed | rounds\_done, streak\_day | Rétention |
|
||||
| streak\_milestone | days\_count | Gamification KPIs |
|
||||
| widget\_added | widget\_type | Stickiness |
|
||||
| notification\_tapped | notification\_type | Rappels efficacité |
|
||||
|
||||
## **9.2 Funnels à Monitorer**
|
||||
|
||||
* Funnel Acquisition : Impression App Store → Téléchargement → Onboarding Start → Onboarding Complete → Paywall View → Trial Start → Purchase
|
||||
|
||||
* Funnel Rétention : J+1 / J+7 / J+30 ouverture de l'app après téléchargement
|
||||
|
||||
* Funnel Engagement : Séance démarrée → Séance complétée → Streak maintenu
|
||||
|
||||
Règle d'or : Si impressions mais pas de téléchargements → Réparer screenshots/icône. Si téléchargements mais pas d'essais → Réparer onboarding. Si essais mais pas de paiements → Réparer paywall/pricing.
|
||||
|
||||
# **10\. Localisation & Growth Hacking**
|
||||
|
||||
## **10.1 Stratégie de Localisation (5 Langues)**
|
||||
|
||||
| Langue | Marché cible | Mots-clés spécifiques | Priorité |
|
||||
| :---- | :---- | :---- | :---- |
|
||||
| Anglais (EN) | US, UK, AU, CA | tabata timer, hiit timer app | 1 — Lancement |
|
||||
| Français (FR) | France, Belgique, Québec | minuterie tabata, chrono hiit | 1 — Lancement |
|
||||
| Espagnol (ES) | Espagne, Mexique, Argentine | temporizador tabata, ejercicio hiit | 2 — Semaine 2 |
|
||||
| Allemand (DE) | Allemagne, Autriche, CH | tabata timer app, intervall training | 2 — Semaine 2 |
|
||||
| Portugais (PT-BR) | Brésil | timer tabata, treino hiit | 3 — Mois 2 |
|
||||
|
||||
## **10.2 Éléments à Localiser**
|
||||
|
||||
* App content : tous les textes UI, onboarding, notifications, erreurs
|
||||
|
||||
* App Store listing : titre, sous-titre, description courte, description longue
|
||||
|
||||
* Screenshots : localisés avec texte natif (utiliser Fastlane Frameit)
|
||||
|
||||
* Prix : PPP via RevenueCat (automatique par pays)
|
||||
|
||||
* Mots-clés App Store : 100 caractères spécifiques par marché
|
||||
|
||||
Outil recommandé : i18next \+ expo-localization pour la gestion des traductions en développement.
|
||||
|
||||
# **11\. App Store Optimization (ASO)**
|
||||
|
||||
## **11.1 Fiche App Store (iOS — EN)**
|
||||
|
||||
| Champ | Valeur optimisée |
|
||||
| :---- | :---- |
|
||||
| Nom de l'app (30 car.) | TabataGo — HIIT Timer |
|
||||
| Sous-titre (30 car.) | Tabata & Interval Workout |
|
||||
| Mots-clés (100 car.) | tabata,hiit,timer,interval,workout,fitness,training,countdown,sport,exercise |
|
||||
| Description (1ère ligne) | The most motivating Tabata timer. 20 seconds of effort. Life-changing results. |
|
||||
| Screenshots (10 max) | Voir section 10.2 |
|
||||
| Preview video | 15-30s montrant la minuterie en action \+ streak \+ widget |
|
||||
|
||||
## **11.2 Plan des Screenshots (6 obligatoires)**
|
||||
|
||||
| \# | Contenu | Message clé | Background |
|
||||
| :---- | :---- | :---- | :---- |
|
||||
| 1 | Timer plein écran en action (20s) | "Train smarter, not longer" | Dark \+ orange |
|
||||
| 2 | Écran d'onboarding moment Wow | "Your first Tabata in 20 seconds" | Gradient sombre |
|
||||
| 3 | Widget iOS home screen | "Train from anywhere — even your lock screen" | iPhone mockup |
|
||||
| 4 | Historique \+ Streak en feu | "Build the habit. Keep the streak." | Dark \+ vert |
|
||||
| 5 | Bibliothèque de programmes | "100+ ready-to-go programs" | Gradient |
|
||||
| 6 | Statistiques mensuelles | "See your progress. Stay motivated." | Fond clair |
|
||||
|
||||
# **12\. Plan de Lancement en 72h**
|
||||
|
||||
## **12.1 Timeline de Développement (72 heures)**
|
||||
|
||||
| Phase | Durée | Livrables | Responsable |
|
||||
| :---- | :---- | :---- | :---- |
|
||||
| Setup & Architecture | H0 → H8 | Expo project init, navigation, design system, RevenueCat config | Dev Lead |
|
||||
| Onboarding | H8 → H20 | 6 écrans onboarding \+ mini-timer démo \+ animation | Dev \+ Design |
|
||||
| Core Timer | H20 → H36 | Minuterie plein écran, son, haptiques, background timer | Dev |
|
||||
| Streak \+ Historique | H36 → H48 | Système streak, storage, affichage historique, notifications | Dev |
|
||||
| Paywall \+ Analytics | H48 → H58 | RevenueCat paywall, PostHog events, A/B test config | Dev |
|
||||
| Polish & Testing | H58 → H68 | Bug fixes, dark/light mode, performance, edge cases | Dev \+ QA |
|
||||
| Submission | H68 → H72 | Screenshots, App Store listing (5 langues), soumission Apple | PM \+ Dev |
|
||||
|
||||
## **12.2 Stratégie de Validation Post-Lancement**
|
||||
|
||||
Profiter du boost de visibilité des 3 premiers jours accordé par l'App Store aux nouvelles applications :
|
||||
|
||||
1. Jour 1-3 : Monitoring organique — Analyser impressions, téléchargements, et premiers essais
|
||||
|
||||
2. Jour 4-7 : Analyse funnel — Identifier le premier point de friction (onboarding? paywall?)
|
||||
|
||||
3. Jour 8-14 : Première itération — Corriger le problème prioritaire et soumettre mise à jour OTA (Expo)
|
||||
|
||||
4. Jour 15-30 : Activation publicité — Apple Search Ads seulement si CVR organique \> 15%
|
||||
|
||||
5. Jour 30+ : Scale — Augmenter budget pub sur les marchés qui convertissent le mieux
|
||||
|
||||
Règle absolue : Ne jamais dépenser en publicité avant d'avoir validé que le funnel organique convertit.
|
||||
|
||||
# **13\. KPIs & Métriques de Succès**
|
||||
|
||||
| Métrique | Semaine 1 | Mois 1 | Mois 3 | Outil de mesure |
|
||||
| :---- | :---- | :---- | :---- | :---- |
|
||||
| Téléchargements | 500+ | 5 000+ | 20 000+ | App Store Connect |
|
||||
| Taux conversion Impression→DL | \> 5% | \> 8% | \> 10% | App Store Connect |
|
||||
| Taux essai démarré | \> 25% | \> 30% | \> 35% | RevenueCat \+ PostHog |
|
||||
| Taux conversion essai→payant | \> 30% | \> 35% | \> 40% | RevenueCat |
|
||||
| Rétention J+7 | \> 30% | \> 35% | \> 40% | PostHog |
|
||||
| Rétention J+30 | — | \> 20% | \> 25% | PostHog |
|
||||
| MRR | — | $2 000+ | $8 000+ | RevenueCat |
|
||||
| Note App Store | — | ≥ 4.5 | ≥ 4.6 | App Store Connect |
|
||||
| Streak moyen (actifs) | — | \> 5 jours | \> 10 jours | PostHog custom |
|
||||
|
||||
# **14\. Risques & Mitigations**
|
||||
|
||||
| Risque | Probabilité | Impact | Mitigation |
|
||||
| :---- | :---- | :---- | :---- |
|
||||
| Refus App Store (guidelines) | Moyen | Élevé | Respecter HIG Apple, tester paywall sur TestFlight, pas de dark patterns |
|
||||
| Mauvais taux de conversion paywall | Moyen | Élevé | A/B test RevenueCat dès J+7, 3 variantes de prix/layout |
|
||||
| Précision du timer en background | Faible | Élevé | expo-background-fetch \+ notification locale comme fallback |
|
||||
| Faible rétention sans streaks | Moyen | Moyen | Streak J+1 core feature, reminder push personnalisé par heure d'entraînement |
|
||||
| Concurrence sur mots-clés EN | Élevé | Moyen | Agressivité sur marchés FR/ES/DE dès J+14 |
|
||||
| Revue Apple lente (\> 48h) | Faible | Faible | Soumettre J-5 avant objectif lancement, utiliser Expo OTA pour hotfixes |
|
||||
|
||||
# **15\. Approbations & Versions**
|
||||
|
||||
| Rôle | Nom | Date | Signature |
|
||||
| :---- | :---- | :---- | :---- |
|
||||
| Product Owner | | Février 2026 | |
|
||||
| Tech Lead | | Février 2026 | |
|
||||
| Design Lead | | Février 2026 | |
|
||||
| Marketing | | Février 2026 | |
|
||||
|
||||
*Document créé le 15 février 2026 — TabataGo PRD v1.1*
|
||||
@@ -1,112 +0,0 @@
|
||||
import { Image } from 'expo-image';
|
||||
import { Platform, StyleSheet } from 'react-native';
|
||||
|
||||
import { Collapsible } from '@/components/ui/collapsible';
|
||||
import { ExternalLink } from '@/components/external-link';
|
||||
import ParallaxScrollView from '@/components/parallax-scroll-view';
|
||||
import { ThemedText } from '@/components/themed-text';
|
||||
import { ThemedView } from '@/components/themed-view';
|
||||
import { IconSymbol } from '@/components/ui/icon-symbol';
|
||||
import { Fonts } from '@/constants/theme';
|
||||
|
||||
export default function TabTwoScreen() {
|
||||
return (
|
||||
<ParallaxScrollView
|
||||
headerBackgroundColor={{ light: '#D0D0D0', dark: '#353636' }}
|
||||
headerImage={
|
||||
<IconSymbol
|
||||
size={310}
|
||||
color="#808080"
|
||||
name="chevron.left.forwardslash.chevron.right"
|
||||
style={styles.headerImage}
|
||||
/>
|
||||
}>
|
||||
<ThemedView style={styles.titleContainer}>
|
||||
<ThemedText
|
||||
type="title"
|
||||
style={{
|
||||
fontFamily: Fonts.rounded,
|
||||
}}>
|
||||
Explore
|
||||
</ThemedText>
|
||||
</ThemedView>
|
||||
<ThemedText>This app includes example code to help you get started.</ThemedText>
|
||||
<Collapsible title="File-based routing">
|
||||
<ThemedText>
|
||||
This app has two screens:{' '}
|
||||
<ThemedText type="defaultSemiBold">app/(tabs)/index.tsx</ThemedText> and{' '}
|
||||
<ThemedText type="defaultSemiBold">app/(tabs)/explore.tsx</ThemedText>
|
||||
</ThemedText>
|
||||
<ThemedText>
|
||||
The layout file in <ThemedText type="defaultSemiBold">app/(tabs)/_layout.tsx</ThemedText>{' '}
|
||||
sets up the tab navigator.
|
||||
</ThemedText>
|
||||
<ExternalLink href="https://docs.expo.dev/router/introduction">
|
||||
<ThemedText type="link">Learn more</ThemedText>
|
||||
</ExternalLink>
|
||||
</Collapsible>
|
||||
<Collapsible title="Android, iOS, and web support">
|
||||
<ThemedText>
|
||||
You can open this project on Android, iOS, and the web. To open the web version, press{' '}
|
||||
<ThemedText type="defaultSemiBold">w</ThemedText> in the terminal running this project.
|
||||
</ThemedText>
|
||||
</Collapsible>
|
||||
<Collapsible title="Images">
|
||||
<ThemedText>
|
||||
For static images, you can use the <ThemedText type="defaultSemiBold">@2x</ThemedText> and{' '}
|
||||
<ThemedText type="defaultSemiBold">@3x</ThemedText> suffixes to provide files for
|
||||
different screen densities
|
||||
</ThemedText>
|
||||
<Image
|
||||
source={require('@/assets/images/react-logo.png')}
|
||||
style={{ width: 100, height: 100, alignSelf: 'center' }}
|
||||
/>
|
||||
<ExternalLink href="https://reactnative.dev/docs/images">
|
||||
<ThemedText type="link">Learn more</ThemedText>
|
||||
</ExternalLink>
|
||||
</Collapsible>
|
||||
<Collapsible title="Light and dark mode components">
|
||||
<ThemedText>
|
||||
This template has light and dark mode support. The{' '}
|
||||
<ThemedText type="defaultSemiBold">useColorScheme()</ThemedText> hook lets you inspect
|
||||
what the user's current color scheme is, and so you can adjust UI colors accordingly.
|
||||
</ThemedText>
|
||||
<ExternalLink href="https://docs.expo.dev/develop/user-interface/color-themes/">
|
||||
<ThemedText type="link">Learn more</ThemedText>
|
||||
</ExternalLink>
|
||||
</Collapsible>
|
||||
<Collapsible title="Animations">
|
||||
<ThemedText>
|
||||
This template includes an example of an animated component. The{' '}
|
||||
<ThemedText type="defaultSemiBold">components/HelloWave.tsx</ThemedText> component uses
|
||||
the powerful{' '}
|
||||
<ThemedText type="defaultSemiBold" style={{ fontFamily: Fonts.mono }}>
|
||||
react-native-reanimated
|
||||
</ThemedText>{' '}
|
||||
library to create a waving hand animation.
|
||||
</ThemedText>
|
||||
{Platform.select({
|
||||
ios: (
|
||||
<ThemedText>
|
||||
The <ThemedText type="defaultSemiBold">components/ParallaxScrollView.tsx</ThemedText>{' '}
|
||||
component provides a parallax effect for the header image.
|
||||
</ThemedText>
|
||||
),
|
||||
})}
|
||||
</Collapsible>
|
||||
</ParallaxScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
headerImage: {
|
||||
color: '#808080',
|
||||
bottom: -90,
|
||||
left: -35,
|
||||
position: 'absolute',
|
||||
},
|
||||
titleContainer: {
|
||||
flexDirection: 'row',
|
||||
gap: 8,
|
||||
},
|
||||
});
|
||||
@@ -1,29 +0,0 @@
|
||||
import { Link } from 'expo-router';
|
||||
import { StyleSheet } from 'react-native';
|
||||
|
||||
import { ThemedText } from '@/components/themed-text';
|
||||
import { ThemedView } from '@/components/themed-view';
|
||||
|
||||
export default function ModalScreen() {
|
||||
return (
|
||||
<ThemedView style={styles.container}>
|
||||
<ThemedText type="title">This is a modal</ThemedText>
|
||||
<Link href="/" dismissTo style={styles.link}>
|
||||
<ThemedText type="link">Go to home screen</ThemedText>
|
||||
</Link>
|
||||
</ThemedView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 20,
|
||||
},
|
||||
link: {
|
||||
marginTop: 15,
|
||||
paddingVertical: 15,
|
||||
},
|
||||
});
|
||||
@@ -1,49 +0,0 @@
|
||||
import { useRouter, Redirect } from 'expo-router'
|
||||
import { Screen1Problem } from '@/src/features/onboarding/screens/Screen1Problem'
|
||||
import { Screen2Empathy } from '@/src/features/onboarding/screens/Screen2Empathy'
|
||||
import { Screen3Solution } from '@/src/features/onboarding/screens/Screen3Solution'
|
||||
import { Screen4WowMoment } from '@/src/features/onboarding/screens/Screen4WowMoment'
|
||||
import { Screen5Personalization } from '@/src/features/onboarding/screens/Screen5Personalization'
|
||||
import { Screen6Paywall } from '@/src/features/onboarding/screens/Screen6Paywall'
|
||||
import { useOnboarding } from '@/src/features/onboarding/hooks/useOnboarding'
|
||||
|
||||
export default function OnboardingRouter() {
|
||||
const router = useRouter()
|
||||
const currentStep = useOnboarding((state) => state.currentStep)
|
||||
const isOnboardingComplete = useOnboarding((state) => state.isOnboardingComplete)
|
||||
const nextStep = useOnboarding((state) => state.nextStep)
|
||||
const completeOnboarding = useOnboarding((state) => state.completeOnboarding)
|
||||
|
||||
const handleNext = () => {
|
||||
nextStep()
|
||||
}
|
||||
|
||||
const handleComplete = () => {
|
||||
completeOnboarding()
|
||||
router.replace('/(tabs)')
|
||||
}
|
||||
|
||||
// Redirect to tabs if onboarding is already complete
|
||||
if (isOnboardingComplete) {
|
||||
return <Redirect href="/(tabs)" />
|
||||
}
|
||||
|
||||
// Render the correct screen based on current step
|
||||
switch (currentStep) {
|
||||
case 0:
|
||||
return <Screen1Problem onNext={handleNext} />
|
||||
case 1:
|
||||
return <Screen2Empathy onNext={handleNext} />
|
||||
case 2:
|
||||
return <Screen3Solution onNext={handleNext} />
|
||||
case 3:
|
||||
return <Screen4WowMoment />
|
||||
case 4:
|
||||
return <Screen5Personalization onNext={handleNext} />
|
||||
case 5:
|
||||
return <Screen6Paywall onComplete={handleComplete} />
|
||||
default:
|
||||
// Fallback to first screen if step is out of bounds
|
||||
return <Screen1Problem onNext={handleNext} />
|
||||
}
|
||||
}
|
||||
105
app/timer.tsx
105
app/timer.tsx
@@ -1,105 +0,0 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useRouter } from 'expo-router'
|
||||
import * as Haptics from 'expo-haptics'
|
||||
import { useTimerEngine } from '@/src/features/timer'
|
||||
import { useAudioEngine } from '@/src/features/audio'
|
||||
import { TimerDisplay } from '@/src/features/timer/components/TimerDisplay'
|
||||
import type { TimerEvent } from '@/src/features/timer/types'
|
||||
|
||||
export default function TimerScreen() {
|
||||
const router = useRouter()
|
||||
const timer = useTimerEngine()
|
||||
const audio = useAudioEngine()
|
||||
|
||||
// Preload audio on mount
|
||||
useEffect(() => {
|
||||
audio.preloadAll()
|
||||
return () => {
|
||||
audio.unloadAll()
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Subscribe to timer events → trigger audio + haptics
|
||||
useEffect(() => {
|
||||
const unsubscribe = timer.addEventListener(async (event: TimerEvent) => {
|
||||
switch (event.type) {
|
||||
case 'PHASE_CHANGED':
|
||||
await handlePhaseChange(event.to)
|
||||
break
|
||||
|
||||
case 'COUNTDOWN_TICK':
|
||||
await audio.playPhaseSound(
|
||||
event.secondsLeft === 1 ? 'count_1' : event.secondsLeft === 2 ? 'count_2' : 'count_3'
|
||||
)
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light)
|
||||
break
|
||||
|
||||
case 'ROUND_COMPLETED':
|
||||
await audio.playPhaseSound('bell')
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success)
|
||||
break
|
||||
|
||||
case 'SESSION_COMPLETE':
|
||||
await audio.playPhaseSound('fanfare')
|
||||
await audio.stopMusic(1000)
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success)
|
||||
break
|
||||
}
|
||||
})
|
||||
|
||||
return unsubscribe
|
||||
}, [audio.isLoaded])
|
||||
|
||||
async function handlePhaseChange(to: string) {
|
||||
switch (to) {
|
||||
case 'GET_READY':
|
||||
await audio.startMusic('LOW')
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium)
|
||||
break
|
||||
case 'WORK':
|
||||
await audio.playPhaseSound('beep_long')
|
||||
await audio.switchIntensity('HIGH')
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy)
|
||||
break
|
||||
case 'REST':
|
||||
await audio.playPhaseSound('beep_double')
|
||||
await audio.switchIntensity('LOW')
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
function handleStart() {
|
||||
timer.start()
|
||||
}
|
||||
|
||||
function handleStop() {
|
||||
timer.stop()
|
||||
audio.stopMusic(300)
|
||||
router.back()
|
||||
}
|
||||
|
||||
return (
|
||||
<TimerDisplay
|
||||
state={{
|
||||
phase: timer.phase,
|
||||
secondsLeft: timer.secondsLeft,
|
||||
currentRound: timer.currentRound,
|
||||
totalRounds: timer.totalRounds,
|
||||
currentCycle: timer.currentCycle,
|
||||
totalCycles: timer.totalCycles,
|
||||
isRunning: timer.isRunning,
|
||||
isPaused: timer.isPaused,
|
||||
totalElapsedSeconds: timer.totalElapsedSeconds,
|
||||
}}
|
||||
config={timer.config}
|
||||
exerciseName="Burpees"
|
||||
nextExerciseName="Squats"
|
||||
onStart={handleStart}
|
||||
onPause={timer.pause}
|
||||
onResume={timer.resume}
|
||||
onStop={handleStop}
|
||||
onSkip={timer.skip}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import { Href, Link } from 'expo-router';
|
||||
import { openBrowserAsync, WebBrowserPresentationStyle } from 'expo-web-browser';
|
||||
import { type ComponentProps } from 'react';
|
||||
|
||||
type Props = Omit<ComponentProps<typeof Link>, 'href'> & { href: Href & string };
|
||||
|
||||
export function ExternalLink({ href, ...rest }: Props) {
|
||||
return (
|
||||
<Link
|
||||
target="_blank"
|
||||
{...rest}
|
||||
href={href}
|
||||
onPress={async (event) => {
|
||||
if (process.env.EXPO_OS !== 'web') {
|
||||
// Prevent the default behavior of linking to the default browser on native.
|
||||
event.preventDefault();
|
||||
// Open the link in an in-app browser.
|
||||
await openBrowserAsync(href, {
|
||||
presentationStyle: WebBrowserPresentationStyle.AUTOMATIC,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import { BottomTabBarButtonProps } from '@react-navigation/bottom-tabs';
|
||||
import { PlatformPressable } from '@react-navigation/elements';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
|
||||
export function HapticTab(props: BottomTabBarButtonProps) {
|
||||
return (
|
||||
<PlatformPressable
|
||||
{...props}
|
||||
onPressIn={(ev) => {
|
||||
if (process.env.EXPO_OS === 'ios') {
|
||||
// Add a soft haptic feedback when pressing down on the tabs.
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
}
|
||||
props.onPressIn?.(ev);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import Animated from 'react-native-reanimated';
|
||||
|
||||
export function HelloWave() {
|
||||
return (
|
||||
<Animated.Text
|
||||
style={{
|
||||
fontSize: 28,
|
||||
lineHeight: 32,
|
||||
marginTop: -6,
|
||||
animationName: {
|
||||
'50%': { transform: [{ rotate: '25deg' }] },
|
||||
},
|
||||
animationIterationCount: 4,
|
||||
animationDuration: '300ms',
|
||||
}}>
|
||||
👋
|
||||
</Animated.Text>
|
||||
);
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
import type { PropsWithChildren, ReactElement } from 'react';
|
||||
import { StyleSheet } from 'react-native';
|
||||
import Animated, {
|
||||
interpolate,
|
||||
useAnimatedRef,
|
||||
useAnimatedStyle,
|
||||
useScrollOffset,
|
||||
} from 'react-native-reanimated';
|
||||
|
||||
import { ThemedView } from '@/components/themed-view';
|
||||
import { useColorScheme } from '@/hooks/use-color-scheme';
|
||||
import { useThemeColor } from '@/hooks/use-theme-color';
|
||||
|
||||
const HEADER_HEIGHT = 250;
|
||||
|
||||
type Props = PropsWithChildren<{
|
||||
headerImage: ReactElement;
|
||||
headerBackgroundColor: { dark: string; light: string };
|
||||
}>;
|
||||
|
||||
export default function ParallaxScrollView({
|
||||
children,
|
||||
headerImage,
|
||||
headerBackgroundColor,
|
||||
}: Props) {
|
||||
const backgroundColor = useThemeColor({}, 'background');
|
||||
const colorScheme = useColorScheme() ?? 'light';
|
||||
const scrollRef = useAnimatedRef<Animated.ScrollView>();
|
||||
const scrollOffset = useScrollOffset(scrollRef);
|
||||
const headerAnimatedStyle = useAnimatedStyle(() => {
|
||||
return {
|
||||
transform: [
|
||||
{
|
||||
translateY: interpolate(
|
||||
scrollOffset.value,
|
||||
[-HEADER_HEIGHT, 0, HEADER_HEIGHT],
|
||||
[-HEADER_HEIGHT / 2, 0, HEADER_HEIGHT * 0.75]
|
||||
),
|
||||
},
|
||||
{
|
||||
scale: interpolate(scrollOffset.value, [-HEADER_HEIGHT, 0, HEADER_HEIGHT], [2, 1, 1]),
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<Animated.ScrollView
|
||||
ref={scrollRef}
|
||||
style={{ backgroundColor, flex: 1 }}
|
||||
scrollEventThrottle={16}>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.header,
|
||||
{ backgroundColor: headerBackgroundColor[colorScheme] },
|
||||
headerAnimatedStyle,
|
||||
]}>
|
||||
{headerImage}
|
||||
</Animated.View>
|
||||
<ThemedView style={styles.content}>{children}</ThemedView>
|
||||
</Animated.ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
header: {
|
||||
height: HEADER_HEIGHT,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
padding: 32,
|
||||
gap: 16,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
});
|
||||
@@ -1,60 +0,0 @@
|
||||
import { StyleSheet, Text, type TextProps } from 'react-native';
|
||||
|
||||
import { useThemeColor } from '@/hooks/use-theme-color';
|
||||
|
||||
export type ThemedTextProps = TextProps & {
|
||||
lightColor?: string;
|
||||
darkColor?: string;
|
||||
type?: 'default' | 'title' | 'defaultSemiBold' | 'subtitle' | 'link';
|
||||
};
|
||||
|
||||
export function ThemedText({
|
||||
style,
|
||||
lightColor,
|
||||
darkColor,
|
||||
type = 'default',
|
||||
...rest
|
||||
}: ThemedTextProps) {
|
||||
const color = useThemeColor({ light: lightColor, dark: darkColor }, 'text');
|
||||
|
||||
return (
|
||||
<Text
|
||||
style={[
|
||||
{ color },
|
||||
type === 'default' ? styles.default : undefined,
|
||||
type === 'title' ? styles.title : undefined,
|
||||
type === 'defaultSemiBold' ? styles.defaultSemiBold : undefined,
|
||||
type === 'subtitle' ? styles.subtitle : undefined,
|
||||
type === 'link' ? styles.link : undefined,
|
||||
style,
|
||||
]}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
default: {
|
||||
fontSize: 16,
|
||||
lineHeight: 24,
|
||||
},
|
||||
defaultSemiBold: {
|
||||
fontSize: 16,
|
||||
lineHeight: 24,
|
||||
fontWeight: '600',
|
||||
},
|
||||
title: {
|
||||
fontSize: 32,
|
||||
fontWeight: 'bold',
|
||||
lineHeight: 32,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
link: {
|
||||
lineHeight: 30,
|
||||
fontSize: 16,
|
||||
color: '#0a7ea4',
|
||||
},
|
||||
});
|
||||
@@ -1,14 +0,0 @@
|
||||
import { View, type ViewProps } from 'react-native';
|
||||
|
||||
import { useThemeColor } from '@/hooks/use-theme-color';
|
||||
|
||||
export type ThemedViewProps = ViewProps & {
|
||||
lightColor?: string;
|
||||
darkColor?: string;
|
||||
};
|
||||
|
||||
export function ThemedView({ style, lightColor, darkColor, ...otherProps }: ThemedViewProps) {
|
||||
const backgroundColor = useThemeColor({ light: lightColor, dark: darkColor }, 'background');
|
||||
|
||||
return <View style={[{ backgroundColor }, style]} {...otherProps} />;
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
import { PropsWithChildren, useState } from 'react';
|
||||
import { StyleSheet, TouchableOpacity } from 'react-native';
|
||||
|
||||
import { ThemedText } from '@/components/themed-text';
|
||||
import { ThemedView } from '@/components/themed-view';
|
||||
import { IconSymbol } from '@/components/ui/icon-symbol';
|
||||
import { Colors } from '@/constants/theme';
|
||||
import { useColorScheme } from '@/hooks/use-color-scheme';
|
||||
|
||||
export function Collapsible({ children, title }: PropsWithChildren & { title: string }) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const theme = useColorScheme() ?? 'light';
|
||||
|
||||
return (
|
||||
<ThemedView>
|
||||
<TouchableOpacity
|
||||
style={styles.heading}
|
||||
onPress={() => setIsOpen((value) => !value)}
|
||||
activeOpacity={0.8}>
|
||||
<IconSymbol
|
||||
name="chevron.right"
|
||||
size={18}
|
||||
weight="medium"
|
||||
color={theme === 'light' ? Colors.light.icon : Colors.dark.icon}
|
||||
style={{ transform: [{ rotate: isOpen ? '90deg' : '0deg' }] }}
|
||||
/>
|
||||
|
||||
<ThemedText type="defaultSemiBold">{title}</ThemedText>
|
||||
</TouchableOpacity>
|
||||
{isOpen && <ThemedView style={styles.content}>{children}</ThemedView>}
|
||||
</ThemedView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
heading: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
},
|
||||
content: {
|
||||
marginTop: 6,
|
||||
marginLeft: 24,
|
||||
},
|
||||
});
|
||||
@@ -1,32 +0,0 @@
|
||||
import { SymbolView, SymbolViewProps, SymbolWeight } from 'expo-symbols';
|
||||
import { StyleProp, ViewStyle } from 'react-native';
|
||||
|
||||
export function IconSymbol({
|
||||
name,
|
||||
size = 24,
|
||||
color,
|
||||
style,
|
||||
weight = 'regular',
|
||||
}: {
|
||||
name: SymbolViewProps['name'];
|
||||
size?: number;
|
||||
color: string;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
weight?: SymbolWeight;
|
||||
}) {
|
||||
return (
|
||||
<SymbolView
|
||||
weight={weight}
|
||||
tintColor={color}
|
||||
resizeMode="scaleAspectFit"
|
||||
name={name}
|
||||
style={[
|
||||
{
|
||||
width: size,
|
||||
height: size,
|
||||
},
|
||||
style,
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
// Fallback for using MaterialIcons on Android and web.
|
||||
|
||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||
import { SymbolWeight, SymbolViewProps } from 'expo-symbols';
|
||||
import { ComponentProps } from 'react';
|
||||
import { OpaqueColorValue, type StyleProp, type TextStyle } from 'react-native';
|
||||
|
||||
type IconMapping = Record<SymbolViewProps['name'], ComponentProps<typeof MaterialIcons>['name']>;
|
||||
type IconSymbolName = keyof typeof MAPPING;
|
||||
|
||||
/**
|
||||
* Add your SF Symbols to Material Icons mappings here.
|
||||
* - see Material Icons in the [Icons Directory](https://icons.expo.fyi).
|
||||
* - see SF Symbols in the [SF Symbols](https://developer.apple.com/sf-symbols/) app.
|
||||
*/
|
||||
const MAPPING = {
|
||||
'house.fill': 'home',
|
||||
'paperplane.fill': 'send',
|
||||
'chevron.left.forwardslash.chevron.right': 'code',
|
||||
'chevron.right': 'chevron-right',
|
||||
} as IconMapping;
|
||||
|
||||
/**
|
||||
* An icon component that uses native SF Symbols on iOS, and Material Icons on Android and web.
|
||||
* This ensures a consistent look across platforms, and optimal resource usage.
|
||||
* Icon `name`s are based on SF Symbols and require manual mapping to Material Icons.
|
||||
*/
|
||||
export function IconSymbol({
|
||||
name,
|
||||
size = 24,
|
||||
color,
|
||||
style,
|
||||
}: {
|
||||
name: IconSymbolName;
|
||||
size?: number;
|
||||
color: string | OpaqueColorValue;
|
||||
style?: StyleProp<TextStyle>;
|
||||
weight?: SymbolWeight;
|
||||
}) {
|
||||
return <MaterialIcons color={color} size={size} name={MAPPING[name]} style={style} />;
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
/**
|
||||
* Below are the colors that are used in the app. The colors are defined in the light and dark mode.
|
||||
* There are many other ways to style your app. For example, [Nativewind](https://www.nativewind.dev/), [Tamagui](https://tamagui.dev/), [unistyles](https://reactnativeunistyles.vercel.app), etc.
|
||||
*/
|
||||
|
||||
import { Platform } from 'react-native';
|
||||
|
||||
const tintColorLight = '#0a7ea4';
|
||||
const tintColorDark = '#fff';
|
||||
|
||||
export const Colors = {
|
||||
light: {
|
||||
text: '#11181C',
|
||||
background: '#fff',
|
||||
tint: tintColorLight,
|
||||
icon: '#687076',
|
||||
tabIconDefault: '#687076',
|
||||
tabIconSelected: tintColorLight,
|
||||
},
|
||||
dark: {
|
||||
text: '#ECEDEE',
|
||||
background: '#151718',
|
||||
tint: tintColorDark,
|
||||
icon: '#9BA1A6',
|
||||
tabIconDefault: '#9BA1A6',
|
||||
tabIconSelected: tintColorDark,
|
||||
},
|
||||
};
|
||||
|
||||
export const Fonts = Platform.select({
|
||||
ios: {
|
||||
/** iOS `UIFontDescriptorSystemDesignDefault` */
|
||||
sans: 'system-ui',
|
||||
/** iOS `UIFontDescriptorSystemDesignSerif` */
|
||||
serif: 'ui-serif',
|
||||
/** iOS `UIFontDescriptorSystemDesignRounded` */
|
||||
rounded: 'ui-rounded',
|
||||
/** iOS `UIFontDescriptorSystemDesignMonospaced` */
|
||||
mono: 'ui-monospace',
|
||||
},
|
||||
default: {
|
||||
sans: 'normal',
|
||||
serif: 'serif',
|
||||
rounded: 'normal',
|
||||
mono: 'monospace',
|
||||
},
|
||||
web: {
|
||||
sans: "system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif",
|
||||
serif: "Georgia, 'Times New Roman', serif",
|
||||
rounded: "'SF Pro Rounded', 'Hiragino Maru Gothic ProN', Meiryo, 'MS PGothic', sans-serif",
|
||||
mono: "SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace",
|
||||
},
|
||||
});
|
||||
@@ -1 +0,0 @@
|
||||
export { useColorScheme } from 'react-native';
|
||||
@@ -1,21 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useColorScheme as useRNColorScheme } from 'react-native';
|
||||
|
||||
/**
|
||||
* To support static rendering, this value needs to be re-calculated on the client side for web
|
||||
*/
|
||||
export function useColorScheme() {
|
||||
const [hasHydrated, setHasHydrated] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setHasHydrated(true);
|
||||
}, []);
|
||||
|
||||
const colorScheme = useRNColorScheme();
|
||||
|
||||
if (hasHydrated) {
|
||||
return colorScheme;
|
||||
}
|
||||
|
||||
return 'light';
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
/**
|
||||
* Learn more about light and dark modes:
|
||||
* https://docs.expo.dev/guides/color-schemes/
|
||||
*/
|
||||
|
||||
import { Colors } from '@/constants/theme';
|
||||
import { useColorScheme } from '@/hooks/use-color-scheme';
|
||||
|
||||
export function useThemeColor(
|
||||
props: { light?: string; dark?: string },
|
||||
colorName: keyof typeof Colors.light & keyof typeof Colors.dark
|
||||
) {
|
||||
const theme = useColorScheme() ?? 'light';
|
||||
const colorFromProps = props[theme];
|
||||
|
||||
if (colorFromProps) {
|
||||
return colorFromProps;
|
||||
} else {
|
||||
return Colors[theme][colorName];
|
||||
}
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* This script is used to reset the project to a blank state.
|
||||
* It deletes or moves the /app, /components, /hooks, /scripts, and /constants directories to /app-example based on user input and creates a new /app directory with an index.tsx and _layout.tsx file.
|
||||
* You can remove the `reset-project` script from package.json and safely delete this file after running it.
|
||||
*/
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const readline = require("readline");
|
||||
|
||||
const root = process.cwd();
|
||||
const oldDirs = ["app", "components", "hooks", "constants", "scripts"];
|
||||
const exampleDir = "app-example";
|
||||
const newAppDir = "app";
|
||||
const exampleDirPath = path.join(root, exampleDir);
|
||||
|
||||
const indexContent = `import { Text, View } from "react-native";
|
||||
|
||||
export default function Index() {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Text>Edit app/index.tsx to edit this screen.</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
`;
|
||||
|
||||
const layoutContent = `import { Stack } from "expo-router";
|
||||
|
||||
export default function RootLayout() {
|
||||
return <Stack />;
|
||||
}
|
||||
`;
|
||||
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
});
|
||||
|
||||
const moveDirectories = async (userInput) => {
|
||||
try {
|
||||
if (userInput === "y") {
|
||||
// Create the app-example directory
|
||||
await fs.promises.mkdir(exampleDirPath, { recursive: true });
|
||||
console.log(`📁 /${exampleDir} directory created.`);
|
||||
}
|
||||
|
||||
// Move old directories to new app-example directory or delete them
|
||||
for (const dir of oldDirs) {
|
||||
const oldDirPath = path.join(root, dir);
|
||||
if (fs.existsSync(oldDirPath)) {
|
||||
if (userInput === "y") {
|
||||
const newDirPath = path.join(root, exampleDir, dir);
|
||||
await fs.promises.rename(oldDirPath, newDirPath);
|
||||
console.log(`➡️ /${dir} moved to /${exampleDir}/${dir}.`);
|
||||
} else {
|
||||
await fs.promises.rm(oldDirPath, { recursive: true, force: true });
|
||||
console.log(`❌ /${dir} deleted.`);
|
||||
}
|
||||
} else {
|
||||
console.log(`➡️ /${dir} does not exist, skipping.`);
|
||||
}
|
||||
}
|
||||
|
||||
// Create new /app directory
|
||||
const newAppDirPath = path.join(root, newAppDir);
|
||||
await fs.promises.mkdir(newAppDirPath, { recursive: true });
|
||||
console.log("\n📁 New /app directory created.");
|
||||
|
||||
// Create index.tsx
|
||||
const indexPath = path.join(newAppDirPath, "index.tsx");
|
||||
await fs.promises.writeFile(indexPath, indexContent);
|
||||
console.log("📄 app/index.tsx created.");
|
||||
|
||||
// Create _layout.tsx
|
||||
const layoutPath = path.join(newAppDirPath, "_layout.tsx");
|
||||
await fs.promises.writeFile(layoutPath, layoutContent);
|
||||
console.log("📄 app/_layout.tsx created.");
|
||||
|
||||
console.log("\n✅ Project reset complete. Next steps:");
|
||||
console.log(
|
||||
`1. Run \`npx expo start\` to start a development server.\n2. Edit app/index.tsx to edit the main screen.${
|
||||
userInput === "y"
|
||||
? `\n3. Delete the /${exampleDir} directory when you're done referencing it.`
|
||||
: ""
|
||||
}`
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(`❌ Error during script execution: ${error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
rl.question(
|
||||
"Do you want to move existing files to /app-example instead of deleting them? (Y/n): ",
|
||||
(answer) => {
|
||||
const userInput = answer.trim().toLowerCase() || "y";
|
||||
if (userInput === "y" || userInput === "n") {
|
||||
moveDirectories(userInput).finally(() => rl.close());
|
||||
} else {
|
||||
console.log("❌ Invalid input. Please enter 'Y' or 'N'.");
|
||||
rl.close();
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -1,13 +0,0 @@
|
||||
import type { PhaseSound } from '../types'
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||
export const PHASE_SOUNDS: Record<PhaseSound, number> = {
|
||||
beep_long: require('@/assets/audio/sounds/beep_long.mp3'),
|
||||
beep_double: require('@/assets/audio/sounds/beep_double.mp3'),
|
||||
beep_short: require('@/assets/audio/sounds/beep_short.mp3'),
|
||||
bell: require('@/assets/audio/sounds/bell.mp3'),
|
||||
fanfare: require('@/assets/audio/sounds/fanfare.mp3'),
|
||||
count_3: require('@/assets/audio/sounds/count_3.mp3'),
|
||||
count_2: require('@/assets/audio/sounds/count_2.mp3'),
|
||||
count_1: require('@/assets/audio/sounds/count_1.mp3'),
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import type { MusicIntensity } from '../types'
|
||||
|
||||
interface MusicTrack {
|
||||
id: string
|
||||
intensity: MusicIntensity
|
||||
asset: number
|
||||
}
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||
export const TRACKS: MusicTrack[] = [
|
||||
{
|
||||
id: 'electro_high',
|
||||
intensity: 'HIGH',
|
||||
asset: require('@/assets/audio/music/electro_high.mp3'),
|
||||
},
|
||||
{
|
||||
id: 'electro_low',
|
||||
intensity: 'LOW',
|
||||
asset: require('@/assets/audio/music/electro_low.mp3'),
|
||||
},
|
||||
]
|
||||
|
||||
export function getTrack(intensity: MusicIntensity): MusicTrack {
|
||||
const track = TRACKS.find((t) => t.intensity === intensity)
|
||||
if (!track) throw new Error(`Track not found: ${intensity}`)
|
||||
return track
|
||||
}
|
||||
@@ -1,173 +0,0 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Audio } from 'expo-av'
|
||||
import type { AudioEngine, MusicIntensity, PhaseSound } from '../types'
|
||||
import { PHASE_SOUNDS } from '../data/sounds'
|
||||
import { getTrack } from '../data/tracks'
|
||||
|
||||
const FADE_STEPS = 10
|
||||
|
||||
async function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
export function useAudioEngine(): AudioEngine {
|
||||
const [isLoaded, setIsLoaded] = useState(false)
|
||||
const soundsRef = useRef<Record<string, Audio.Sound>>({})
|
||||
const currentIntensityRef = useRef<MusicIntensity>('LOW')
|
||||
const musicVolumeRef = useRef(0.5)
|
||||
|
||||
// Configure audio session once
|
||||
useEffect(() => {
|
||||
Audio.setAudioModeAsync({
|
||||
playsInSilentModeIOS: true,
|
||||
allowsRecordingIOS: false,
|
||||
staysActiveInBackground: true,
|
||||
shouldDuckAndroid: true,
|
||||
playThroughEarpieceAndroid: false,
|
||||
}).catch((e) => {
|
||||
if (__DEV__) console.warn('[AudioEngine] Failed to configure audio session:', e)
|
||||
})
|
||||
}, [])
|
||||
|
||||
const preloadAll = useCallback(async () => {
|
||||
try {
|
||||
// Preload phase sounds
|
||||
for (const [key, asset] of Object.entries(PHASE_SOUNDS)) {
|
||||
const { sound } = await Audio.Sound.createAsync(asset, {
|
||||
shouldPlay: false,
|
||||
volume: 1.0,
|
||||
})
|
||||
soundsRef.current[key] = sound
|
||||
}
|
||||
|
||||
// Preload music tracks
|
||||
const highTrack = getTrack('HIGH')
|
||||
const lowTrack = getTrack('LOW')
|
||||
|
||||
const { sound: musicHigh } = await Audio.Sound.createAsync(highTrack.asset, {
|
||||
shouldPlay: false,
|
||||
volume: 0,
|
||||
isLooping: true,
|
||||
})
|
||||
soundsRef.current['music_high'] = musicHigh
|
||||
|
||||
const { sound: musicLow } = await Audio.Sound.createAsync(lowTrack.asset, {
|
||||
shouldPlay: false,
|
||||
volume: 0,
|
||||
isLooping: true,
|
||||
})
|
||||
soundsRef.current['music_low'] = musicLow
|
||||
|
||||
setIsLoaded(true)
|
||||
if (__DEV__) console.log('[AudioEngine] All sounds preloaded')
|
||||
} catch (e) {
|
||||
if (__DEV__) console.warn('[AudioEngine] Preload error:', e)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const playPhaseSound = useCallback(async (sound: PhaseSound) => {
|
||||
const s = soundsRef.current[sound]
|
||||
if (!s) return
|
||||
try {
|
||||
await s.setPositionAsync(0)
|
||||
await s.playAsync()
|
||||
} catch (e) {
|
||||
if (__DEV__) console.warn('[AudioEngine] Play error:', sound, e)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const startMusic = useCallback(async (intensity: MusicIntensity) => {
|
||||
const key = intensity === 'HIGH' ? 'music_high' : 'music_low'
|
||||
const s = soundsRef.current[key]
|
||||
if (!s) return
|
||||
try {
|
||||
currentIntensityRef.current = intensity
|
||||
await s.setPositionAsync(0)
|
||||
await s.setVolumeAsync(musicVolumeRef.current)
|
||||
await s.playAsync()
|
||||
} catch (e) {
|
||||
if (__DEV__) console.warn('[AudioEngine] startMusic error:', e)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const switchIntensity = useCallback(async (to: MusicIntensity) => {
|
||||
const from = currentIntensityRef.current
|
||||
if (from === to) return
|
||||
|
||||
const outKey = from === 'HIGH' ? 'music_high' : 'music_low'
|
||||
const inKey = to === 'HIGH' ? 'music_high' : 'music_low'
|
||||
const outSound = soundsRef.current[outKey]
|
||||
const inSound = soundsRef.current[inKey]
|
||||
|
||||
if (!outSound || !inSound) return
|
||||
|
||||
try {
|
||||
const vol = musicVolumeRef.current
|
||||
await inSound.setPositionAsync(0)
|
||||
await inSound.setVolumeAsync(0)
|
||||
await inSound.playAsync()
|
||||
|
||||
// Crossfade
|
||||
const stepMs = 500 / FADE_STEPS
|
||||
for (let i = 1; i <= FADE_STEPS; i++) {
|
||||
const progress = i / FADE_STEPS
|
||||
await Promise.all([
|
||||
outSound.setVolumeAsync(vol * (1 - progress)),
|
||||
inSound.setVolumeAsync(vol * progress),
|
||||
])
|
||||
await delay(stepMs)
|
||||
}
|
||||
|
||||
await outSound.stopAsync()
|
||||
currentIntensityRef.current = to
|
||||
} catch (e) {
|
||||
if (__DEV__) console.warn('[AudioEngine] switchIntensity error:', e)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const stopMusic = useCallback(async (fadeMs: number = 500) => {
|
||||
const key = currentIntensityRef.current === 'HIGH' ? 'music_high' : 'music_low'
|
||||
const s = soundsRef.current[key]
|
||||
if (!s) return
|
||||
|
||||
try {
|
||||
const stepMs = fadeMs / FADE_STEPS
|
||||
for (let i = FADE_STEPS; i >= 0; i--) {
|
||||
await s.setVolumeAsync(musicVolumeRef.current * (i / FADE_STEPS))
|
||||
await delay(stepMs)
|
||||
}
|
||||
await s.stopAsync()
|
||||
} catch (e) {
|
||||
if (__DEV__) console.warn('[AudioEngine] stopMusic error:', e)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const unloadAll = useCallback(async () => {
|
||||
await Promise.all(
|
||||
Object.values(soundsRef.current).map((s) =>
|
||||
s.unloadAsync().catch(() => {})
|
||||
)
|
||||
)
|
||||
soundsRef.current = {}
|
||||
setIsLoaded(false)
|
||||
}, [])
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
Object.values(soundsRef.current).forEach((s) => {
|
||||
s.unloadAsync().catch(() => {})
|
||||
})
|
||||
}
|
||||
}, [])
|
||||
|
||||
return {
|
||||
isLoaded,
|
||||
preloadAll,
|
||||
playPhaseSound,
|
||||
startMusic,
|
||||
switchIntensity,
|
||||
stopMusic,
|
||||
unloadAll,
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
export { useAudioEngine } from './hooks/useAudioEngine'
|
||||
export type {
|
||||
MusicAmbiance,
|
||||
MusicIntensity,
|
||||
PhaseSound,
|
||||
AudioSettings,
|
||||
AudioEngine,
|
||||
} from './types'
|
||||
@@ -1,31 +0,0 @@
|
||||
export type MusicAmbiance = 'ELECTRO' | 'SILENCE'
|
||||
export type MusicIntensity = 'LOW' | 'HIGH'
|
||||
|
||||
export type PhaseSound =
|
||||
| 'beep_long'
|
||||
| 'beep_double'
|
||||
| 'beep_short'
|
||||
| 'bell'
|
||||
| 'fanfare'
|
||||
| 'count_3'
|
||||
| 'count_2'
|
||||
| 'count_1'
|
||||
|
||||
export interface AudioSettings {
|
||||
musicEnabled: boolean
|
||||
ambiance: MusicAmbiance
|
||||
musicVolume: number
|
||||
soundsEnabled: boolean
|
||||
soundsVolume: number
|
||||
hapticsEnabled: boolean
|
||||
}
|
||||
|
||||
export interface AudioEngine {
|
||||
isLoaded: boolean
|
||||
preloadAll: () => Promise<void>
|
||||
playPhaseSound: (sound: PhaseSound) => Promise<void>
|
||||
startMusic: (intensity: MusicIntensity) => Promise<void>
|
||||
switchIntensity: (intensity: MusicIntensity) => Promise<void>
|
||||
stopMusic: (fadeMs?: number) => Promise<void>
|
||||
unloadAll: () => Promise<void>
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
import { StyleSheet, View, Text, Pressable } from 'react-native'
|
||||
import { Ionicons } from '@expo/vector-icons'
|
||||
import { BRAND, GLASS, TEXT, BORDER, SURFACE } from '@/src/shared/constants/colors'
|
||||
import { TYPOGRAPHY } from '@/src/shared/constants/typography'
|
||||
import { RADIUS } from '@/src/shared/constants/borderRadius'
|
||||
import { SPACING } from '@/src/shared/constants/spacing'
|
||||
|
||||
interface ChoiceButtonProps {
|
||||
label: string
|
||||
description?: string
|
||||
icon: keyof typeof Ionicons.glyphMap
|
||||
selected: boolean
|
||||
onPress: () => void
|
||||
}
|
||||
|
||||
export function ChoiceButton({
|
||||
label,
|
||||
description,
|
||||
icon,
|
||||
selected,
|
||||
onPress,
|
||||
}: ChoiceButtonProps) {
|
||||
return (
|
||||
<Pressable onPress={onPress} style={styles.pressable}>
|
||||
<View style={[styles.container, selected && styles.containerSelected]}>
|
||||
<View style={[styles.iconContainer, selected && styles.iconContainerSelected]}>
|
||||
<Ionicons
|
||||
name={icon}
|
||||
size={24}
|
||||
color={selected ? BRAND.PRIMARY : TEXT.MUTED}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.textContainer}>
|
||||
<Text style={[styles.label, selected && styles.labelSelected]}>
|
||||
{label}
|
||||
</Text>
|
||||
{description && (
|
||||
<Text style={styles.description}>{description}</Text>
|
||||
)}
|
||||
</View>
|
||||
{selected && (
|
||||
<View style={styles.checkmark}>
|
||||
<Ionicons name="checkmark-circle" size={24} color={BRAND.PRIMARY} />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</Pressable>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
pressable: {
|
||||
width: '100%',
|
||||
},
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: GLASS.FILL,
|
||||
borderWidth: 1,
|
||||
borderColor: GLASS.BORDER,
|
||||
borderRadius: RADIUS.LG,
|
||||
paddingVertical: SPACING[4],
|
||||
paddingHorizontal: SPACING[4],
|
||||
gap: SPACING[3],
|
||||
},
|
||||
containerSelected: {
|
||||
backgroundColor: SURFACE.OVERLAY_LIGHT,
|
||||
borderColor: BRAND.PRIMARY,
|
||||
borderWidth: 2,
|
||||
},
|
||||
iconContainer: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: RADIUS.MD,
|
||||
backgroundColor: SURFACE.OVERLAY_LIGHT,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
iconContainerSelected: {
|
||||
backgroundColor: SURFACE.OVERLAY_MEDIUM,
|
||||
},
|
||||
textContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
label: {
|
||||
...TYPOGRAPHY.body,
|
||||
color: TEXT.PRIMARY,
|
||||
},
|
||||
labelSelected: {
|
||||
color: BRAND.PRIMARY,
|
||||
},
|
||||
description: {
|
||||
...TYPOGRAPHY.caption,
|
||||
color: TEXT.MUTED,
|
||||
marginTop: 4,
|
||||
},
|
||||
checkmark: {
|
||||
marginLeft: SPACING[2],
|
||||
},
|
||||
})
|
||||
@@ -1,178 +0,0 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { StyleSheet, View, Text, Animated } from 'react-native'
|
||||
import { useTimerEngine } from '@/src/features/timer/hooks/useTimerEngine'
|
||||
import { PHASE_COLORS, TEXT } from '@/src/shared/constants/colors'
|
||||
import { TYPOGRAPHY } from '@/src/shared/constants/typography'
|
||||
import { RADIUS } from '@/src/shared/constants/borderRadius'
|
||||
import { SPACING } from '@/src/shared/constants/spacing'
|
||||
|
||||
interface MiniTimerDemoProps {
|
||||
onComplete?: () => void
|
||||
onPhaseChange?: (phase: string) => void
|
||||
onCountdownTick?: (seconds: number) => void
|
||||
autoStartDelay?: number
|
||||
}
|
||||
|
||||
export function MiniTimerDemo({
|
||||
onComplete,
|
||||
onPhaseChange,
|
||||
onCountdownTick,
|
||||
autoStartDelay = 500,
|
||||
}: MiniTimerDemoProps) {
|
||||
const timer = useTimerEngine()
|
||||
const [hasCompleted, setHasCompleted] = useState(false)
|
||||
const pulseAnim = useRef(new Animated.Value(1)).current
|
||||
const glowAnim = useRef(new Animated.Value(0)).current
|
||||
|
||||
useEffect(() => {
|
||||
// Auto-start after a short delay
|
||||
const startTimeout = setTimeout(() => {
|
||||
timer.start({
|
||||
workDuration: 20,
|
||||
restDuration: 0,
|
||||
rounds: 1,
|
||||
getReadyDuration: 3,
|
||||
cycles: 1,
|
||||
})
|
||||
}, autoStartDelay)
|
||||
|
||||
return () => clearTimeout(startTimeout)
|
||||
}, [timer, autoStartDelay])
|
||||
|
||||
useEffect(() => {
|
||||
// Listen for all timer events
|
||||
const unsubscribe = timer.addEventListener((event) => {
|
||||
switch (event.type) {
|
||||
case 'SESSION_COMPLETE':
|
||||
setHasCompleted(true)
|
||||
onComplete?.()
|
||||
break
|
||||
case 'PHASE_CHANGED':
|
||||
onPhaseChange?.(event.to)
|
||||
break
|
||||
case 'COUNTDOWN_TICK':
|
||||
onCountdownTick?.(event.secondsLeft)
|
||||
break
|
||||
}
|
||||
})
|
||||
|
||||
return unsubscribe
|
||||
}, [timer, onComplete, onPhaseChange, onCountdownTick])
|
||||
|
||||
useEffect(() => {
|
||||
// Pulse animation when running
|
||||
if (timer.isRunning) {
|
||||
Animated.loop(
|
||||
Animated.sequence([
|
||||
Animated.timing(pulseAnim, {
|
||||
toValue: 1.05,
|
||||
duration: 800,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(pulseAnim, {
|
||||
toValue: 1,
|
||||
duration: 800,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
])
|
||||
).start()
|
||||
} else {
|
||||
pulseAnim.setValue(1)
|
||||
}
|
||||
}, [timer.isRunning, pulseAnim])
|
||||
|
||||
useEffect(() => {
|
||||
// Glow animation
|
||||
Animated.loop(
|
||||
Animated.sequence([
|
||||
Animated.timing(glowAnim, {
|
||||
toValue: 1,
|
||||
duration: 1500,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(glowAnim, {
|
||||
toValue: 0,
|
||||
duration: 1500,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
])
|
||||
).start()
|
||||
}, [glowAnim])
|
||||
|
||||
const getPhaseText = (): string => {
|
||||
if (hasCompleted) return 'DONE!'
|
||||
if (timer.phase === 'GET_READY') return 'GET READY'
|
||||
if (timer.phase === 'WORK') return 'GO!'
|
||||
if (timer.phase === 'COMPLETE') return 'DONE!'
|
||||
return ''
|
||||
}
|
||||
|
||||
const phaseColor = PHASE_COLORS[timer.phase] || PHASE_COLORS.IDLE
|
||||
const displaySeconds = timer.secondsLeft > 0 ? timer.secondsLeft : 0
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.timerCircle,
|
||||
{ borderColor: phaseColor },
|
||||
{ transform: [{ scale: pulseAnim }] },
|
||||
]}
|
||||
>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.glowOverlay,
|
||||
{
|
||||
backgroundColor: phaseColor,
|
||||
opacity: glowAnim.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: [0.05, 0.15],
|
||||
}),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Text style={[styles.countdown, { color: phaseColor }]}>
|
||||
{displaySeconds}
|
||||
</Text>
|
||||
<Text style={[styles.phaseText, { color: phaseColor }]}>
|
||||
{getPhaseText()}
|
||||
</Text>
|
||||
</Animated.View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: SPACING[6],
|
||||
},
|
||||
timerCircle: {
|
||||
width: 200,
|
||||
height: 200,
|
||||
borderRadius: 100,
|
||||
borderWidth: 4,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.3)',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
glowOverlay: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
borderRadius: 100,
|
||||
},
|
||||
countdown: {
|
||||
...TYPOGRAPHY.timeDisplay,
|
||||
fontVariant: ['tabular-nums'],
|
||||
},
|
||||
phaseText: {
|
||||
...TYPOGRAPHY.label,
|
||||
marginTop: SPACING[2],
|
||||
letterSpacing: 3,
|
||||
},
|
||||
})
|
||||
@@ -1,46 +0,0 @@
|
||||
import { ReactNode } from 'react'
|
||||
import { StyleSheet, View, SafeAreaView } from 'react-native'
|
||||
import { LinearGradient } from 'expo-linear-gradient'
|
||||
import { APP_GRADIENTS } from '@/src/shared/constants/colors'
|
||||
import { LAYOUT, SPACING } from '@/src/shared/constants/spacing'
|
||||
import { ProgressBar } from './ProgressBar'
|
||||
|
||||
interface OnboardingScreenProps {
|
||||
children: ReactNode
|
||||
currentStep: number
|
||||
totalSteps?: number
|
||||
}
|
||||
|
||||
export function OnboardingScreen({
|
||||
children,
|
||||
currentStep,
|
||||
totalSteps = 6,
|
||||
}: OnboardingScreenProps) {
|
||||
return (
|
||||
<LinearGradient
|
||||
colors={APP_GRADIENTS.HOME}
|
||||
style={styles.gradient}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
>
|
||||
<SafeAreaView style={styles.container}>
|
||||
<ProgressBar currentStep={currentStep} totalSteps={totalSteps} />
|
||||
<View style={styles.content}>{children}</View>
|
||||
</SafeAreaView>
|
||||
</LinearGradient>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
gradient: {
|
||||
flex: 1,
|
||||
},
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
paddingHorizontal: LAYOUT.PAGE_HORIZONTAL,
|
||||
paddingBottom: SPACING[6],
|
||||
},
|
||||
})
|
||||
@@ -1,158 +0,0 @@
|
||||
import { StyleSheet, View, Text, Pressable } from 'react-native'
|
||||
import { BRAND, GLASS, TEXT, SURFACE, BORDER } from '@/src/shared/constants/colors'
|
||||
import { TYPOGRAPHY } from '@/src/shared/constants/typography'
|
||||
import { RADIUS } from '@/src/shared/constants/borderRadius'
|
||||
import { SPACING } from '@/src/shared/constants/spacing'
|
||||
|
||||
interface PaywallCardProps {
|
||||
title: string
|
||||
price: string
|
||||
period: string
|
||||
features: string[]
|
||||
selected: boolean
|
||||
onPress: () => void
|
||||
badge?: string
|
||||
}
|
||||
|
||||
export function PaywallCard({
|
||||
title,
|
||||
price,
|
||||
period,
|
||||
features,
|
||||
selected,
|
||||
onPress,
|
||||
badge,
|
||||
}: PaywallCardProps) {
|
||||
return (
|
||||
<Pressable onPress={onPress} style={styles.pressable}>
|
||||
<View style={[styles.container, selected && styles.containerSelected]}>
|
||||
{badge && (
|
||||
<View style={styles.badge}>
|
||||
<Text style={styles.badgeText}>{badge}</Text>
|
||||
</View>
|
||||
)}
|
||||
<View style={styles.header}>
|
||||
<Text style={[styles.title, selected && styles.titleSelected]}>
|
||||
{title}
|
||||
</Text>
|
||||
<View style={styles.priceRow}>
|
||||
<Text style={styles.price}>{price}</Text>
|
||||
<Text style={styles.period}>/{period}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.divider} />
|
||||
<View style={styles.features}>
|
||||
{features.map((feature, index) => (
|
||||
<View key={index} style={styles.featureRow}>
|
||||
<View style={styles.featureDot} />
|
||||
<Text style={styles.featureText}>{feature}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
{selected && (
|
||||
<View style={styles.selectedIndicator}>
|
||||
<View style={styles.selectedDot} />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</Pressable>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
pressable: {
|
||||
width: '100%',
|
||||
},
|
||||
container: {
|
||||
backgroundColor: GLASS.FILL_MEDIUM,
|
||||
borderWidth: 1,
|
||||
borderColor: GLASS.BORDER,
|
||||
borderRadius: RADIUS.XL,
|
||||
padding: SPACING[4],
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
containerSelected: {
|
||||
backgroundColor: SURFACE.OVERLAY_MEDIUM,
|
||||
borderColor: BRAND.PRIMARY,
|
||||
borderWidth: 2,
|
||||
},
|
||||
badge: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
right: 0,
|
||||
backgroundColor: BRAND.PRIMARY,
|
||||
paddingHorizontal: SPACING[3],
|
||||
paddingVertical: SPACING[1.5],
|
||||
borderBottomLeftRadius: RADIUS.MD,
|
||||
},
|
||||
badgeText: {
|
||||
...TYPOGRAPHY.overline,
|
||||
color: TEXT.PRIMARY,
|
||||
fontWeight: '700',
|
||||
},
|
||||
header: {
|
||||
marginBottom: SPACING[3],
|
||||
},
|
||||
title: {
|
||||
...TYPOGRAPHY.heading,
|
||||
color: TEXT.PRIMARY,
|
||||
marginBottom: SPACING[2],
|
||||
},
|
||||
titleSelected: {
|
||||
color: BRAND.PRIMARY,
|
||||
},
|
||||
priceRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'baseline',
|
||||
},
|
||||
price: {
|
||||
...TYPOGRAPHY.displaySmall,
|
||||
color: TEXT.PRIMARY,
|
||||
fontWeight: '900',
|
||||
},
|
||||
period: {
|
||||
...TYPOGRAPHY.caption,
|
||||
color: TEXT.MUTED,
|
||||
marginLeft: SPACING[1],
|
||||
},
|
||||
divider: {
|
||||
height: 1,
|
||||
backgroundColor: BORDER.LIGHT,
|
||||
marginBottom: SPACING[3],
|
||||
},
|
||||
features: {
|
||||
gap: SPACING[2],
|
||||
},
|
||||
featureRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: SPACING[2],
|
||||
},
|
||||
featureDot: {
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: 3,
|
||||
backgroundColor: BRAND.PRIMARY,
|
||||
},
|
||||
featureText: {
|
||||
...TYPOGRAPHY.caption,
|
||||
color: TEXT.SECONDARY,
|
||||
},
|
||||
selectedIndicator: {
|
||||
position: 'absolute',
|
||||
top: SPACING[3],
|
||||
left: SPACING[3],
|
||||
},
|
||||
selectedDot: {
|
||||
width: 12,
|
||||
height: 12,
|
||||
borderRadius: 6,
|
||||
backgroundColor: BRAND.PRIMARY,
|
||||
shadowColor: BRAND.PRIMARY,
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: 0.6,
|
||||
shadowRadius: 6,
|
||||
elevation: 3,
|
||||
},
|
||||
})
|
||||
@@ -1,85 +0,0 @@
|
||||
import { StyleSheet, Text, Pressable, Animated } from 'react-native'
|
||||
import { BRAND, TEXT } from '@/src/shared/constants/colors'
|
||||
import { TYPOGRAPHY } from '@/src/shared/constants/typography'
|
||||
import { RADIUS } from '@/src/shared/constants/borderRadius'
|
||||
|
||||
interface PrimaryButtonProps {
|
||||
title: string
|
||||
onPress: () => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export function PrimaryButton({
|
||||
title,
|
||||
onPress,
|
||||
disabled = false,
|
||||
}: PrimaryButtonProps) {
|
||||
const animatedValue = new Animated.Value(1)
|
||||
|
||||
const handlePressIn = () => {
|
||||
Animated.spring(animatedValue, {
|
||||
toValue: 0.96,
|
||||
useNativeDriver: true,
|
||||
}).start()
|
||||
}
|
||||
|
||||
const handlePressOut = () => {
|
||||
Animated.spring(animatedValue, {
|
||||
toValue: 1,
|
||||
friction: 3,
|
||||
useNativeDriver: true,
|
||||
}).start()
|
||||
}
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
onPressIn={handlePressIn}
|
||||
onPressOut={handlePressOut}
|
||||
disabled={disabled}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.button,
|
||||
disabled && styles.buttonDisabled,
|
||||
{ transform: [{ scale: animatedValue }] },
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.text, disabled && styles.textDisabled]}>
|
||||
{title}
|
||||
</Text>
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
button: {
|
||||
backgroundColor: BRAND.PRIMARY,
|
||||
borderRadius: RADIUS['2XL'],
|
||||
paddingVertical: 18,
|
||||
paddingHorizontal: 32,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
shadowColor: BRAND.PRIMARY,
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.4,
|
||||
shadowRadius: 12,
|
||||
elevation: 8,
|
||||
},
|
||||
buttonDisabled: {
|
||||
backgroundColor: BRAND.PRIMARY,
|
||||
opacity: 0.5,
|
||||
shadowOpacity: 0,
|
||||
elevation: 0,
|
||||
},
|
||||
text: {
|
||||
...TYPOGRAPHY.buttonMedium,
|
||||
color: TEXT.PRIMARY,
|
||||
},
|
||||
textDisabled: {
|
||||
color: TEXT.PRIMARY,
|
||||
opacity: 0.7,
|
||||
},
|
||||
})
|
||||
@@ -1,89 +0,0 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { StyleSheet, View, Animated } from 'react-native'
|
||||
import { BRAND, SURFACE } from '@/src/shared/constants/colors'
|
||||
|
||||
interface ProgressBarProps {
|
||||
currentStep: number
|
||||
totalSteps?: number
|
||||
}
|
||||
|
||||
export function ProgressBar({
|
||||
currentStep,
|
||||
totalSteps = 6,
|
||||
}: ProgressBarProps) {
|
||||
const scaleAnims = useRef(
|
||||
Array.from({ length: totalSteps }, () => new Animated.Value(1))
|
||||
).current
|
||||
|
||||
useEffect(() => {
|
||||
// Animate the newly active dot
|
||||
if (currentStep >= 0 && currentStep < totalSteps) {
|
||||
Animated.sequence([
|
||||
Animated.timing(scaleAnims[currentStep], {
|
||||
toValue: 1.3,
|
||||
duration: 150,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(scaleAnims[currentStep], {
|
||||
toValue: 1,
|
||||
duration: 150,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start()
|
||||
}
|
||||
}, [currentStep, totalSteps, scaleAnims])
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{Array.from({ length: totalSteps }).map((_, index) => {
|
||||
const isActive = index === currentStep
|
||||
const isCompleted = index < currentStep
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
key={index}
|
||||
style={[
|
||||
styles.dot,
|
||||
isActive && styles.dotActive,
|
||||
isCompleted && styles.dotCompleted,
|
||||
{ transform: [{ scale: scaleAnims[index] }] },
|
||||
]}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const DOT_SIZE = 10
|
||||
const DOT_SIZE_ACTIVE = 12
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
paddingVertical: 16,
|
||||
},
|
||||
dot: {
|
||||
width: DOT_SIZE,
|
||||
height: DOT_SIZE,
|
||||
borderRadius: DOT_SIZE / 2,
|
||||
backgroundColor: SURFACE.OVERLAY_LIGHT,
|
||||
},
|
||||
dotActive: {
|
||||
width: DOT_SIZE_ACTIVE,
|
||||
height: DOT_SIZE_ACTIVE,
|
||||
borderRadius: DOT_SIZE_ACTIVE / 2,
|
||||
backgroundColor: BRAND.PRIMARY,
|
||||
shadowColor: BRAND.PRIMARY,
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: 0.8,
|
||||
shadowRadius: 8,
|
||||
elevation: 4,
|
||||
},
|
||||
dotCompleted: {
|
||||
backgroundColor: BRAND.PRIMARY,
|
||||
},
|
||||
})
|
||||
@@ -1,6 +0,0 @@
|
||||
export { OnboardingScreen } from './OnboardingScreen'
|
||||
export { ProgressBar } from './ProgressBar'
|
||||
export { PrimaryButton } from './PrimaryButton'
|
||||
export { ChoiceButton } from './ChoiceButton'
|
||||
export { MiniTimerDemo } from './MiniTimerDemo'
|
||||
export { PaywallCard } from './PaywallCard'
|
||||
@@ -1,15 +0,0 @@
|
||||
import type { Barrier } from '../types'
|
||||
|
||||
export interface BarrierOption {
|
||||
id: Barrier
|
||||
label: string
|
||||
description: string
|
||||
icon: string // Ionicons name
|
||||
}
|
||||
|
||||
export const BARRIERS: BarrierOption[] = [
|
||||
{ id: 'time', label: 'Le temps', description: 'Je n\'ai pas assez de temps', icon: 'time-outline' },
|
||||
{ id: 'motivation', label: 'La motivation', description: 'Je n\'arrive pas à rester motivé(e)', icon: 'flash-outline' },
|
||||
{ id: 'knowledge', label: 'Le savoir', description: 'Je ne sais pas quoi faire', icon: 'book-outline' },
|
||||
{ id: 'gym', label: 'La salle', description: 'Je n\'ai pas accès à une salle', icon: 'barbell-outline' },
|
||||
]
|
||||
@@ -1,15 +0,0 @@
|
||||
import type { Goal } from '../types'
|
||||
|
||||
export interface GoalOption {
|
||||
id: Goal
|
||||
label: string
|
||||
description: string
|
||||
icon: string
|
||||
}
|
||||
|
||||
export const GOALS: GoalOption[] = [
|
||||
{ id: 'weight_loss', label: 'Perte de poids', description: 'Brûler des graisses efficacement', icon: 'flame-outline' },
|
||||
{ id: 'cardio', label: 'Cardio', description: 'Améliorer mon endurance', icon: 'heart-outline' },
|
||||
{ id: 'strength', label: 'Force', description: 'Développer ma musculature', icon: 'barbell-outline' },
|
||||
{ id: 'wellness', label: 'Bien-être', description: 'Me sentir mieux dans mon corps', icon: 'happy-outline' },
|
||||
]
|
||||
@@ -1,5 +0,0 @@
|
||||
// Barrel export for onboarding data
|
||||
|
||||
export { BARRIERS, type BarrierOption } from './barriers'
|
||||
export { GOALS, type GoalOption } from './goals'
|
||||
export { LEVELS, type LevelOption } from './levels'
|
||||
@@ -1,14 +0,0 @@
|
||||
import type { Level } from '../types'
|
||||
|
||||
export interface LevelOption {
|
||||
id: Level
|
||||
label: string
|
||||
description: string
|
||||
icon: string
|
||||
}
|
||||
|
||||
export const LEVELS: LevelOption[] = [
|
||||
{ id: 'beginner', label: 'Débutant', description: 'Je commence le sport', icon: 'leaf-outline' },
|
||||
{ id: 'intermediate', label: 'Intermédiaire', description: 'Je fais du sport régulièrement', icon: 'fitness-outline' },
|
||||
{ id: 'advanced', label: 'Avancé', description: 'Je suis très actif(ve)', icon: 'trophy-outline' },
|
||||
]
|
||||
@@ -1,137 +0,0 @@
|
||||
import { create } from 'zustand'
|
||||
import {
|
||||
createJSONStorage,
|
||||
persist,
|
||||
type StateStorage,
|
||||
} from 'zustand/middleware'
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
import type {
|
||||
Barrier,
|
||||
Frequency,
|
||||
Goal,
|
||||
Level,
|
||||
OnboardingData,
|
||||
OnboardingState,
|
||||
} from '../types'
|
||||
|
||||
const STORAGE_KEY_COMPLETE = 'tabatago_onboarding_complete'
|
||||
const STORAGE_KEY_DATA = 'tabatago_onboarding_data'
|
||||
|
||||
// Custom storage that uses AsyncStorage
|
||||
const onboardingStorage: StateStorage = {
|
||||
getItem: async (name: string): Promise<string | null> => {
|
||||
return await AsyncStorage.getItem(name)
|
||||
},
|
||||
setItem: async (name: string, value: string): Promise<void> => {
|
||||
await AsyncStorage.setItem(name, value)
|
||||
},
|
||||
removeItem: async (name: string): Promise<void> => {
|
||||
await AsyncStorage.removeItem(name)
|
||||
},
|
||||
}
|
||||
|
||||
const initialData: OnboardingData = {
|
||||
barrier: null,
|
||||
level: null,
|
||||
goal: null,
|
||||
frequency: null,
|
||||
}
|
||||
|
||||
interface OnboardingActions {
|
||||
setStep: (step: number) => void
|
||||
nextStep: () => void
|
||||
prevStep: () => void
|
||||
setData: (data: Partial<OnboardingData>) => void
|
||||
setBarrier: (barrier: Barrier) => void
|
||||
setLevel: (level: Level) => void
|
||||
setGoal: (goal: Goal) => void
|
||||
setFrequency: (frequency: Frequency) => void
|
||||
completeOnboarding: () => void
|
||||
resetOnboarding: () => void
|
||||
}
|
||||
|
||||
type OnboardingStore = OnboardingState & OnboardingActions
|
||||
|
||||
export const useOnboarding = create<OnboardingStore>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
// Initial state
|
||||
currentStep: 0,
|
||||
isOnboardingComplete: false,
|
||||
data: initialData,
|
||||
|
||||
// Actions
|
||||
setStep: (step: number) => {
|
||||
set({ currentStep: step })
|
||||
},
|
||||
|
||||
nextStep: () => {
|
||||
const { currentStep } = get()
|
||||
set({ currentStep: currentStep + 1 })
|
||||
},
|
||||
|
||||
prevStep: () => {
|
||||
const { currentStep } = get()
|
||||
if (currentStep > 0) {
|
||||
set({ currentStep: currentStep - 1 })
|
||||
}
|
||||
},
|
||||
|
||||
setData: (data: Partial<OnboardingData>) => {
|
||||
set((state) => ({
|
||||
data: { ...state.data, ...data },
|
||||
}))
|
||||
},
|
||||
|
||||
setBarrier: (barrier: Barrier) => {
|
||||
set((state) => ({
|
||||
data: { ...state.data, barrier },
|
||||
}))
|
||||
},
|
||||
|
||||
setLevel: (level: Level) => {
|
||||
set((state) => ({
|
||||
data: { ...state.data, level },
|
||||
}))
|
||||
},
|
||||
|
||||
setGoal: (goal: Goal) => {
|
||||
set((state) => ({
|
||||
data: { ...state.data, goal },
|
||||
}))
|
||||
},
|
||||
|
||||
setFrequency: (frequency: Frequency) => {
|
||||
set((state) => ({
|
||||
data: { ...state.data, frequency },
|
||||
}))
|
||||
},
|
||||
|
||||
completeOnboarding: () => {
|
||||
set({ isOnboardingComplete: true })
|
||||
},
|
||||
|
||||
resetOnboarding: () => {
|
||||
set({
|
||||
currentStep: 0,
|
||||
isOnboardingComplete: false,
|
||||
data: initialData,
|
||||
})
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: STORAGE_KEY_DATA,
|
||||
storage: createJSONStorage(() => onboardingStorage),
|
||||
partialize: (state) => ({
|
||||
isOnboardingComplete: state.isOnboardingComplete,
|
||||
data: state.data,
|
||||
}),
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
// Selector hooks for better performance
|
||||
export const useOnboardingStep = () => useOnboarding((state) => state.currentStep)
|
||||
export const useIsOnboardingComplete = () =>
|
||||
useOnboarding((state) => state.isOnboardingComplete)
|
||||
export const useOnboardingData = () => useOnboarding((state) => state.data)
|
||||
@@ -1,26 +0,0 @@
|
||||
// Types
|
||||
export * from './types'
|
||||
|
||||
// Hooks
|
||||
export { useOnboarding, useOnboardingStep, useIsOnboardingComplete, useOnboardingData } from './hooks/useOnboarding'
|
||||
|
||||
// Components
|
||||
export { OnboardingScreen } from './components/OnboardingScreen'
|
||||
export { PrimaryButton } from './components/PrimaryButton'
|
||||
export { ChoiceButton } from './components/ChoiceButton'
|
||||
export { PaywallCard } from './components/PaywallCard'
|
||||
export { MiniTimerDemo } from './components/MiniTimerDemo'
|
||||
export { ProgressBar } from './components/ProgressBar'
|
||||
|
||||
// Screens
|
||||
export { Screen1Problem } from './screens/Screen1Problem'
|
||||
export { Screen2Empathy } from './screens/Screen2Empathy'
|
||||
export { Screen3Solution } from './screens/Screen3Solution'
|
||||
export { Screen4WowMoment } from './screens/Screen4WowMoment'
|
||||
export { Screen5Personalization } from './screens/Screen5Personalization'
|
||||
export { Screen6Paywall } from './screens/Screen6Paywall'
|
||||
|
||||
// Data
|
||||
export { BARRIERS } from './data/barriers'
|
||||
export { LEVELS } from './data/levels'
|
||||
export { GOALS } from './data/goals'
|
||||
@@ -1,165 +0,0 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { StyleSheet, View, Text, Animated } from 'react-native'
|
||||
import { Ionicons } from '@expo/vector-icons'
|
||||
import { OnboardingScreen } from '../components/OnboardingScreen'
|
||||
import { PrimaryButton } from '../components/PrimaryButton'
|
||||
import { BRAND, TEXT } from '@/src/shared/constants/colors'
|
||||
import { TYPOGRAPHY } from '@/src/shared/constants/typography'
|
||||
import { SPACING } from '@/src/shared/constants/spacing'
|
||||
|
||||
interface Screen1ProblemProps {
|
||||
onNext: () => void
|
||||
}
|
||||
|
||||
export function Screen1Problem({ onNext }: Screen1ProblemProps) {
|
||||
const clockScale = useRef(new Animated.Value(1)).current
|
||||
const clockRotation = useRef(new Animated.Value(0)).current
|
||||
const opacityAnim = useRef(new Animated.Value(0)).current
|
||||
const translateYAnim = useRef(new Animated.Value(20)).current
|
||||
|
||||
useEffect(() => {
|
||||
// Entrance animation
|
||||
Animated.parallel([
|
||||
Animated.timing(opacityAnim, {
|
||||
toValue: 1,
|
||||
duration: 600,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(translateYAnim, {
|
||||
toValue: 0,
|
||||
duration: 600,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start()
|
||||
|
||||
// Clock pulse animation
|
||||
const pulseAnimation = Animated.loop(
|
||||
Animated.sequence([
|
||||
Animated.timing(clockScale, {
|
||||
toValue: 1.1,
|
||||
duration: 800,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(clockScale, {
|
||||
toValue: 1,
|
||||
duration: 800,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
])
|
||||
)
|
||||
|
||||
// Clock rotation animation
|
||||
const rotationAnimation = Animated.loop(
|
||||
Animated.timing(clockRotation, {
|
||||
toValue: 1,
|
||||
duration: 4000,
|
||||
useNativeDriver: true,
|
||||
})
|
||||
)
|
||||
|
||||
pulseAnimation.start()
|
||||
rotationAnimation.start()
|
||||
|
||||
return () => {
|
||||
pulseAnimation.stop()
|
||||
rotationAnimation.stop()
|
||||
}
|
||||
}, [clockScale, clockRotation, opacityAnim, translateYAnim])
|
||||
|
||||
const rotationInterpolate = clockRotation.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: ['0deg', '360deg'],
|
||||
})
|
||||
|
||||
return (
|
||||
<OnboardingScreen currentStep={0}>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.container,
|
||||
{
|
||||
opacity: opacityAnim,
|
||||
transform: [{ translateY: translateYAnim }],
|
||||
},
|
||||
]}
|
||||
>
|
||||
{/* Clock Icon Animation */}
|
||||
<View style={styles.iconContainer}>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.clockCircle,
|
||||
{ transform: [{ scale: clockScale }] },
|
||||
]}
|
||||
>
|
||||
<Animated.View style={{ transform: [{ rotate: rotationInterpolate }] }}>
|
||||
<Ionicons name="time-outline" size={80} color={BRAND.PRIMARY} />
|
||||
</Animated.View>
|
||||
</Animated.View>
|
||||
<View style={styles.crossMark}>
|
||||
<Ionicons name="close" size={32} color={BRAND.DANGER} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Title and Subtitle */}
|
||||
<View style={styles.textContainer}>
|
||||
<Text style={styles.title}>Tu n'as pas 1 heure pour t'entrainer ?</Text>
|
||||
<Text style={styles.subtitle}>Ni 30 minutes ? Ni meme 10 ?</Text>
|
||||
</View>
|
||||
|
||||
{/* Spacer */}
|
||||
<View style={styles.spacer} />
|
||||
|
||||
{/* Continue Button */}
|
||||
<PrimaryButton title="Continuer" onPress={onNext} />
|
||||
</Animated.View>
|
||||
</OnboardingScreen>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: SPACING[4],
|
||||
},
|
||||
iconContainer: {
|
||||
position: 'relative',
|
||||
marginBottom: SPACING[10],
|
||||
},
|
||||
clockCircle: {
|
||||
width: 140,
|
||||
height: 140,
|
||||
borderRadius: 70,
|
||||
backgroundColor: 'rgba(249, 115, 22, 0.1)',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderWidth: 2,
|
||||
borderColor: 'rgba(249, 115, 22, 0.3)',
|
||||
},
|
||||
crossMark: {
|
||||
position: 'absolute',
|
||||
bottom: -5,
|
||||
right: -5,
|
||||
backgroundColor: 'rgba(239, 68, 68, 0.2)',
|
||||
borderRadius: 20,
|
||||
padding: SPACING[1],
|
||||
},
|
||||
textContainer: {
|
||||
alignItems: 'center',
|
||||
marginBottom: SPACING[8],
|
||||
},
|
||||
title: {
|
||||
...TYPOGRAPHY.displayLarge,
|
||||
color: TEXT.PRIMARY,
|
||||
textAlign: 'center',
|
||||
marginBottom: SPACING[4],
|
||||
},
|
||||
subtitle: {
|
||||
...TYPOGRAPHY.heading,
|
||||
color: TEXT.MUTED,
|
||||
textAlign: 'center',
|
||||
},
|
||||
spacer: {
|
||||
flex: 1,
|
||||
},
|
||||
})
|
||||
@@ -1,104 +0,0 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { StyleSheet, View, Text, Animated } from 'react-native'
|
||||
import { OnboardingScreen } from '../components/OnboardingScreen'
|
||||
import { ChoiceButton } from '../components/ChoiceButton'
|
||||
import { useOnboarding } from '../hooks/useOnboarding'
|
||||
import { BARRIERS } from '../data/barriers'
|
||||
import { TEXT } from '@/src/shared/constants/colors'
|
||||
import { TYPOGRAPHY } from '@/src/shared/constants/typography'
|
||||
import { SPACING } from '@/src/shared/constants/spacing'
|
||||
import type { Barrier } from '../types'
|
||||
|
||||
interface Screen2EmpathyProps {
|
||||
onNext: () => void
|
||||
}
|
||||
|
||||
export function Screen2Empathy({ onNext }: Screen2EmpathyProps) {
|
||||
const [selectedBarrier, setSelectedBarrier] = useState<Barrier | null>(null)
|
||||
const { setBarrier } = useOnboarding()
|
||||
const opacityAnim = useRef(new Animated.Value(0)).current
|
||||
const translateYAnim = useRef(new Animated.Value(20)).current
|
||||
|
||||
useEffect(() => {
|
||||
// Entrance animation
|
||||
Animated.parallel([
|
||||
Animated.timing(opacityAnim, {
|
||||
toValue: 1,
|
||||
duration: 600,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(translateYAnim, {
|
||||
toValue: 0,
|
||||
duration: 600,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start()
|
||||
}, [opacityAnim, translateYAnim])
|
||||
|
||||
const handleBarrierSelect = (barrier: Barrier) => {
|
||||
setSelectedBarrier(barrier)
|
||||
setBarrier(barrier)
|
||||
// Auto-advance after selection
|
||||
setTimeout(() => {
|
||||
onNext()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
return (
|
||||
<OnboardingScreen currentStep={1}>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.container,
|
||||
{
|
||||
opacity: opacityAnim,
|
||||
transform: [{ translateY: translateYAnim }],
|
||||
},
|
||||
]}
|
||||
>
|
||||
{/* Title */}
|
||||
<View style={styles.titleContainer}>
|
||||
<Text style={styles.title}>
|
||||
Qu'est-ce qui t'empeche de t'entrainer ?
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Choice Buttons */}
|
||||
<View style={styles.choicesContainer}>
|
||||
{BARRIERS.map((barrier, index) => (
|
||||
<View key={barrier.id} style={styles.choiceWrapper}>
|
||||
<ChoiceButton
|
||||
label={barrier.label}
|
||||
description={barrier.description}
|
||||
icon={barrier.icon as never}
|
||||
selected={selectedBarrier === barrier.id}
|
||||
onPress={() => handleBarrierSelect(barrier.id)}
|
||||
/>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</Animated.View>
|
||||
</OnboardingScreen>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
paddingTop: SPACING[8],
|
||||
},
|
||||
titleContainer: {
|
||||
marginBottom: SPACING[8],
|
||||
paddingHorizontal: SPACING[2],
|
||||
},
|
||||
title: {
|
||||
...TYPOGRAPHY.heading,
|
||||
color: TEXT.PRIMARY,
|
||||
textAlign: 'center',
|
||||
},
|
||||
choicesContainer: {
|
||||
gap: SPACING[3],
|
||||
},
|
||||
choiceWrapper: {
|
||||
width: '100%',
|
||||
},
|
||||
})
|
||||
@@ -1,251 +0,0 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { StyleSheet, View, Text, Animated } from 'react-native'
|
||||
import { OnboardingScreen } from '../components/OnboardingScreen'
|
||||
import { PrimaryButton } from '../components/PrimaryButton'
|
||||
import { BRAND, TEXT, SURFACE, PHASE_COLORS } from '@/src/shared/constants/colors'
|
||||
import { TYPOGRAPHY } from '@/src/shared/constants/typography'
|
||||
import { SPACING } from '@/src/shared/constants/spacing'
|
||||
import { RADIUS } from '@/src/shared/constants/borderRadius'
|
||||
|
||||
interface Screen3SolutionProps {
|
||||
onNext: () => void
|
||||
}
|
||||
|
||||
const TABATA_ROUNDS = 8
|
||||
const WORK_DURATION = 20
|
||||
const REST_DURATION = 10
|
||||
|
||||
export function Screen3Solution({ onNext }: Screen3SolutionProps) {
|
||||
const opacityAnim = useRef(new Animated.Value(0)).current
|
||||
const translateYAnim = useRef(new Animated.Value(20)).current
|
||||
const activeRound = useRef(new Animated.Value(0)).current
|
||||
const pulseAnim = useRef(new Animated.Value(1)).current
|
||||
|
||||
useEffect(() => {
|
||||
// Entrance animation
|
||||
Animated.parallel([
|
||||
Animated.timing(opacityAnim, {
|
||||
toValue: 1,
|
||||
duration: 600,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(translateYAnim, {
|
||||
toValue: 0,
|
||||
duration: 600,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start()
|
||||
|
||||
// Round cycling animation
|
||||
const roundAnimation = Animated.loop(
|
||||
Animated.sequence([
|
||||
...Array.from({ length: TABATA_ROUNDS }, (_, i) =>
|
||||
Animated.timing(activeRound, {
|
||||
toValue: i + 1,
|
||||
duration: 800,
|
||||
useNativeDriver: false,
|
||||
})
|
||||
),
|
||||
Animated.timing(activeRound, {
|
||||
toValue: 0,
|
||||
duration: 500,
|
||||
useNativeDriver: false,
|
||||
}),
|
||||
])
|
||||
)
|
||||
|
||||
// Pulse animation for the "4 minutes" text
|
||||
const pulseAnimation = Animated.loop(
|
||||
Animated.sequence([
|
||||
Animated.timing(pulseAnim, {
|
||||
toValue: 1.05,
|
||||
duration: 600,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(pulseAnim, {
|
||||
toValue: 1,
|
||||
duration: 600,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
])
|
||||
)
|
||||
|
||||
roundAnimation.start()
|
||||
pulseAnimation.start()
|
||||
|
||||
return () => {
|
||||
roundAnimation.stop()
|
||||
pulseAnimation.stop()
|
||||
}
|
||||
}, [activeRound, opacityAnim, translateYAnim, pulseAnim])
|
||||
|
||||
const renderTabataTimeline = () => {
|
||||
return (
|
||||
<View style={styles.timelineContainer}>
|
||||
{Array.from({ length: TABATA_ROUNDS }, (_, index) => {
|
||||
const isWork = index % 2 === 0
|
||||
const baseColor = isWork ? PHASE_COLORS.WORK : PHASE_COLORS.REST
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
key={index}
|
||||
style={[
|
||||
styles.timelineBlock,
|
||||
{
|
||||
backgroundColor: activeRound.interpolate({
|
||||
inputRange: [index, index + 1, index + 1.01, TABATA_ROUNDS + 1],
|
||||
outputRange: [
|
||||
'rgba(255, 255, 255, 0.1)',
|
||||
baseColor,
|
||||
'rgba(255, 255, 255, 0.1)',
|
||||
'rgba(255, 255, 255, 0.1)',
|
||||
],
|
||||
extrapolate: 'clamp',
|
||||
}),
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Text style={styles.timelineText}>
|
||||
{isWork ? WORK_DURATION : REST_DURATION}s
|
||||
</Text>
|
||||
</Animated.View>
|
||||
)
|
||||
})}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<OnboardingScreen currentStep={2}>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.container,
|
||||
{
|
||||
opacity: opacityAnim,
|
||||
transform: [{ translateY: translateYAnim }],
|
||||
},
|
||||
]}
|
||||
>
|
||||
{/* Animated Title */}
|
||||
<Animated.View style={[styles.titleContainer, { transform: [{ scale: pulseAnim }] }]}>
|
||||
<Text style={styles.title}>4 minutes</Text>
|
||||
</Animated.View>
|
||||
|
||||
{/* Subtitle */}
|
||||
<Text style={styles.subtitle}>Vraiment transformatrices.</Text>
|
||||
|
||||
{/* Tabata Timeline Animation */}
|
||||
<View style={styles.animationContainer}>
|
||||
{renderTabataTimeline()}
|
||||
|
||||
{/* Legend */}
|
||||
<View style={styles.legendContainer}>
|
||||
<View style={styles.legendItem}>
|
||||
<View style={[styles.legendDot, { backgroundColor: PHASE_COLORS.WORK }]} />
|
||||
<Text style={styles.legendText}>20s travail</Text>
|
||||
</View>
|
||||
<View style={styles.legendItem}>
|
||||
<View style={[styles.legendDot, { backgroundColor: PHASE_COLORS.REST }]} />
|
||||
<Text style={styles.legendText}>10s repos</Text>
|
||||
</View>
|
||||
<Text style={styles.legendText}>x 8 rounds</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Scientific Explanation */}
|
||||
<View style={styles.explanationContainer}>
|
||||
<Text style={styles.explanationText}>
|
||||
Protocole scientifique HIIT = resultats max en temps min
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Spacer */}
|
||||
<View style={styles.spacer} />
|
||||
|
||||
{/* Continue Button */}
|
||||
<PrimaryButton title="Voir comment ca marche" onPress={onNext} />
|
||||
</Animated.View>
|
||||
</OnboardingScreen>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
paddingTop: SPACING[10],
|
||||
},
|
||||
titleContainer: {
|
||||
marginBottom: SPACING[2],
|
||||
},
|
||||
title: {
|
||||
...TYPOGRAPHY.brandTitle,
|
||||
color: BRAND.PRIMARY,
|
||||
textAlign: 'center',
|
||||
},
|
||||
subtitle: {
|
||||
...TYPOGRAPHY.heading,
|
||||
color: TEXT.SECONDARY,
|
||||
textAlign: 'center',
|
||||
marginBottom: SPACING[8],
|
||||
},
|
||||
animationContainer: {
|
||||
width: '100%',
|
||||
paddingVertical: SPACING[6],
|
||||
paddingHorizontal: SPACING[4],
|
||||
},
|
||||
timelineContainer: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
justifyContent: 'center',
|
||||
gap: SPACING[2],
|
||||
marginBottom: SPACING[4],
|
||||
},
|
||||
timelineBlock: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: RADIUS.MD,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: SURFACE.OVERLAY_LIGHT,
|
||||
},
|
||||
timelineText: {
|
||||
...TYPOGRAPHY.overline,
|
||||
color: TEXT.PRIMARY,
|
||||
fontWeight: '700',
|
||||
},
|
||||
legendContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: SPACING[4],
|
||||
flexWrap: 'wrap',
|
||||
},
|
||||
legendItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: SPACING[1],
|
||||
},
|
||||
legendDot: {
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: 5,
|
||||
},
|
||||
legendText: {
|
||||
...TYPOGRAPHY.caption,
|
||||
color: TEXT.MUTED,
|
||||
},
|
||||
explanationContainer: {
|
||||
paddingHorizontal: SPACING[4],
|
||||
marginTop: SPACING[4],
|
||||
},
|
||||
explanationText: {
|
||||
...TYPOGRAPHY.body,
|
||||
color: TEXT.TERTIARY,
|
||||
textAlign: 'center',
|
||||
lineHeight: 26,
|
||||
},
|
||||
spacer: {
|
||||
flex: 1,
|
||||
},
|
||||
})
|
||||
@@ -1,149 +0,0 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { StyleSheet, View, Text, Animated } from 'react-native'
|
||||
import * as Haptics from 'expo-haptics'
|
||||
import { OnboardingScreen } from '../components/OnboardingScreen'
|
||||
import { PrimaryButton } from '../components/PrimaryButton'
|
||||
import { MiniTimerDemo } from '../components/MiniTimerDemo'
|
||||
import { useOnboarding } from '../hooks/useOnboarding'
|
||||
import { TEXT } from '@/src/shared/constants/colors'
|
||||
import { TYPOGRAPHY } from '@/src/shared/constants/typography'
|
||||
import { SPACING } from '@/src/shared/constants/spacing'
|
||||
|
||||
export function Screen4WowMoment() {
|
||||
const nextStep = useOnboarding((state) => state.nextStep)
|
||||
const [isComplete, setIsComplete] = useState(false)
|
||||
const [currentPhase, setCurrentPhase] = useState<string>('IDLE')
|
||||
const fadeAnim = useState(new Animated.Value(0))[0]
|
||||
|
||||
// Fade in animation for the button when complete
|
||||
useEffect(() => {
|
||||
if (isComplete) {
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success)
|
||||
Animated.timing(fadeAnim, {
|
||||
toValue: 1,
|
||||
duration: 400,
|
||||
useNativeDriver: true,
|
||||
}).start()
|
||||
}
|
||||
}, [isComplete, fadeAnim])
|
||||
|
||||
const getInstructionText = (): string => {
|
||||
if (isComplete) {
|
||||
return 'Bravo ! Tu viens de completer ton premier mini-Tabata.'
|
||||
}
|
||||
if (currentPhase === 'GET_READY') {
|
||||
return 'Prepare-toi... Le timer va bientot commencer !'
|
||||
}
|
||||
if (currentPhase === 'WORK') {
|
||||
return 'Donne tout ! 20 secondes, c est parti !'
|
||||
}
|
||||
return 'Un mini-Tabata de 20 secondes. Juste pour sentir.'
|
||||
}
|
||||
|
||||
const handlePhaseChange = useCallback((phase: string) => {
|
||||
setCurrentPhase(phase)
|
||||
if (phase === 'WORK') {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium)
|
||||
} else if (phase === 'GET_READY') {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleCountdownTick = useCallback((seconds: number) => {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light)
|
||||
}, [])
|
||||
|
||||
const handleComplete = useCallback(() => {
|
||||
setIsComplete(true)
|
||||
}, [])
|
||||
|
||||
const handleContinue = () => {
|
||||
nextStep()
|
||||
}
|
||||
|
||||
return (
|
||||
<OnboardingScreen currentStep={3}>
|
||||
<View style={styles.container}>
|
||||
{/* Title Section */}
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>Essaie maintenant</Text>
|
||||
<Text style={styles.subtitle}>20 secondes. Juste pour sentir.</Text>
|
||||
</View>
|
||||
|
||||
{/* Timer Demo - The "Wow" Moment */}
|
||||
<View style={styles.timerContainer}>
|
||||
<MiniTimerDemo
|
||||
onComplete={handleComplete}
|
||||
onPhaseChange={handlePhaseChange}
|
||||
onCountdownTick={handleCountdownTick}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Instruction Text */}
|
||||
<View style={styles.instructionContainer}>
|
||||
<Text style={styles.instructionText}>{getInstructionText()}</Text>
|
||||
</View>
|
||||
|
||||
{/* Continue Button - Only visible after completion */}
|
||||
<View style={styles.buttonContainer}>
|
||||
{isComplete ? (
|
||||
<Animated.View style={{ opacity: fadeAnim, width: '100%' }}>
|
||||
<PrimaryButton title="C'etait facile !" onPress={handleContinue} />
|
||||
</Animated.View>
|
||||
) : (
|
||||
<View style={styles.placeholderButton} />
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</OnboardingScreen>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
justifyContent: 'space-between',
|
||||
paddingTop: SPACING[10],
|
||||
},
|
||||
header: {
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: SPACING[4],
|
||||
},
|
||||
title: {
|
||||
...TYPOGRAPHY.displayLarge,
|
||||
color: TEXT.PRIMARY,
|
||||
textAlign: 'center',
|
||||
marginBottom: SPACING[3],
|
||||
},
|
||||
subtitle: {
|
||||
...TYPOGRAPHY.body,
|
||||
color: TEXT.SECONDARY,
|
||||
textAlign: 'center',
|
||||
},
|
||||
timerContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
instructionContainer: {
|
||||
paddingHorizontal: SPACING[6],
|
||||
paddingVertical: SPACING[4],
|
||||
alignItems: 'center',
|
||||
},
|
||||
instructionText: {
|
||||
...TYPOGRAPHY.caption,
|
||||
color: TEXT.TERTIARY,
|
||||
textAlign: 'center',
|
||||
lineHeight: 24,
|
||||
},
|
||||
buttonContainer: {
|
||||
paddingHorizontal: SPACING[4],
|
||||
paddingBottom: SPACING[4],
|
||||
minHeight: 70,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
placeholderButton: {
|
||||
height: 54,
|
||||
},
|
||||
})
|
||||
@@ -1,186 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
import { StyleSheet, View, Text, ScrollView } from 'react-native'
|
||||
import { Ionicons } from '@expo/vector-icons'
|
||||
import { useOnboarding } from '../hooks/useOnboarding'
|
||||
import { LEVELS } from '../data/levels'
|
||||
import { GOALS } from '../data/goals'
|
||||
import { OnboardingScreen } from '../components/OnboardingScreen'
|
||||
import { ChoiceButton } from '../components/ChoiceButton'
|
||||
import { PrimaryButton } from '../components/PrimaryButton'
|
||||
import { BRAND, TEXT } from '@/src/shared/constants/colors'
|
||||
import { TYPOGRAPHY } from '@/src/shared/constants/typography'
|
||||
import { SPACING } from '@/src/shared/constants/spacing'
|
||||
import type { Level, Goal, Frequency } from '../types'
|
||||
|
||||
interface Screen5PersonalizationProps {
|
||||
onNext: () => void
|
||||
}
|
||||
|
||||
interface FrequencyOption {
|
||||
id: Frequency
|
||||
label: string
|
||||
description: string
|
||||
}
|
||||
|
||||
const FREQUENCIES: FrequencyOption[] = [
|
||||
{ id: 2, label: '2x/semaine', description: 'Démarrage en douceur' },
|
||||
{ id: 3, label: '3x/semaine', description: 'Rythme equilibre' },
|
||||
{ id: 5, label: '5x/semaine', description: 'Entrainement intensif' },
|
||||
]
|
||||
|
||||
export function Screen5Personalization({ onNext }: Screen5PersonalizationProps) {
|
||||
const { data, setLevel, setGoal, setFrequency } = useOnboarding()
|
||||
|
||||
// Local state for selections
|
||||
const [selectedLevel, setSelectedLevel] = useState<Level | null>(data.level)
|
||||
const [selectedGoal, setSelectedGoal] = useState<Goal | null>(data.goal)
|
||||
const [selectedFrequency, setSelectedFrequency] = useState<Frequency | null>(data.frequency)
|
||||
|
||||
const handleLevelSelect = (level: Level) => {
|
||||
setSelectedLevel(level)
|
||||
setLevel(level)
|
||||
}
|
||||
|
||||
const handleGoalSelect = (goal: Goal) => {
|
||||
setSelectedGoal(goal)
|
||||
setGoal(goal)
|
||||
}
|
||||
|
||||
const handleFrequencySelect = (frequency: Frequency) => {
|
||||
setSelectedFrequency(frequency)
|
||||
setFrequency(frequency)
|
||||
}
|
||||
|
||||
const handleContinue = () => {
|
||||
onNext()
|
||||
}
|
||||
|
||||
const isFormComplete = selectedLevel && selectedGoal && selectedFrequency
|
||||
|
||||
return (
|
||||
<OnboardingScreen currentStep={4}>
|
||||
<ScrollView
|
||||
style={styles.scrollView}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>Personnalise ton experience</Text>
|
||||
<Text style={styles.subtitle}>
|
||||
Dis-nous en plus sur toi pour un programme sur mesure
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Level Section */}
|
||||
<View style={styles.section}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<Ionicons name="fitness-outline" size={20} color={BRAND.PRIMARY} />
|
||||
<Text style={styles.sectionTitle}>Niveau</Text>
|
||||
</View>
|
||||
<View style={styles.optionsContainer}>
|
||||
{LEVELS.map((level) => (
|
||||
<ChoiceButton
|
||||
key={level.id}
|
||||
label={level.label}
|
||||
description={level.description}
|
||||
icon={level.icon as keyof typeof Ionicons.glyphMap}
|
||||
selected={selectedLevel === level.id}
|
||||
onPress={() => handleLevelSelect(level.id)}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Goal Section */}
|
||||
<View style={styles.section}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<Ionicons name="flag-outline" size={20} color={BRAND.PRIMARY} />
|
||||
<Text style={styles.sectionTitle}>Objectif</Text>
|
||||
</View>
|
||||
<View style={styles.optionsContainer}>
|
||||
{GOALS.map((goal) => (
|
||||
<ChoiceButton
|
||||
key={goal.id}
|
||||
label={goal.label}
|
||||
description={goal.description}
|
||||
icon={goal.icon as keyof typeof Ionicons.glyphMap}
|
||||
selected={selectedGoal === goal.id}
|
||||
onPress={() => handleGoalSelect(goal.id)}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Frequency Section */}
|
||||
<View style={styles.section}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<Ionicons name="calendar-outline" size={20} color={BRAND.PRIMARY} />
|
||||
<Text style={styles.sectionTitle}>Frequence</Text>
|
||||
</View>
|
||||
<View style={styles.optionsContainer}>
|
||||
{FREQUENCIES.map((freq) => (
|
||||
<ChoiceButton
|
||||
key={freq.id}
|
||||
label={freq.label}
|
||||
description={freq.description}
|
||||
icon="time-outline"
|
||||
selected={selectedFrequency === freq.id}
|
||||
onPress={() => handleFrequencySelect(freq.id)}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
<View style={styles.footer}>
|
||||
<PrimaryButton
|
||||
title="Continuer"
|
||||
onPress={handleContinue}
|
||||
disabled={!isFormComplete}
|
||||
/>
|
||||
</View>
|
||||
</OnboardingScreen>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
scrollView: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollContent: {
|
||||
paddingBottom: SPACING[6],
|
||||
},
|
||||
header: {
|
||||
marginBottom: SPACING[6],
|
||||
},
|
||||
title: {
|
||||
...TYPOGRAPHY.heading,
|
||||
color: TEXT.PRIMARY,
|
||||
marginBottom: SPACING[2],
|
||||
},
|
||||
subtitle: {
|
||||
...TYPOGRAPHY.caption,
|
||||
color: TEXT.MUTED,
|
||||
},
|
||||
section: {
|
||||
marginBottom: SPACING[6],
|
||||
},
|
||||
sectionHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: SPACING[2],
|
||||
marginBottom: SPACING[3],
|
||||
},
|
||||
sectionTitle: {
|
||||
...TYPOGRAPHY.label,
|
||||
color: TEXT.SECONDARY,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 1,
|
||||
},
|
||||
optionsContainer: {
|
||||
gap: SPACING[3],
|
||||
},
|
||||
footer: {
|
||||
paddingTop: SPACING[4],
|
||||
},
|
||||
})
|
||||
@@ -1,234 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
import { StyleSheet, View, Text, ScrollView, Pressable } from 'react-native'
|
||||
import { useOnboarding } from '../hooks/useOnboarding'
|
||||
import { OnboardingScreen } from '../components/OnboardingScreen'
|
||||
import { PaywallCard } from '../components/PaywallCard'
|
||||
import { BRAND, TEXT } from '@/src/shared/constants/colors'
|
||||
import { TYPOGRAPHY } from '@/src/shared/constants/typography'
|
||||
import { SPACING } from '@/src/shared/constants/spacing'
|
||||
|
||||
interface Screen6PaywallProps {
|
||||
onComplete: () => void
|
||||
}
|
||||
|
||||
interface PlanOption {
|
||||
id: 'trial' | 'monthly' | 'annual'
|
||||
title: string
|
||||
price: string
|
||||
period: string
|
||||
features: string[]
|
||||
badge?: string
|
||||
}
|
||||
|
||||
const PLANS: PlanOption[] = [
|
||||
{
|
||||
id: 'trial',
|
||||
title: 'Essai gratuit',
|
||||
price: '0',
|
||||
period: '7 jours',
|
||||
features: [
|
||||
'Acces complet pendant 7 jours',
|
||||
'Tous les programmes debloques',
|
||||
'Annule a tout moment',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'monthly',
|
||||
title: 'Mensuel',
|
||||
price: '4.99',
|
||||
period: 'mois',
|
||||
features: [
|
||||
'Acces illimite',
|
||||
'Tous les programmes',
|
||||
'Support prioritaire',
|
||||
'Nouvelles fonctionnalites',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'annual',
|
||||
title: 'Annuel',
|
||||
price: '29.99',
|
||||
period: 'an',
|
||||
badge: 'Economise 50%',
|
||||
features: [
|
||||
'Acces illimite',
|
||||
'Tous les programmes',
|
||||
'Support prioritaire',
|
||||
'Nouvelles fonctionnalites',
|
||||
'Entrainements exclusifs',
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
export function Screen6Paywall({ onComplete }: Screen6PaywallProps) {
|
||||
const { completeOnboarding } = useOnboarding()
|
||||
const [selectedPlan, setSelectedPlan] = useState<'trial' | 'monthly' | 'annual'>('annual')
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
const handlePlanSelect = (planId: 'trial' | 'monthly' | 'annual') => {
|
||||
setSelectedPlan(planId)
|
||||
}
|
||||
|
||||
const handlePurchase = async () => {
|
||||
setIsLoading(true)
|
||||
|
||||
try {
|
||||
// Mock RevenueCat purchase in dev mode
|
||||
// In production, this would call the actual RevenueCat SDK
|
||||
await mockPurchase(selectedPlan)
|
||||
|
||||
// Complete onboarding and navigate to home
|
||||
completeOnboarding()
|
||||
onComplete()
|
||||
} catch (error) {
|
||||
console.error('Purchase failed:', error)
|
||||
// In production, show error to user
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSkip = () => {
|
||||
// Skip paywall and go to home
|
||||
completeOnboarding()
|
||||
onComplete()
|
||||
}
|
||||
|
||||
return (
|
||||
<OnboardingScreen currentStep={5}>
|
||||
<ScrollView
|
||||
style={styles.scrollView}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>Debloque ton potentiel</Text>
|
||||
<Text style={styles.subtitle}>
|
||||
7 jours gratuits, annule quand tu veux
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.plansContainer}>
|
||||
{PLANS.map((plan) => (
|
||||
<PaywallCard
|
||||
key={plan.id}
|
||||
title={plan.title}
|
||||
price={plan.price}
|
||||
period={plan.period}
|
||||
features={plan.features}
|
||||
badge={plan.badge}
|
||||
selected={selectedPlan === plan.id}
|
||||
onPress={() => handlePlanSelect(plan.id)}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<View style={styles.guarantee}>
|
||||
<Text style={styles.guaranteeText}>
|
||||
Garantie satisfait ou rembourse sous 30 jours
|
||||
</Text>
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
<View style={styles.footer}>
|
||||
<Pressable
|
||||
style={[styles.continueButton, isLoading && styles.buttonLoading]}
|
||||
onPress={handlePurchase}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<Text style={styles.continueButtonText}>
|
||||
{isLoading ? 'Traitement...' : selectedPlan === 'trial' ? 'Essayer gratuitement' : "S'abonner"}
|
||||
</Text>
|
||||
</Pressable>
|
||||
|
||||
<Pressable style={styles.skipButton} onPress={handleSkip}>
|
||||
<Text style={styles.skipButtonText}>
|
||||
Continuer sans abonnement
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</OnboardingScreen>
|
||||
)
|
||||
}
|
||||
|
||||
// Mock RevenueCat purchase function for dev mode
|
||||
async function mockPurchase(planId: string): Promise<void> {
|
||||
// Simulate network delay
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500))
|
||||
|
||||
// Simulate successful purchase
|
||||
console.log(`[MOCK] Purchase successful for plan: ${planId}`)
|
||||
|
||||
// In production with RevenueCat:
|
||||
// const { customerInfo } = await Purchases.purchasePackage(package)
|
||||
// if (customerInfo.entitlements.active['pro']) { ... }
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
scrollView: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollContent: {
|
||||
paddingBottom: SPACING[6],
|
||||
},
|
||||
header: {
|
||||
marginBottom: SPACING[6],
|
||||
alignItems: 'center',
|
||||
},
|
||||
title: {
|
||||
...TYPOGRAPHY.heading,
|
||||
color: TEXT.PRIMARY,
|
||||
marginBottom: SPACING[2],
|
||||
textAlign: 'center',
|
||||
},
|
||||
subtitle: {
|
||||
...TYPOGRAPHY.caption,
|
||||
color: TEXT.MUTED,
|
||||
textAlign: 'center',
|
||||
},
|
||||
plansContainer: {
|
||||
gap: SPACING[4],
|
||||
},
|
||||
guarantee: {
|
||||
marginTop: SPACING[6],
|
||||
alignItems: 'center',
|
||||
},
|
||||
guaranteeText: {
|
||||
...TYPOGRAPHY.caption,
|
||||
color: TEXT.HINT,
|
||||
textAlign: 'center',
|
||||
},
|
||||
footer: {
|
||||
paddingTop: SPACING[4],
|
||||
},
|
||||
continueButton: {
|
||||
backgroundColor: BRAND.PRIMARY,
|
||||
borderRadius: 28,
|
||||
paddingVertical: 18,
|
||||
paddingHorizontal: 32,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
shadowColor: BRAND.PRIMARY,
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.4,
|
||||
shadowRadius: 12,
|
||||
elevation: 8,
|
||||
},
|
||||
buttonLoading: {
|
||||
opacity: 0.7,
|
||||
},
|
||||
continueButtonText: {
|
||||
...TYPOGRAPHY.buttonMedium,
|
||||
color: TEXT.PRIMARY,
|
||||
},
|
||||
skipButton: {
|
||||
marginTop: SPACING[4],
|
||||
paddingVertical: SPACING[2],
|
||||
alignItems: 'center',
|
||||
},
|
||||
skipButtonText: {
|
||||
...TYPOGRAPHY.caption,
|
||||
color: TEXT.HINT,
|
||||
textDecorationLine: 'underline',
|
||||
},
|
||||
})
|
||||
@@ -1,2 +0,0 @@
|
||||
export { Screen5Personalization } from './Screen5Personalization'
|
||||
export { Screen6Paywall } from './Screen6Paywall'
|
||||
@@ -1,20 +0,0 @@
|
||||
export type Barrier = 'time' | 'motivation' | 'knowledge' | 'gym'
|
||||
|
||||
export type Level = 'beginner' | 'intermediate' | 'advanced'
|
||||
|
||||
export type Goal = 'weight_loss' | 'cardio' | 'strength' | 'wellness'
|
||||
|
||||
export type Frequency = 2 | 3 | 5
|
||||
|
||||
export interface OnboardingData {
|
||||
barrier: Barrier | null
|
||||
level: Level | null
|
||||
goal: Goal | null
|
||||
frequency: Frequency | null
|
||||
}
|
||||
|
||||
export interface OnboardingState {
|
||||
currentStep: number
|
||||
isOnboardingComplete: boolean
|
||||
data: OnboardingData
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
import { Pressable, StyleSheet, View } from 'react-native'
|
||||
import Ionicons from '@expo/vector-icons/Ionicons'
|
||||
|
||||
import { SURFACE, TEXT } from '@/src/shared/constants/colors'
|
||||
import { RADIUS } from '@/src/shared/constants/borderRadius'
|
||||
import { LAYOUT } from '@/src/shared/constants/spacing'
|
||||
|
||||
interface TimerControlsProps {
|
||||
isRunning: boolean
|
||||
isPaused: boolean
|
||||
onPause: () => void
|
||||
onResume: () => void
|
||||
onStop: () => void
|
||||
onSkip: () => void
|
||||
}
|
||||
|
||||
export function TimerControls({
|
||||
isRunning,
|
||||
isPaused,
|
||||
onPause,
|
||||
onResume,
|
||||
onStop,
|
||||
onSkip,
|
||||
}: TimerControlsProps) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.button, pressed && styles.pressed]}
|
||||
onPress={onStop}
|
||||
>
|
||||
<Ionicons name="stop" size={28} color={TEXT.PRIMARY} />
|
||||
</Pressable>
|
||||
|
||||
<Pressable
|
||||
style={({ pressed }) => [
|
||||
styles.button,
|
||||
styles.mainButton,
|
||||
pressed && styles.pressed,
|
||||
]}
|
||||
onPress={isPaused ? onResume : onPause}
|
||||
>
|
||||
<Ionicons
|
||||
name={isPaused ? 'play' : 'pause'}
|
||||
size={36}
|
||||
color={TEXT.PRIMARY}
|
||||
/>
|
||||
</Pressable>
|
||||
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.button, pressed && styles.pressed]}
|
||||
onPress={onSkip}
|
||||
>
|
||||
<Ionicons name="play-skip-forward" size={28} color={TEXT.PRIMARY} />
|
||||
</Pressable>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: LAYOUT.CONTROLS_GAP,
|
||||
},
|
||||
button: {
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: RADIUS['2XL'],
|
||||
backgroundColor: SURFACE.OVERLAY_MEDIUM,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
mainButton: {
|
||||
width: 72,
|
||||
height: 72,
|
||||
borderRadius: RADIUS['4XL'],
|
||||
backgroundColor: SURFACE.OVERLAY_STRONG,
|
||||
},
|
||||
pressed: {
|
||||
transform: [{ scale: 0.92 }],
|
||||
},
|
||||
})
|
||||
@@ -1,615 +0,0 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
Animated,
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
} from 'react-native'
|
||||
import { LinearGradient } from 'expo-linear-gradient'
|
||||
import { BlurView } from 'expo-blur'
|
||||
import { StatusBar } from 'expo-status-bar'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import {
|
||||
PHASE_GRADIENTS,
|
||||
ACCENT,
|
||||
BRAND,
|
||||
SURFACE,
|
||||
TEXT,
|
||||
BORDER,
|
||||
APP_GRADIENTS,
|
||||
GLASS,
|
||||
} from '@/src/shared/constants/colors'
|
||||
import { TYPOGRAPHY } from '@/src/shared/constants/typography'
|
||||
import { SHADOW, TEXT_SHADOW } from '@/src/shared/constants/shadows'
|
||||
import { RADIUS } from '@/src/shared/constants/borderRadius'
|
||||
import { LAYOUT, SPACING } from '@/src/shared/constants/spacing'
|
||||
import { SPRING, ANIMATION } from '@/src/shared/constants/animations'
|
||||
import { formatTime } from '@/src/shared/utils/formatTime'
|
||||
import type { TimerConfig, TimerPhase, TimerState } from '../types'
|
||||
import { TimerControls } from './TimerControls'
|
||||
|
||||
const PHASE_LABELS: Record<TimerPhase, string> = {
|
||||
IDLE: '',
|
||||
GET_READY: 'PRÉPARE-TOI',
|
||||
WORK: 'GO !',
|
||||
REST: 'REPOS',
|
||||
COMPLETE: 'TERMINÉ !',
|
||||
}
|
||||
|
||||
interface TimerDisplayProps {
|
||||
state: TimerState
|
||||
config: TimerConfig
|
||||
exerciseName: string
|
||||
nextExerciseName: string
|
||||
onStart: () => void
|
||||
onPause: () => void
|
||||
onResume: () => void
|
||||
onStop: () => void
|
||||
onSkip: () => void
|
||||
}
|
||||
|
||||
export function TimerDisplay({
|
||||
state,
|
||||
config,
|
||||
exerciseName,
|
||||
nextExerciseName,
|
||||
onStart,
|
||||
onPause,
|
||||
onResume,
|
||||
onStop,
|
||||
onSkip,
|
||||
}: TimerDisplayProps) {
|
||||
const insets = useSafeAreaInsets()
|
||||
const top = insets.top || 20
|
||||
const bottom = insets.bottom || 20
|
||||
|
||||
if (state.phase === 'IDLE') {
|
||||
return <IdleView config={config} onStart={onStart} top={top} bottom={bottom} />
|
||||
}
|
||||
|
||||
if (state.phase === 'COMPLETE') {
|
||||
return (
|
||||
<CompleteView
|
||||
totalElapsedSeconds={state.totalElapsedSeconds}
|
||||
totalRounds={state.totalRounds}
|
||||
onStop={onStop}
|
||||
top={top}
|
||||
bottom={bottom}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ActiveView
|
||||
state={state}
|
||||
exerciseName={exerciseName}
|
||||
nextExerciseName={nextExerciseName}
|
||||
onPause={onPause}
|
||||
onResume={onResume}
|
||||
onStop={onStop}
|
||||
onSkip={onSkip}
|
||||
top={top}
|
||||
bottom={bottom}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────
|
||||
// IDLE — Start screen
|
||||
// ──────────────────────────────────────────────────────
|
||||
|
||||
function IdleView({
|
||||
config,
|
||||
onStart,
|
||||
top,
|
||||
bottom,
|
||||
}: {
|
||||
config: TimerConfig
|
||||
onStart: () => void
|
||||
top: number
|
||||
bottom: number
|
||||
}) {
|
||||
const glowAnim = useRef(new Animated.Value(0)).current
|
||||
|
||||
useEffect(() => {
|
||||
Animated.loop(
|
||||
Animated.sequence([
|
||||
Animated.timing(glowAnim, {
|
||||
toValue: 1,
|
||||
...ANIMATION.BREATH_HALF,
|
||||
}),
|
||||
Animated.timing(glowAnim, {
|
||||
toValue: 0,
|
||||
...ANIMATION.BREATH_HALF,
|
||||
}),
|
||||
])
|
||||
).start()
|
||||
}, [])
|
||||
|
||||
const glowOpacity = glowAnim.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: [0.15, 0.4],
|
||||
})
|
||||
|
||||
const glowScale = glowAnim.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: [1, 1.12],
|
||||
})
|
||||
|
||||
return (
|
||||
<LinearGradient
|
||||
colors={APP_GRADIENTS.HOME}
|
||||
style={[styles.screen, { paddingTop: top, paddingBottom: bottom }]}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
>
|
||||
<StatusBar style="light" />
|
||||
|
||||
<View style={styles.idleContent}>
|
||||
<Text style={styles.idleTitle}>TABATA</Text>
|
||||
<Text style={styles.idleSubtitle}>GO</Text>
|
||||
|
||||
<View style={styles.configSummary}>
|
||||
<View style={styles.configRow}>
|
||||
<ConfigBadge label={`${config.workDuration}s`} sub="travail" />
|
||||
<ConfigBadge label={`${config.restDuration}s`} sub="repos" />
|
||||
<ConfigBadge label={`${config.rounds}`} sub="rounds" />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.startButtonContainer}>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.startButtonGlow,
|
||||
{ opacity: glowOpacity, transform: [{ scale: glowScale }] },
|
||||
]}
|
||||
/>
|
||||
<Pressable
|
||||
style={({ pressed }) => [
|
||||
styles.startButton,
|
||||
pressed && styles.buttonPressed,
|
||||
]}
|
||||
onPress={onStart}
|
||||
>
|
||||
<Text style={styles.startButtonText}>START</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
</LinearGradient>
|
||||
)
|
||||
}
|
||||
|
||||
function ConfigBadge({ label, sub }: { label: string; sub: string }) {
|
||||
return (
|
||||
<View style={styles.configBadge}>
|
||||
<Text style={styles.configBadgeLabel}>{label}</Text>
|
||||
<Text style={styles.configBadgeSub}>{sub}</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────
|
||||
// COMPLETE — Victory screen
|
||||
// ──────────────────────────────────────────────────────
|
||||
|
||||
function CompleteView({
|
||||
totalElapsedSeconds,
|
||||
totalRounds,
|
||||
onStop,
|
||||
top,
|
||||
bottom,
|
||||
}: {
|
||||
totalElapsedSeconds: number
|
||||
totalRounds: number
|
||||
onStop: () => void
|
||||
top: number
|
||||
bottom: number
|
||||
}) {
|
||||
const scaleAnim = useRef(new Animated.Value(0.5)).current
|
||||
const opacityAnim = useRef(new Animated.Value(0)).current
|
||||
|
||||
useEffect(() => {
|
||||
Animated.parallel([
|
||||
Animated.spring(scaleAnim, {
|
||||
toValue: 1,
|
||||
...SPRING.BOUNCY,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(opacityAnim, ANIMATION.FADE_IN),
|
||||
]).start()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<LinearGradient
|
||||
colors={PHASE_GRADIENTS.COMPLETE}
|
||||
style={[styles.screen, { paddingTop: top, paddingBottom: bottom }]}
|
||||
>
|
||||
<StatusBar style="light" />
|
||||
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.completeContent,
|
||||
{ opacity: opacityAnim, transform: [{ scale: scaleAnim }] },
|
||||
]}
|
||||
>
|
||||
<Text style={styles.completeEmoji}>🔥</Text>
|
||||
<Text style={styles.completeTitle}>TERMINÉ !</Text>
|
||||
<Text style={styles.completeTime}>{formatTime(totalElapsedSeconds)}</Text>
|
||||
<Text style={styles.completeRounds}>
|
||||
{totalRounds} rounds complétés
|
||||
</Text>
|
||||
|
||||
<Pressable
|
||||
style={({ pressed }) => [
|
||||
styles.doneButton,
|
||||
pressed && styles.buttonPressed,
|
||||
]}
|
||||
onPress={onStop}
|
||||
>
|
||||
<Text style={styles.doneButtonText}>Terminer</Text>
|
||||
</Pressable>
|
||||
</Animated.View>
|
||||
</LinearGradient>
|
||||
)
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────
|
||||
// ACTIVE — Countdown with native blur toolbar
|
||||
// ──────────────────────────────────────────────────────
|
||||
|
||||
function ActiveView({
|
||||
state,
|
||||
exerciseName,
|
||||
nextExerciseName,
|
||||
onPause,
|
||||
onResume,
|
||||
onStop,
|
||||
onSkip,
|
||||
top,
|
||||
bottom,
|
||||
}: {
|
||||
state: TimerState
|
||||
exerciseName: string
|
||||
nextExerciseName: string
|
||||
onPause: () => void
|
||||
onResume: () => void
|
||||
onStop: () => void
|
||||
onSkip: () => void
|
||||
top: number
|
||||
bottom: number
|
||||
}) {
|
||||
const gradient = PHASE_GRADIENTS[state.phase] ?? PHASE_GRADIENTS.IDLE
|
||||
|
||||
// --- Gradient crossfade ---
|
||||
const [prevGradient, setPrevGradient] = useState<readonly string[]>(PHASE_GRADIENTS.IDLE)
|
||||
const fadeAnim = useRef(new Animated.Value(1)).current
|
||||
|
||||
useEffect(() => {
|
||||
fadeAnim.setValue(0)
|
||||
Animated.timing(fadeAnim, ANIMATION.GRADIENT_CROSSFADE).start(() => {
|
||||
setPrevGradient(gradient)
|
||||
})
|
||||
}, [state.phase])
|
||||
|
||||
// --- Countdown pulse ---
|
||||
const pulseAnim = useRef(new Animated.Value(1)).current
|
||||
|
||||
useEffect(() => {
|
||||
if (state.isRunning) {
|
||||
Animated.sequence([
|
||||
Animated.timing(pulseAnim, ANIMATION.PULSE_UP),
|
||||
Animated.timing(pulseAnim, ANIMATION.PULSE_DOWN),
|
||||
]).start()
|
||||
}
|
||||
}, [state.secondsLeft])
|
||||
|
||||
const isLastSeconds = state.secondsLeft <= 3 && state.secondsLeft > 0
|
||||
|
||||
const topLabel =
|
||||
state.phase === 'GET_READY'
|
||||
? exerciseName
|
||||
: state.phase === 'REST'
|
||||
? `Prochain : ${nextExerciseName}`
|
||||
: exerciseName
|
||||
|
||||
return (
|
||||
<View style={styles.screen}>
|
||||
<StatusBar hidden />
|
||||
|
||||
{/* Gradient layers */}
|
||||
<LinearGradient
|
||||
colors={prevGradient as [string, string, ...string[]]}
|
||||
style={StyleSheet.absoluteFill}
|
||||
/>
|
||||
<Animated.View style={[StyleSheet.absoluteFill, { opacity: fadeAnim }]}>
|
||||
<LinearGradient
|
||||
colors={gradient as unknown as [string, string, ...string[]]}
|
||||
style={StyleSheet.absoluteFill}
|
||||
/>
|
||||
</Animated.View>
|
||||
|
||||
{/* Content */}
|
||||
<View style={[styles.activeContent, { paddingTop: top }]}>
|
||||
{/* Top bar */}
|
||||
<View style={styles.topZone}>
|
||||
<Text style={styles.exerciseName} numberOfLines={1}>
|
||||
{topLabel}
|
||||
</Text>
|
||||
{state.currentRound > 0 && (
|
||||
<View style={styles.roundBadge}>
|
||||
<Text style={styles.roundBadgeText}>
|
||||
{state.currentRound}/{state.totalRounds}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Center — countdown */}
|
||||
<View style={styles.centerZone}>
|
||||
<Text style={styles.phaseLabel}>
|
||||
{PHASE_LABELS[state.phase]}
|
||||
</Text>
|
||||
|
||||
<Animated.Text
|
||||
style={[
|
||||
styles.countdown,
|
||||
{ transform: [{ scale: pulseAnim }] },
|
||||
isLastSeconds && styles.countdownFlash,
|
||||
]}
|
||||
>
|
||||
{state.secondsLeft}
|
||||
</Animated.Text>
|
||||
|
||||
{state.isPaused && (
|
||||
<View style={styles.pausedContainer}>
|
||||
<Text style={styles.pausedLabel}>EN PAUSE</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Bottom toolbar — native blur like iOS UIToolbar */}
|
||||
<BlurView
|
||||
intensity={GLASS.BLUR_HEAVY}
|
||||
tint="dark"
|
||||
style={[styles.bottomBar, { paddingBottom: bottom + 8 }]}
|
||||
>
|
||||
<View style={styles.roundDots}>
|
||||
{Array.from({ length: state.totalRounds }, (_, i) => (
|
||||
<View
|
||||
key={i}
|
||||
style={[
|
||||
styles.dot,
|
||||
i < state.currentRound && styles.dotFilled,
|
||||
i === state.currentRound - 1 &&
|
||||
state.phase === 'WORK' &&
|
||||
styles.dotActive,
|
||||
]}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<TimerControls
|
||||
isRunning={state.isRunning}
|
||||
isPaused={state.isPaused}
|
||||
onPause={onPause}
|
||||
onResume={onResume}
|
||||
onStop={onStop}
|
||||
onSkip={onSkip}
|
||||
/>
|
||||
</BlurView>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────
|
||||
// Styles
|
||||
// ──────────────────────────────────────────────────────
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
screen: {
|
||||
flex: 1,
|
||||
},
|
||||
|
||||
// --- IDLE ---
|
||||
idleContent: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
idleTitle: {
|
||||
...TYPOGRAPHY.brandTitle,
|
||||
color: ACCENT.ORANGE,
|
||||
...TEXT_SHADOW.BRAND,
|
||||
},
|
||||
idleSubtitle: {
|
||||
...TYPOGRAPHY.displaySmall,
|
||||
color: ACCENT.WHITE,
|
||||
marginTop: -6,
|
||||
},
|
||||
configSummary: {
|
||||
marginTop: SPACING[10],
|
||||
},
|
||||
configRow: {
|
||||
flexDirection: 'row',
|
||||
gap: SPACING[3],
|
||||
},
|
||||
configBadge: {
|
||||
backgroundColor: SURFACE.OVERLAY_LIGHT,
|
||||
borderRadius: RADIUS.LG,
|
||||
paddingVertical: SPACING[3],
|
||||
paddingHorizontal: SPACING[5],
|
||||
alignItems: 'center',
|
||||
borderWidth: 1,
|
||||
borderColor: BORDER.LIGHT,
|
||||
},
|
||||
configBadgeLabel: {
|
||||
...TYPOGRAPHY.heading,
|
||||
color: ACCENT.WHITE,
|
||||
},
|
||||
configBadgeSub: {
|
||||
...TYPOGRAPHY.overline,
|
||||
color: TEXT.MUTED,
|
||||
marginTop: 2,
|
||||
},
|
||||
startButtonContainer: {
|
||||
marginTop: SPACING[14],
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
startButtonGlow: {
|
||||
position: 'absolute',
|
||||
width: 172,
|
||||
height: 172,
|
||||
borderRadius: 86,
|
||||
backgroundColor: ACCENT.ORANGE,
|
||||
},
|
||||
startButton: {
|
||||
width: 150,
|
||||
height: 150,
|
||||
borderRadius: 75,
|
||||
backgroundColor: ACCENT.ORANGE,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
...SHADOW.BRAND_GLOW,
|
||||
},
|
||||
buttonPressed: {
|
||||
transform: [{ scale: 0.95 }],
|
||||
},
|
||||
startButtonText: {
|
||||
...TYPOGRAPHY.buttonHero,
|
||||
color: ACCENT.WHITE,
|
||||
},
|
||||
|
||||
// --- COMPLETE ---
|
||||
completeContent: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: SPACING[2],
|
||||
},
|
||||
completeEmoji: {
|
||||
fontSize: 72,
|
||||
marginBottom: SPACING[2],
|
||||
},
|
||||
completeTitle: {
|
||||
...TYPOGRAPHY.displayLarge,
|
||||
color: ACCENT.WHITE,
|
||||
},
|
||||
completeTime: {
|
||||
...TYPOGRAPHY.timeDisplay,
|
||||
color: ACCENT.WHITE,
|
||||
...TEXT_SHADOW.WHITE_MEDIUM,
|
||||
},
|
||||
completeRounds: {
|
||||
...TYPOGRAPHY.body,
|
||||
color: TEXT.TERTIARY,
|
||||
fontWeight: '600',
|
||||
},
|
||||
doneButton: {
|
||||
marginTop: SPACING[10],
|
||||
paddingHorizontal: SPACING[12],
|
||||
paddingVertical: SPACING[4],
|
||||
borderRadius: RADIUS['3XL'],
|
||||
backgroundColor: SURFACE.OVERLAY_MEDIUM,
|
||||
borderWidth: 1,
|
||||
borderColor: BORDER.STRONG,
|
||||
},
|
||||
doneButtonText: {
|
||||
...TYPOGRAPHY.buttonMedium,
|
||||
color: ACCENT.WHITE,
|
||||
},
|
||||
|
||||
// --- ACTIVE ---
|
||||
activeContent: {
|
||||
flex: 1,
|
||||
},
|
||||
topZone: {
|
||||
paddingHorizontal: LAYOUT.PAGE_HORIZONTAL,
|
||||
paddingTop: SPACING[3],
|
||||
paddingBottom: SPACING[2],
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
},
|
||||
exerciseName: {
|
||||
...TYPOGRAPHY.body,
|
||||
color: TEXT.SECONDARY,
|
||||
flex: 1,
|
||||
},
|
||||
roundBadge: {
|
||||
backgroundColor: SURFACE.OVERLAY_MEDIUM,
|
||||
borderRadius: RADIUS.MD,
|
||||
paddingVertical: SPACING[1.5],
|
||||
paddingHorizontal: SPACING[3.5],
|
||||
marginLeft: SPACING[3],
|
||||
},
|
||||
roundBadgeText: {
|
||||
...TYPOGRAPHY.label,
|
||||
color: ACCENT.WHITE,
|
||||
},
|
||||
|
||||
centerZone: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
phaseLabel: {
|
||||
...TYPOGRAPHY.heading,
|
||||
color: TEXT.TERTIARY,
|
||||
letterSpacing: 6,
|
||||
marginBottom: SPACING[1],
|
||||
},
|
||||
countdown: {
|
||||
...TYPOGRAPHY.countdown,
|
||||
color: ACCENT.WHITE,
|
||||
...TEXT_SHADOW.WHITE_SOFT,
|
||||
},
|
||||
countdownFlash: {
|
||||
color: ACCENT.RED_HOT,
|
||||
...TEXT_SHADOW.DANGER,
|
||||
},
|
||||
pausedContainer: {
|
||||
marginTop: SPACING[4],
|
||||
paddingVertical: SPACING[2],
|
||||
paddingHorizontal: SPACING[6],
|
||||
borderRadius: RADIUS.XL,
|
||||
backgroundColor: SURFACE.SCRIM,
|
||||
},
|
||||
pausedLabel: {
|
||||
...TYPOGRAPHY.caption,
|
||||
fontWeight: '800',
|
||||
color: TEXT.MUTED,
|
||||
letterSpacing: 6,
|
||||
},
|
||||
|
||||
// --- Bottom toolbar (native blur) ---
|
||||
bottomBar: {
|
||||
borderTopWidth: StyleSheet.hairlineWidth,
|
||||
borderTopColor: BORDER.MEDIUM,
|
||||
paddingTop: SPACING[4],
|
||||
gap: SPACING[5],
|
||||
},
|
||||
roundDots: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
gap: SPACING[2.5],
|
||||
},
|
||||
dot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: RADIUS.XS,
|
||||
backgroundColor: SURFACE.OVERLAY_MEDIUM,
|
||||
},
|
||||
dotFilled: {
|
||||
backgroundColor: TEXT.SECONDARY,
|
||||
},
|
||||
dotActive: {
|
||||
backgroundColor: ACCENT.WHITE,
|
||||
width: 12,
|
||||
height: 12,
|
||||
borderRadius: SPACING[1.5],
|
||||
...SHADOW.WHITE_GLOW,
|
||||
},
|
||||
})
|
||||
@@ -1,380 +0,0 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { AppState, type AppStateStatus } from 'react-native'
|
||||
import {
|
||||
activateKeepAwakeAsync,
|
||||
deactivateKeepAwake,
|
||||
} from 'expo-keep-awake'
|
||||
import { TIMER_DEFAULTS, TICK_INTERVAL_MS } from '@/src/shared/constants/timer'
|
||||
import type {
|
||||
TimerConfig,
|
||||
TimerEngine,
|
||||
TimerEvent,
|
||||
TimerEventListener,
|
||||
TimerPhase,
|
||||
} from '../types'
|
||||
|
||||
const VALID_TRANSITIONS: Record<TimerPhase, TimerPhase[]> = {
|
||||
IDLE: ['GET_READY'],
|
||||
GET_READY: ['WORK', 'IDLE'],
|
||||
WORK: ['REST', 'IDLE'],
|
||||
REST: ['WORK', 'COMPLETE', 'IDLE'],
|
||||
COMPLETE: ['IDLE'],
|
||||
}
|
||||
|
||||
function canTransition(from: TimerPhase, to: TimerPhase): boolean {
|
||||
return VALID_TRANSITIONS[from].includes(to)
|
||||
}
|
||||
|
||||
function buildDefaultConfig(): TimerConfig {
|
||||
return {
|
||||
workDuration: TIMER_DEFAULTS.WORK_DURATION,
|
||||
restDuration: TIMER_DEFAULTS.REST_DURATION,
|
||||
rounds: TIMER_DEFAULTS.ROUNDS,
|
||||
getReadyDuration: TIMER_DEFAULTS.GET_READY_DURATION,
|
||||
cycles: TIMER_DEFAULTS.CYCLES,
|
||||
cyclePauseDuration: TIMER_DEFAULTS.CYCLE_PAUSE_DURATION,
|
||||
}
|
||||
}
|
||||
|
||||
function getDurationForPhase(phase: TimerPhase, config: TimerConfig): number {
|
||||
switch (phase) {
|
||||
case 'GET_READY':
|
||||
return config.getReadyDuration
|
||||
case 'WORK':
|
||||
return config.workDuration
|
||||
case 'REST':
|
||||
return config.restDuration
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
export function useTimerEngine(): TimerEngine {
|
||||
// --- UI state (triggers re-renders) ---
|
||||
const [phase, setPhase] = useState<TimerPhase>('IDLE')
|
||||
const [secondsLeft, setSecondsLeft] = useState(0)
|
||||
const [currentRound, setCurrentRound] = useState(0)
|
||||
const [currentCycle, setCurrentCycle] = useState(0)
|
||||
const [isRunning, setIsRunning] = useState(false)
|
||||
const [isPaused, setIsPaused] = useState(false)
|
||||
const [totalElapsedSeconds, setTotalElapsedSeconds] = useState(0)
|
||||
const [config, setConfig] = useState<TimerConfig>(buildDefaultConfig)
|
||||
|
||||
// --- Refs for time-critical values (no stale closures) ---
|
||||
const targetEndTimeRef = useRef(0)
|
||||
const remainingMsRef = useRef(0)
|
||||
const tickRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const phaseRef = useRef<TimerPhase>('IDLE')
|
||||
const currentRoundRef = useRef(0)
|
||||
const currentCycleRef = useRef(0)
|
||||
const configRef = useRef<TimerConfig>(buildDefaultConfig())
|
||||
const isRunningRef = useRef(false)
|
||||
const isPausedRef = useRef(false)
|
||||
const elapsedRef = useRef(0)
|
||||
const lastTickTimeRef = useRef(0)
|
||||
const lastEmittedSecondRef = useRef(-1)
|
||||
const listenersRef = useRef<Set<TimerEventListener>>(new Set())
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
function emit(event: TimerEvent): void {
|
||||
listenersRef.current.forEach((listener) => {
|
||||
try {
|
||||
listener(event)
|
||||
} catch (e) {
|
||||
if (__DEV__) console.warn('[TimerEngine] Listener error:', e)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function clearTick(): void {
|
||||
if (tickRef.current !== null) {
|
||||
clearTimeout(tickRef.current)
|
||||
tickRef.current = null
|
||||
}
|
||||
}
|
||||
|
||||
function transitionTo(nextPhase: TimerPhase): void {
|
||||
const prevPhase = phaseRef.current
|
||||
if (!canTransition(prevPhase, nextPhase)) {
|
||||
if (__DEV__) {
|
||||
console.warn(
|
||||
`[TimerEngine] Invalid transition: ${prevPhase} → ${nextPhase}`
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (__DEV__) {
|
||||
console.log(`[TimerEngine] ${prevPhase} → ${nextPhase}`)
|
||||
}
|
||||
|
||||
phaseRef.current = nextPhase
|
||||
setPhase(nextPhase)
|
||||
|
||||
emit({ type: 'PHASE_CHANGED', from: prevPhase, to: nextPhase })
|
||||
|
||||
const duration = getDurationForPhase(nextPhase, configRef.current)
|
||||
if (duration > 0) {
|
||||
startCountdown(duration)
|
||||
} else {
|
||||
// Phase with 0 duration - immediately advance to next phase
|
||||
advancePhase()
|
||||
}
|
||||
}
|
||||
|
||||
function startCountdown(durationSeconds: number): void {
|
||||
clearTick()
|
||||
targetEndTimeRef.current = Date.now() + durationSeconds * 1000
|
||||
lastEmittedSecondRef.current = -1
|
||||
scheduleTick()
|
||||
}
|
||||
|
||||
function scheduleTick(): void {
|
||||
tickRef.current = setTimeout(tick, TICK_INTERVAL_MS)
|
||||
}
|
||||
|
||||
function tick(): void {
|
||||
const now = Date.now()
|
||||
|
||||
// Accumulate elapsed time
|
||||
if (lastTickTimeRef.current > 0) {
|
||||
const delta = (now - lastTickTimeRef.current) / 1000
|
||||
elapsedRef.current += delta
|
||||
setTotalElapsedSeconds(Math.floor(elapsedRef.current))
|
||||
}
|
||||
lastTickTimeRef.current = now
|
||||
|
||||
const remainingMs = Math.max(0, targetEndTimeRef.current - now)
|
||||
const seconds = Math.ceil(remainingMs / 1000)
|
||||
|
||||
setSecondsLeft(seconds)
|
||||
|
||||
// Emit COUNTDOWN_TICK for the last 3 seconds (once per second)
|
||||
if (seconds <= 3 && seconds > 0 && seconds !== lastEmittedSecondRef.current) {
|
||||
lastEmittedSecondRef.current = seconds
|
||||
emit({ type: 'COUNTDOWN_TICK', secondsLeft: seconds })
|
||||
}
|
||||
|
||||
if (remainingMs <= 0) {
|
||||
advancePhase()
|
||||
} else {
|
||||
scheduleTick()
|
||||
}
|
||||
}
|
||||
|
||||
function advancePhase(): void {
|
||||
clearTick()
|
||||
const current = phaseRef.current
|
||||
const cfg = configRef.current
|
||||
|
||||
switch (current) {
|
||||
case 'GET_READY':
|
||||
currentRoundRef.current = 1
|
||||
setCurrentRound(1)
|
||||
transitionTo('WORK')
|
||||
break
|
||||
|
||||
case 'WORK':
|
||||
transitionTo('REST')
|
||||
break
|
||||
|
||||
case 'REST': {
|
||||
const round = currentRoundRef.current
|
||||
emit({ type: 'ROUND_COMPLETED', round })
|
||||
|
||||
if (round < cfg.rounds) {
|
||||
// Next round
|
||||
currentRoundRef.current = round + 1
|
||||
setCurrentRound(round + 1)
|
||||
transitionTo('WORK')
|
||||
} else if (currentCycleRef.current < cfg.cycles) {
|
||||
// Next cycle (Premium, future) — for V1 cycles=1, so this is dead code
|
||||
currentCycleRef.current += 1
|
||||
setCurrentCycle(currentCycleRef.current)
|
||||
currentRoundRef.current = 1
|
||||
setCurrentRound(1)
|
||||
transitionTo('WORK')
|
||||
} else {
|
||||
// Session complete
|
||||
const totalSeconds = Math.floor(elapsedRef.current)
|
||||
setTotalElapsedSeconds(totalSeconds)
|
||||
emit({ type: 'SESSION_COMPLETE', totalSeconds })
|
||||
transitionTo('COMPLETE')
|
||||
setIsRunning(false)
|
||||
isRunningRef.current = false
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// --- Actions ---
|
||||
|
||||
const start = useCallback((overrides?: Partial<TimerConfig>) => {
|
||||
if (phaseRef.current !== 'IDLE') return
|
||||
|
||||
const cfg: TimerConfig = { ...buildDefaultConfig(), ...overrides }
|
||||
configRef.current = cfg
|
||||
setConfig(cfg)
|
||||
|
||||
// Reset all state
|
||||
currentRoundRef.current = 0
|
||||
currentCycleRef.current = 1
|
||||
elapsedRef.current = 0
|
||||
lastTickTimeRef.current = Date.now()
|
||||
lastEmittedSecondRef.current = -1
|
||||
|
||||
setCurrentRound(0)
|
||||
setCurrentCycle(1)
|
||||
setTotalElapsedSeconds(0)
|
||||
setIsRunning(true)
|
||||
setIsPaused(false)
|
||||
isRunningRef.current = true
|
||||
isPausedRef.current = false
|
||||
|
||||
transitionTo('GET_READY')
|
||||
}, [])
|
||||
|
||||
const pause = useCallback(() => {
|
||||
if (!isRunningRef.current || isPausedRef.current) return
|
||||
|
||||
clearTick()
|
||||
remainingMsRef.current = Math.max(0, targetEndTimeRef.current - Date.now())
|
||||
|
||||
isPausedRef.current = true
|
||||
setIsPaused(true)
|
||||
setIsRunning(false)
|
||||
isRunningRef.current = false
|
||||
|
||||
if (__DEV__) {
|
||||
console.log('[TimerEngine] Paused, remaining:', remainingMsRef.current, 'ms')
|
||||
}
|
||||
}, [])
|
||||
|
||||
const resume = useCallback(() => {
|
||||
if (!isPausedRef.current) return
|
||||
|
||||
targetEndTimeRef.current = Date.now() + remainingMsRef.current
|
||||
lastTickTimeRef.current = Date.now()
|
||||
|
||||
isPausedRef.current = false
|
||||
isRunningRef.current = true
|
||||
setIsPaused(false)
|
||||
setIsRunning(true)
|
||||
|
||||
scheduleTick()
|
||||
|
||||
if (__DEV__) {
|
||||
console.log('[TimerEngine] Resumed')
|
||||
}
|
||||
}, [])
|
||||
|
||||
const stop = useCallback(() => {
|
||||
if (phaseRef.current === 'IDLE') return
|
||||
|
||||
clearTick()
|
||||
|
||||
phaseRef.current = 'IDLE'
|
||||
isRunningRef.current = false
|
||||
isPausedRef.current = false
|
||||
lastTickTimeRef.current = 0
|
||||
|
||||
setPhase('IDLE')
|
||||
setSecondsLeft(0)
|
||||
setCurrentRound(0)
|
||||
setCurrentCycle(0)
|
||||
setIsRunning(false)
|
||||
setIsPaused(false)
|
||||
|
||||
if (__DEV__) {
|
||||
console.log('[TimerEngine] Stopped')
|
||||
}
|
||||
}, [])
|
||||
|
||||
const skip = useCallback(() => {
|
||||
if (phaseRef.current === 'IDLE' || phaseRef.current === 'COMPLETE') return
|
||||
if (!isRunningRef.current && !isPausedRef.current) return
|
||||
|
||||
// If paused, un-pause first
|
||||
if (isPausedRef.current) {
|
||||
isPausedRef.current = false
|
||||
isRunningRef.current = true
|
||||
setIsPaused(false)
|
||||
setIsRunning(true)
|
||||
}
|
||||
|
||||
clearTick()
|
||||
advancePhase()
|
||||
}, [])
|
||||
|
||||
const addEventListener = useCallback((listener: TimerEventListener) => {
|
||||
listenersRef.current.add(listener)
|
||||
return () => {
|
||||
listenersRef.current.delete(listener)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// --- AppState: auto-pause on interruption ---
|
||||
// iOS: active → inactive (phone call, control center) → background
|
||||
// Android: active → background (directly)
|
||||
// With auto-pause, we pause on any departure from 'active'.
|
||||
// User must manually resume — no reconcile needed.
|
||||
|
||||
useEffect(() => {
|
||||
const handleAppState = (nextState: AppStateStatus) => {
|
||||
if (nextState !== 'active') {
|
||||
if (isRunningRef.current && !isPausedRef.current) {
|
||||
pause()
|
||||
if (__DEV__) {
|
||||
console.log(`[TimerEngine] Auto-paused (${nextState})`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const subscription = AppState.addEventListener('change', handleAppState)
|
||||
return () => subscription.remove()
|
||||
}, [pause])
|
||||
|
||||
// --- Keep-awake ---
|
||||
|
||||
useEffect(() => {
|
||||
if (isRunning || isPaused) {
|
||||
activateKeepAwakeAsync('tabata-session')
|
||||
} else {
|
||||
deactivateKeepAwake('tabata-session')
|
||||
}
|
||||
}, [isRunning, isPaused])
|
||||
|
||||
// --- Cleanup on unmount ---
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearTick()
|
||||
deactivateKeepAwake('tabata-session')
|
||||
}
|
||||
}, [])
|
||||
|
||||
return {
|
||||
phase,
|
||||
secondsLeft,
|
||||
currentRound,
|
||||
totalRounds: config.rounds,
|
||||
currentCycle,
|
||||
totalCycles: config.cycles,
|
||||
isRunning,
|
||||
isPaused,
|
||||
totalElapsedSeconds,
|
||||
start,
|
||||
pause,
|
||||
resume,
|
||||
stop,
|
||||
skip,
|
||||
addEventListener,
|
||||
config,
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
export { useTimerEngine } from './hooks/useTimerEngine'
|
||||
export { TimerDisplay } from './components/TimerDisplay'
|
||||
export { TimerControls } from './components/TimerControls'
|
||||
export type {
|
||||
TimerPhase,
|
||||
TimerConfig,
|
||||
TimerState,
|
||||
TimerActions,
|
||||
TimerEvent,
|
||||
TimerEventListener,
|
||||
TimerEngine,
|
||||
} from './types'
|
||||
@@ -1,43 +0,0 @@
|
||||
export type TimerPhase = 'IDLE' | 'GET_READY' | 'WORK' | 'REST' | 'COMPLETE'
|
||||
|
||||
export interface TimerConfig {
|
||||
workDuration: number
|
||||
restDuration: number
|
||||
rounds: number
|
||||
getReadyDuration: number
|
||||
cycles: number
|
||||
cyclePauseDuration: number
|
||||
}
|
||||
|
||||
export interface TimerState {
|
||||
phase: TimerPhase
|
||||
secondsLeft: number
|
||||
currentRound: number
|
||||
totalRounds: number
|
||||
currentCycle: number
|
||||
totalCycles: number
|
||||
isRunning: boolean
|
||||
isPaused: boolean
|
||||
totalElapsedSeconds: number
|
||||
}
|
||||
|
||||
export interface TimerActions {
|
||||
start: (config?: Partial<TimerConfig>) => void
|
||||
pause: () => void
|
||||
resume: () => void
|
||||
stop: () => void
|
||||
skip: () => void
|
||||
}
|
||||
|
||||
export type TimerEvent =
|
||||
| { type: 'PHASE_CHANGED'; from: TimerPhase; to: TimerPhase }
|
||||
| { type: 'ROUND_COMPLETED'; round: number }
|
||||
| { type: 'SESSION_COMPLETE'; totalSeconds: number }
|
||||
| { type: 'COUNTDOWN_TICK'; secondsLeft: number }
|
||||
|
||||
export type TimerEventListener = (event: TimerEvent) => void
|
||||
|
||||
export interface TimerEngine extends TimerState, TimerActions {
|
||||
addEventListener: (listener: TimerEventListener) => () => void
|
||||
config: TimerConfig
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import { StyleSheet, View, type ViewProps } from 'react-native'
|
||||
import { BlurView } from 'expo-blur'
|
||||
import { GLASS } from '../constants/colors'
|
||||
|
||||
interface GlassViewProps extends ViewProps {
|
||||
intensity?: number
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export function GlassView({
|
||||
intensity = GLASS.BLUR_MEDIUM,
|
||||
style,
|
||||
children,
|
||||
...rest
|
||||
}: GlassViewProps) {
|
||||
return (
|
||||
<View style={[styles.container, style]} {...rest}>
|
||||
<BlurView intensity={intensity} tint="dark" style={StyleSheet.absoluteFill} />
|
||||
{children}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
overflow: 'hidden',
|
||||
backgroundColor: GLASS.FILL,
|
||||
borderWidth: 0.5,
|
||||
borderColor: GLASS.BORDER,
|
||||
borderTopColor: GLASS.BORDER_TOP,
|
||||
},
|
||||
})
|
||||
@@ -1,25 +0,0 @@
|
||||
import { Text, type TextProps } from 'react-native'
|
||||
import { TYPOGRAPHY } from '../constants/typography'
|
||||
import { TEXT } from '../constants/colors'
|
||||
|
||||
type TypographyVariant = keyof typeof TYPOGRAPHY
|
||||
|
||||
interface TypographyProps extends TextProps {
|
||||
variant: TypographyVariant
|
||||
color?: string
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export function Typography({
|
||||
variant,
|
||||
color = TEXT.PRIMARY,
|
||||
style,
|
||||
children,
|
||||
...rest
|
||||
}: TypographyProps) {
|
||||
return (
|
||||
<Text style={[TYPOGRAPHY[variant], { color }, style]} {...rest}>
|
||||
{children}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import type { TextStyle, ViewStyle } from 'react-native'
|
||||
|
||||
export const SHADOW = {
|
||||
BRAND_GLOW: {
|
||||
shadowColor: '#F97316',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.5,
|
||||
shadowRadius: 20,
|
||||
elevation: 12,
|
||||
} as ViewStyle,
|
||||
|
||||
WHITE_GLOW: {
|
||||
shadowColor: '#FFFFFF',
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: 0.8,
|
||||
shadowRadius: 6,
|
||||
} as ViewStyle,
|
||||
} as const
|
||||
|
||||
export const TEXT_SHADOW = {
|
||||
BRAND: {
|
||||
textShadowColor: 'rgba(249, 115, 22, 0.5)',
|
||||
textShadowOffset: { width: 0, height: 0 },
|
||||
textShadowRadius: 30,
|
||||
} as TextStyle,
|
||||
|
||||
WHITE_SOFT: {
|
||||
textShadowColor: 'rgba(255, 255, 255, 0.25)',
|
||||
textShadowOffset: { width: 0, height: 0 },
|
||||
textShadowRadius: 30,
|
||||
} as TextStyle,
|
||||
|
||||
WHITE_MEDIUM: {
|
||||
textShadowColor: 'rgba(255, 255, 255, 0.3)',
|
||||
textShadowOffset: { width: 0, height: 0 },
|
||||
textShadowRadius: 20,
|
||||
} as TextStyle,
|
||||
|
||||
DANGER: {
|
||||
textShadowColor: 'rgba(239, 68, 68, 0.6)',
|
||||
textShadowOffset: { width: 0, height: 0 },
|
||||
textShadowRadius: 40,
|
||||
} as TextStyle,
|
||||
} as const
|
||||
@@ -1,10 +0,0 @@
|
||||
export const TIMER_DEFAULTS = {
|
||||
WORK_DURATION: 20,
|
||||
REST_DURATION: 10,
|
||||
ROUNDS: 8,
|
||||
GET_READY_DURATION: 10,
|
||||
CYCLES: 1,
|
||||
CYCLE_PAUSE_DURATION: 60,
|
||||
} as const
|
||||
|
||||
export const TICK_INTERVAL_MS = 100
|
||||
@@ -1,8 +0,0 @@
|
||||
export function formatTime(totalSeconds: number): string {
|
||||
const mins = Math.floor(totalSeconds / 60)
|
||||
const secs = totalSeconds % 60
|
||||
if (mins > 0) {
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`
|
||||
}
|
||||
return `${secs}`
|
||||
}
|
||||
Reference in New Issue
Block a user