From 54ac8326fab1b5379d708082baa7bfb096f561f2 Mon Sep 17 00:00:00 2001 From: Millian Lamiaux Date: Fri, 20 Feb 2026 13:23:04 +0100 Subject: [PATCH] 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 --- TabataGo_PRD_v1.1.md | 597 ----------------- app/(tabs)/explore.tsx | 112 ---- app/modal.tsx | 29 - app/onboarding/index.tsx | 49 -- app/timer.tsx | 105 --- components/external-link.tsx | 25 - components/haptic-tab.tsx | 18 - components/hello-wave.tsx | 19 - components/parallax-scroll-view.tsx | 79 --- components/themed-text.tsx | 60 -- components/themed-view.tsx | 14 - components/ui/collapsible.tsx | 45 -- components/ui/icon-symbol.ios.tsx | 32 - components/ui/icon-symbol.tsx | 41 -- constants/theme.ts | 53 -- hooks/use-color-scheme.ts | 1 - hooks/use-color-scheme.web.ts | 21 - hooks/use-theme-color.ts | 21 - scripts/reset-project.js | 112 ---- src/features/audio/data/sounds.ts | 13 - src/features/audio/data/tracks.ts | 27 - src/features/audio/hooks/useAudioEngine.ts | 173 ----- src/features/audio/index.ts | 8 - src/features/audio/types.ts | 31 - .../onboarding/components/ChoiceButton.tsx | 100 --- .../onboarding/components/MiniTimerDemo.tsx | 178 ----- .../components/OnboardingScreen.tsx | 46 -- .../onboarding/components/PaywallCard.tsx | 158 ----- .../onboarding/components/PrimaryButton.tsx | 85 --- .../onboarding/components/ProgressBar.tsx | 89 --- src/features/onboarding/components/index.ts | 6 - src/features/onboarding/data/barriers.ts | 15 - src/features/onboarding/data/goals.ts | 15 - src/features/onboarding/data/index.ts | 5 - src/features/onboarding/data/levels.ts | 14 - .../onboarding/hooks/useOnboarding.ts | 137 ---- src/features/onboarding/index.ts | 26 - .../onboarding/screens/Screen1Problem.tsx | 165 ----- .../onboarding/screens/Screen2Empathy.tsx | 104 --- .../onboarding/screens/Screen3Solution.tsx | 251 ------- .../onboarding/screens/Screen4WowMoment.tsx | 149 ----- .../screens/Screen5Personalization.tsx | 186 ------ .../onboarding/screens/Screen6Paywall.tsx | 234 ------- src/features/onboarding/screens/index.ts | 2 - src/features/onboarding/types.ts | 20 - .../timer/components/TimerControls.tsx | 83 --- .../timer/components/TimerDisplay.tsx | 615 ------------------ src/features/timer/hooks/useTimerEngine.ts | 380 ----------- src/features/timer/index.ts | 12 - src/features/timer/types.ts | 43 -- src/shared/components/GlassView.tsx | 32 - src/shared/components/Typography.tsx | 25 - src/shared/constants/shadows.ts | 44 -- src/shared/constants/timer.ts | 10 - src/shared/utils/formatTime.ts | 8 - 55 files changed, 4922 deletions(-) delete mode 100644 TabataGo_PRD_v1.1.md delete mode 100644 app/(tabs)/explore.tsx delete mode 100644 app/modal.tsx delete mode 100644 app/onboarding/index.tsx delete mode 100644 app/timer.tsx delete mode 100644 components/external-link.tsx delete mode 100644 components/haptic-tab.tsx delete mode 100644 components/hello-wave.tsx delete mode 100644 components/parallax-scroll-view.tsx delete mode 100644 components/themed-text.tsx delete mode 100644 components/themed-view.tsx delete mode 100644 components/ui/collapsible.tsx delete mode 100644 components/ui/icon-symbol.ios.tsx delete mode 100644 components/ui/icon-symbol.tsx delete mode 100644 constants/theme.ts delete mode 100644 hooks/use-color-scheme.ts delete mode 100644 hooks/use-color-scheme.web.ts delete mode 100644 hooks/use-theme-color.ts delete mode 100755 scripts/reset-project.js delete mode 100644 src/features/audio/data/sounds.ts delete mode 100644 src/features/audio/data/tracks.ts delete mode 100644 src/features/audio/hooks/useAudioEngine.ts delete mode 100644 src/features/audio/index.ts delete mode 100644 src/features/audio/types.ts delete mode 100644 src/features/onboarding/components/ChoiceButton.tsx delete mode 100644 src/features/onboarding/components/MiniTimerDemo.tsx delete mode 100644 src/features/onboarding/components/OnboardingScreen.tsx delete mode 100644 src/features/onboarding/components/PaywallCard.tsx delete mode 100644 src/features/onboarding/components/PrimaryButton.tsx delete mode 100644 src/features/onboarding/components/ProgressBar.tsx delete mode 100644 src/features/onboarding/components/index.ts delete mode 100644 src/features/onboarding/data/barriers.ts delete mode 100644 src/features/onboarding/data/goals.ts delete mode 100644 src/features/onboarding/data/index.ts delete mode 100644 src/features/onboarding/data/levels.ts delete mode 100644 src/features/onboarding/hooks/useOnboarding.ts delete mode 100644 src/features/onboarding/index.ts delete mode 100644 src/features/onboarding/screens/Screen1Problem.tsx delete mode 100644 src/features/onboarding/screens/Screen2Empathy.tsx delete mode 100644 src/features/onboarding/screens/Screen3Solution.tsx delete mode 100644 src/features/onboarding/screens/Screen4WowMoment.tsx delete mode 100644 src/features/onboarding/screens/Screen5Personalization.tsx delete mode 100644 src/features/onboarding/screens/Screen6Paywall.tsx delete mode 100644 src/features/onboarding/screens/index.ts delete mode 100644 src/features/onboarding/types.ts delete mode 100644 src/features/timer/components/TimerControls.tsx delete mode 100644 src/features/timer/components/TimerDisplay.tsx delete mode 100644 src/features/timer/hooks/useTimerEngine.ts delete mode 100644 src/features/timer/index.ts delete mode 100644 src/features/timer/types.ts delete mode 100644 src/shared/components/GlassView.tsx delete mode 100644 src/shared/components/Typography.tsx delete mode 100644 src/shared/constants/shadows.ts delete mode 100644 src/shared/constants/timer.ts delete mode 100644 src/shared/utils/formatTime.ts diff --git a/TabataGo_PRD_v1.1.md b/TabataGo_PRD_v1.1.md deleted file mode 100644 index cebeb1a..0000000 --- a/TabataGo_PRD_v1.1.md +++ /dev/null @@ -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* \ No newline at end of file diff --git a/app/(tabs)/explore.tsx b/app/(tabs)/explore.tsx deleted file mode 100644 index 71518f9..0000000 --- a/app/(tabs)/explore.tsx +++ /dev/null @@ -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 ( - - }> - - - Explore - - - This app includes example code to help you get started. - - - This app has two screens:{' '} - app/(tabs)/index.tsx and{' '} - app/(tabs)/explore.tsx - - - The layout file in app/(tabs)/_layout.tsx{' '} - sets up the tab navigator. - - - Learn more - - - - - You can open this project on Android, iOS, and the web. To open the web version, press{' '} - w in the terminal running this project. - - - - - For static images, you can use the @2x and{' '} - @3x suffixes to provide files for - different screen densities - - - - Learn more - - - - - This template has light and dark mode support. The{' '} - useColorScheme() hook lets you inspect - what the user's current color scheme is, and so you can adjust UI colors accordingly. - - - Learn more - - - - - This template includes an example of an animated component. The{' '} - components/HelloWave.tsx component uses - the powerful{' '} - - react-native-reanimated - {' '} - library to create a waving hand animation. - - {Platform.select({ - ios: ( - - The components/ParallaxScrollView.tsx{' '} - component provides a parallax effect for the header image. - - ), - })} - - - ); -} - -const styles = StyleSheet.create({ - headerImage: { - color: '#808080', - bottom: -90, - left: -35, - position: 'absolute', - }, - titleContainer: { - flexDirection: 'row', - gap: 8, - }, -}); diff --git a/app/modal.tsx b/app/modal.tsx deleted file mode 100644 index 6dfbc1a..0000000 --- a/app/modal.tsx +++ /dev/null @@ -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 ( - - This is a modal - - Go to home screen - - - ); -} - -const styles = StyleSheet.create({ - container: { - flex: 1, - alignItems: 'center', - justifyContent: 'center', - padding: 20, - }, - link: { - marginTop: 15, - paddingVertical: 15, - }, -}); diff --git a/app/onboarding/index.tsx b/app/onboarding/index.tsx deleted file mode 100644 index 7cee91c..0000000 --- a/app/onboarding/index.tsx +++ /dev/null @@ -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 - } - - // Render the correct screen based on current step - switch (currentStep) { - case 0: - return - case 1: - return - case 2: - return - case 3: - return - case 4: - return - case 5: - return - default: - // Fallback to first screen if step is out of bounds - return - } -} diff --git a/app/timer.tsx b/app/timer.tsx deleted file mode 100644 index 771d2e1..0000000 --- a/app/timer.tsx +++ /dev/null @@ -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 ( - - ) -} diff --git a/components/external-link.tsx b/components/external-link.tsx deleted file mode 100644 index 883e515..0000000 --- a/components/external-link.tsx +++ /dev/null @@ -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, 'href'> & { href: Href & string }; - -export function ExternalLink({ href, ...rest }: Props) { - return ( - { - 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, - }); - } - }} - /> - ); -} diff --git a/components/haptic-tab.tsx b/components/haptic-tab.tsx deleted file mode 100644 index 7f3981c..0000000 --- a/components/haptic-tab.tsx +++ /dev/null @@ -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 ( - { - 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); - }} - /> - ); -} diff --git a/components/hello-wave.tsx b/components/hello-wave.tsx deleted file mode 100644 index 5def547..0000000 --- a/components/hello-wave.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import Animated from 'react-native-reanimated'; - -export function HelloWave() { - return ( - - 👋 - - ); -} diff --git a/components/parallax-scroll-view.tsx b/components/parallax-scroll-view.tsx deleted file mode 100644 index 6f674a7..0000000 --- a/components/parallax-scroll-view.tsx +++ /dev/null @@ -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(); - 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 ( - - - {headerImage} - - {children} - - ); -} - -const styles = StyleSheet.create({ - container: { - flex: 1, - }, - header: { - height: HEADER_HEIGHT, - overflow: 'hidden', - }, - content: { - flex: 1, - padding: 32, - gap: 16, - overflow: 'hidden', - }, -}); diff --git a/components/themed-text.tsx b/components/themed-text.tsx deleted file mode 100644 index d79d0a1..0000000 --- a/components/themed-text.tsx +++ /dev/null @@ -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 ( - - ); -} - -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', - }, -}); diff --git a/components/themed-view.tsx b/components/themed-view.tsx deleted file mode 100644 index 6f181d8..0000000 --- a/components/themed-view.tsx +++ /dev/null @@ -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 ; -} diff --git a/components/ui/collapsible.tsx b/components/ui/collapsible.tsx deleted file mode 100644 index 6345fde..0000000 --- a/components/ui/collapsible.tsx +++ /dev/null @@ -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 ( - - setIsOpen((value) => !value)} - activeOpacity={0.8}> - - - {title} - - {isOpen && {children}} - - ); -} - -const styles = StyleSheet.create({ - heading: { - flexDirection: 'row', - alignItems: 'center', - gap: 6, - }, - content: { - marginTop: 6, - marginLeft: 24, - }, -}); diff --git a/components/ui/icon-symbol.ios.tsx b/components/ui/icon-symbol.ios.tsx deleted file mode 100644 index 9177f4d..0000000 --- a/components/ui/icon-symbol.ios.tsx +++ /dev/null @@ -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; - weight?: SymbolWeight; -}) { - return ( - - ); -} diff --git a/components/ui/icon-symbol.tsx b/components/ui/icon-symbol.tsx deleted file mode 100644 index b7ece6b..0000000 --- a/components/ui/icon-symbol.tsx +++ /dev/null @@ -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['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; - weight?: SymbolWeight; -}) { - return ; -} diff --git a/constants/theme.ts b/constants/theme.ts deleted file mode 100644 index f06facd..0000000 --- a/constants/theme.ts +++ /dev/null @@ -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", - }, -}); diff --git a/hooks/use-color-scheme.ts b/hooks/use-color-scheme.ts deleted file mode 100644 index 17e3c63..0000000 --- a/hooks/use-color-scheme.ts +++ /dev/null @@ -1 +0,0 @@ -export { useColorScheme } from 'react-native'; diff --git a/hooks/use-color-scheme.web.ts b/hooks/use-color-scheme.web.ts deleted file mode 100644 index 7eb1c1b..0000000 --- a/hooks/use-color-scheme.web.ts +++ /dev/null @@ -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'; -} diff --git a/hooks/use-theme-color.ts b/hooks/use-theme-color.ts deleted file mode 100644 index 0cbc3a6..0000000 --- a/hooks/use-theme-color.ts +++ /dev/null @@ -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]; - } -} diff --git a/scripts/reset-project.js b/scripts/reset-project.js deleted file mode 100755 index 51dff15..0000000 --- a/scripts/reset-project.js +++ /dev/null @@ -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 ( - - Edit app/index.tsx to edit this screen. - - ); -} -`; - -const layoutContent = `import { Stack } from "expo-router"; - -export default function RootLayout() { - return ; -} -`; - -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(); - } - } -); diff --git a/src/features/audio/data/sounds.ts b/src/features/audio/data/sounds.ts deleted file mode 100644 index df5fced..0000000 --- a/src/features/audio/data/sounds.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { PhaseSound } from '../types' - -/* eslint-disable @typescript-eslint/no-require-imports */ -export const PHASE_SOUNDS: Record = { - 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'), -} diff --git a/src/features/audio/data/tracks.ts b/src/features/audio/data/tracks.ts deleted file mode 100644 index 2431840..0000000 --- a/src/features/audio/data/tracks.ts +++ /dev/null @@ -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 -} diff --git a/src/features/audio/hooks/useAudioEngine.ts b/src/features/audio/hooks/useAudioEngine.ts deleted file mode 100644 index e0f29c3..0000000 --- a/src/features/audio/hooks/useAudioEngine.ts +++ /dev/null @@ -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 { - return new Promise((resolve) => setTimeout(resolve, ms)) -} - -export function useAudioEngine(): AudioEngine { - const [isLoaded, setIsLoaded] = useState(false) - const soundsRef = useRef>({}) - const currentIntensityRef = useRef('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, - } -} diff --git a/src/features/audio/index.ts b/src/features/audio/index.ts deleted file mode 100644 index 5d596ce..0000000 --- a/src/features/audio/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -export { useAudioEngine } from './hooks/useAudioEngine' -export type { - MusicAmbiance, - MusicIntensity, - PhaseSound, - AudioSettings, - AudioEngine, -} from './types' diff --git a/src/features/audio/types.ts b/src/features/audio/types.ts deleted file mode 100644 index 677df8b..0000000 --- a/src/features/audio/types.ts +++ /dev/null @@ -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 - playPhaseSound: (sound: PhaseSound) => Promise - startMusic: (intensity: MusicIntensity) => Promise - switchIntensity: (intensity: MusicIntensity) => Promise - stopMusic: (fadeMs?: number) => Promise - unloadAll: () => Promise -} diff --git a/src/features/onboarding/components/ChoiceButton.tsx b/src/features/onboarding/components/ChoiceButton.tsx deleted file mode 100644 index 1502e2e..0000000 --- a/src/features/onboarding/components/ChoiceButton.tsx +++ /dev/null @@ -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 ( - - - - - - - - {label} - - {description && ( - {description} - )} - - {selected && ( - - - - )} - - - ) -} - -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], - }, -}) diff --git a/src/features/onboarding/components/MiniTimerDemo.tsx b/src/features/onboarding/components/MiniTimerDemo.tsx deleted file mode 100644 index 7649ebf..0000000 --- a/src/features/onboarding/components/MiniTimerDemo.tsx +++ /dev/null @@ -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 ( - - - - - {displaySeconds} - - - {getPhaseText()} - - - - ) -} - -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, - }, -}) diff --git a/src/features/onboarding/components/OnboardingScreen.tsx b/src/features/onboarding/components/OnboardingScreen.tsx deleted file mode 100644 index dc1e2c5..0000000 --- a/src/features/onboarding/components/OnboardingScreen.tsx +++ /dev/null @@ -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 ( - - - - {children} - - - ) -} - -const styles = StyleSheet.create({ - gradient: { - flex: 1, - }, - container: { - flex: 1, - }, - content: { - flex: 1, - paddingHorizontal: LAYOUT.PAGE_HORIZONTAL, - paddingBottom: SPACING[6], - }, -}) diff --git a/src/features/onboarding/components/PaywallCard.tsx b/src/features/onboarding/components/PaywallCard.tsx deleted file mode 100644 index 6553604..0000000 --- a/src/features/onboarding/components/PaywallCard.tsx +++ /dev/null @@ -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 ( - - - {badge && ( - - {badge} - - )} - - - {title} - - - {price} - /{period} - - - - - {features.map((feature, index) => ( - - - {feature} - - ))} - - {selected && ( - - - - )} - - - ) -} - -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, - }, -}) diff --git a/src/features/onboarding/components/PrimaryButton.tsx b/src/features/onboarding/components/PrimaryButton.tsx deleted file mode 100644 index 4353078..0000000 --- a/src/features/onboarding/components/PrimaryButton.tsx +++ /dev/null @@ -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 ( - - - - {title} - - - - ) -} - -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, - }, -}) diff --git a/src/features/onboarding/components/ProgressBar.tsx b/src/features/onboarding/components/ProgressBar.tsx deleted file mode 100644 index 4d3e0f7..0000000 --- a/src/features/onboarding/components/ProgressBar.tsx +++ /dev/null @@ -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 ( - - {Array.from({ length: totalSteps }).map((_, index) => { - const isActive = index === currentStep - const isCompleted = index < currentStep - - return ( - - ) - })} - - ) -} - -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, - }, -}) diff --git a/src/features/onboarding/components/index.ts b/src/features/onboarding/components/index.ts deleted file mode 100644 index e5fae39..0000000 --- a/src/features/onboarding/components/index.ts +++ /dev/null @@ -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' diff --git a/src/features/onboarding/data/barriers.ts b/src/features/onboarding/data/barriers.ts deleted file mode 100644 index 8bc33c4..0000000 --- a/src/features/onboarding/data/barriers.ts +++ /dev/null @@ -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' }, -] diff --git a/src/features/onboarding/data/goals.ts b/src/features/onboarding/data/goals.ts deleted file mode 100644 index 8664f27..0000000 --- a/src/features/onboarding/data/goals.ts +++ /dev/null @@ -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' }, -] diff --git a/src/features/onboarding/data/index.ts b/src/features/onboarding/data/index.ts deleted file mode 100644 index 6612251..0000000 --- a/src/features/onboarding/data/index.ts +++ /dev/null @@ -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' diff --git a/src/features/onboarding/data/levels.ts b/src/features/onboarding/data/levels.ts deleted file mode 100644 index 8a3e687..0000000 --- a/src/features/onboarding/data/levels.ts +++ /dev/null @@ -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' }, -] diff --git a/src/features/onboarding/hooks/useOnboarding.ts b/src/features/onboarding/hooks/useOnboarding.ts deleted file mode 100644 index b5a8b57..0000000 --- a/src/features/onboarding/hooks/useOnboarding.ts +++ /dev/null @@ -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 => { - return await AsyncStorage.getItem(name) - }, - setItem: async (name: string, value: string): Promise => { - await AsyncStorage.setItem(name, value) - }, - removeItem: async (name: string): Promise => { - 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) => 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()( - 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) => { - 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) diff --git a/src/features/onboarding/index.ts b/src/features/onboarding/index.ts deleted file mode 100644 index 5feec6b..0000000 --- a/src/features/onboarding/index.ts +++ /dev/null @@ -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' diff --git a/src/features/onboarding/screens/Screen1Problem.tsx b/src/features/onboarding/screens/Screen1Problem.tsx deleted file mode 100644 index 530c458..0000000 --- a/src/features/onboarding/screens/Screen1Problem.tsx +++ /dev/null @@ -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 ( - - - {/* Clock Icon Animation */} - - - - - - - - - - - - {/* Title and Subtitle */} - - Tu n'as pas 1 heure pour t'entrainer ? - Ni 30 minutes ? Ni meme 10 ? - - - {/* Spacer */} - - - {/* Continue Button */} - - - - ) -} - -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, - }, -}) diff --git a/src/features/onboarding/screens/Screen2Empathy.tsx b/src/features/onboarding/screens/Screen2Empathy.tsx deleted file mode 100644 index 755125b..0000000 --- a/src/features/onboarding/screens/Screen2Empathy.tsx +++ /dev/null @@ -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(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 ( - - - {/* Title */} - - - Qu'est-ce qui t'empeche de t'entrainer ? - - - - {/* Choice Buttons */} - - {BARRIERS.map((barrier, index) => ( - - handleBarrierSelect(barrier.id)} - /> - - ))} - - - - ) -} - -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%', - }, -}) diff --git a/src/features/onboarding/screens/Screen3Solution.tsx b/src/features/onboarding/screens/Screen3Solution.tsx deleted file mode 100644 index 49b8c1e..0000000 --- a/src/features/onboarding/screens/Screen3Solution.tsx +++ /dev/null @@ -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 ( - - {Array.from({ length: TABATA_ROUNDS }, (_, index) => { - const isWork = index % 2 === 0 - const baseColor = isWork ? PHASE_COLORS.WORK : PHASE_COLORS.REST - - return ( - - - {isWork ? WORK_DURATION : REST_DURATION}s - - - ) - })} - - ) - } - - return ( - - - {/* Animated Title */} - - 4 minutes - - - {/* Subtitle */} - Vraiment transformatrices. - - {/* Tabata Timeline Animation */} - - {renderTabataTimeline()} - - {/* Legend */} - - - - 20s travail - - - - 10s repos - - x 8 rounds - - - - {/* Scientific Explanation */} - - - Protocole scientifique HIIT = resultats max en temps min - - - - {/* Spacer */} - - - {/* Continue Button */} - - - - ) -} - -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, - }, -}) diff --git a/src/features/onboarding/screens/Screen4WowMoment.tsx b/src/features/onboarding/screens/Screen4WowMoment.tsx deleted file mode 100644 index 0e20ca0..0000000 --- a/src/features/onboarding/screens/Screen4WowMoment.tsx +++ /dev/null @@ -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('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 ( - - - {/* Title Section */} - - Essaie maintenant - 20 secondes. Juste pour sentir. - - - {/* Timer Demo - The "Wow" Moment */} - - - - - {/* Instruction Text */} - - {getInstructionText()} - - - {/* Continue Button - Only visible after completion */} - - {isComplete ? ( - - - - ) : ( - - )} - - - - ) -} - -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, - }, -}) diff --git a/src/features/onboarding/screens/Screen5Personalization.tsx b/src/features/onboarding/screens/Screen5Personalization.tsx deleted file mode 100644 index 1fb7ff1..0000000 --- a/src/features/onboarding/screens/Screen5Personalization.tsx +++ /dev/null @@ -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(data.level) - const [selectedGoal, setSelectedGoal] = useState(data.goal) - const [selectedFrequency, setSelectedFrequency] = useState(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 ( - - - - Personnalise ton experience - - Dis-nous en plus sur toi pour un programme sur mesure - - - - {/* Level Section */} - - - - Niveau - - - {LEVELS.map((level) => ( - handleLevelSelect(level.id)} - /> - ))} - - - - {/* Goal Section */} - - - - Objectif - - - {GOALS.map((goal) => ( - handleGoalSelect(goal.id)} - /> - ))} - - - - {/* Frequency Section */} - - - - Frequence - - - {FREQUENCIES.map((freq) => ( - handleFrequencySelect(freq.id)} - /> - ))} - - - - - - - - - ) -} - -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], - }, -}) diff --git a/src/features/onboarding/screens/Screen6Paywall.tsx b/src/features/onboarding/screens/Screen6Paywall.tsx deleted file mode 100644 index a2b5bc9..0000000 --- a/src/features/onboarding/screens/Screen6Paywall.tsx +++ /dev/null @@ -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 ( - - - - Debloque ton potentiel - - 7 jours gratuits, annule quand tu veux - - - - - {PLANS.map((plan) => ( - handlePlanSelect(plan.id)} - /> - ))} - - - - - Garantie satisfait ou rembourse sous 30 jours - - - - - - - - {isLoading ? 'Traitement...' : selectedPlan === 'trial' ? 'Essayer gratuitement' : "S'abonner"} - - - - - - Continuer sans abonnement - - - - - ) -} - -// Mock RevenueCat purchase function for dev mode -async function mockPurchase(planId: string): Promise { - // 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', - }, -}) diff --git a/src/features/onboarding/screens/index.ts b/src/features/onboarding/screens/index.ts deleted file mode 100644 index cdb24e2..0000000 --- a/src/features/onboarding/screens/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { Screen5Personalization } from './Screen5Personalization' -export { Screen6Paywall } from './Screen6Paywall' diff --git a/src/features/onboarding/types.ts b/src/features/onboarding/types.ts deleted file mode 100644 index 80c73a2..0000000 --- a/src/features/onboarding/types.ts +++ /dev/null @@ -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 -} diff --git a/src/features/timer/components/TimerControls.tsx b/src/features/timer/components/TimerControls.tsx deleted file mode 100644 index 46448a2..0000000 --- a/src/features/timer/components/TimerControls.tsx +++ /dev/null @@ -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 ( - - [styles.button, pressed && styles.pressed]} - onPress={onStop} - > - - - - [ - styles.button, - styles.mainButton, - pressed && styles.pressed, - ]} - onPress={isPaused ? onResume : onPause} - > - - - - [styles.button, pressed && styles.pressed]} - onPress={onSkip} - > - - - - ) -} - -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 }], - }, -}) diff --git a/src/features/timer/components/TimerDisplay.tsx b/src/features/timer/components/TimerDisplay.tsx deleted file mode 100644 index 09a6b12..0000000 --- a/src/features/timer/components/TimerDisplay.tsx +++ /dev/null @@ -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 = { - 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 - } - - if (state.phase === 'COMPLETE') { - return ( - - ) - } - - return ( - - ) -} - -// ────────────────────────────────────────────────────── -// 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 ( - - - - - TABATA - GO - - - - - - - - - - - - [ - styles.startButton, - pressed && styles.buttonPressed, - ]} - onPress={onStart} - > - START - - - - - ) -} - -function ConfigBadge({ label, sub }: { label: string; sub: string }) { - return ( - - {label} - {sub} - - ) -} - -// ────────────────────────────────────────────────────── -// 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 ( - - - - - 🔥 - TERMINÉ ! - {formatTime(totalElapsedSeconds)} - - {totalRounds} rounds complĂ©tĂ©s - - - [ - styles.doneButton, - pressed && styles.buttonPressed, - ]} - onPress={onStop} - > - Terminer - - - - ) -} - -// ────────────────────────────────────────────────────── -// 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(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 ( - - - ) -} - -// ────────────────────────────────────────────────────── -// 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, - }, -}) diff --git a/src/features/timer/hooks/useTimerEngine.ts b/src/features/timer/hooks/useTimerEngine.ts deleted file mode 100644 index 933d135..0000000 --- a/src/features/timer/hooks/useTimerEngine.ts +++ /dev/null @@ -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 = { - 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('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(buildDefaultConfig) - - // --- Refs for time-critical values (no stale closures) --- - const targetEndTimeRef = useRef(0) - const remainingMsRef = useRef(0) - const tickRef = useRef | null>(null) - const phaseRef = useRef('IDLE') - const currentRoundRef = useRef(0) - const currentCycleRef = useRef(0) - const configRef = useRef(buildDefaultConfig()) - const isRunningRef = useRef(false) - const isPausedRef = useRef(false) - const elapsedRef = useRef(0) - const lastTickTimeRef = useRef(0) - const lastEmittedSecondRef = useRef(-1) - const listenersRef = useRef>(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) => { - 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, - } -} diff --git a/src/features/timer/index.ts b/src/features/timer/index.ts deleted file mode 100644 index 138f7db..0000000 --- a/src/features/timer/index.ts +++ /dev/null @@ -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' diff --git a/src/features/timer/types.ts b/src/features/timer/types.ts deleted file mode 100644 index 1cfa0ae..0000000 --- a/src/features/timer/types.ts +++ /dev/null @@ -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) => 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 -} diff --git a/src/shared/components/GlassView.tsx b/src/shared/components/GlassView.tsx deleted file mode 100644 index 5341336..0000000 --- a/src/shared/components/GlassView.tsx +++ /dev/null @@ -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 ( - - - {children} - - ) -} - -const styles = StyleSheet.create({ - container: { - overflow: 'hidden', - backgroundColor: GLASS.FILL, - borderWidth: 0.5, - borderColor: GLASS.BORDER, - borderTopColor: GLASS.BORDER_TOP, - }, -}) diff --git a/src/shared/components/Typography.tsx b/src/shared/components/Typography.tsx deleted file mode 100644 index 4f5d1b6..0000000 --- a/src/shared/components/Typography.tsx +++ /dev/null @@ -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 ( - - {children} - - ) -} diff --git a/src/shared/constants/shadows.ts b/src/shared/constants/shadows.ts deleted file mode 100644 index f9fd811..0000000 --- a/src/shared/constants/shadows.ts +++ /dev/null @@ -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 diff --git a/src/shared/constants/timer.ts b/src/shared/constants/timer.ts deleted file mode 100644 index cb8658b..0000000 --- a/src/shared/constants/timer.ts +++ /dev/null @@ -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 diff --git a/src/shared/utils/formatTime.ts b/src/shared/utils/formatTime.ts deleted file mode 100644 index 58ae655..0000000 --- a/src/shared/utils/formatTime.ts +++ /dev/null @@ -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}` -}