feat: Apple Watch app + Paywall + Privacy Policy + rebranding

## Major Features
- Apple Watch companion app (6 phases complete)
  - WatchConnectivity iPhone ↔ Watch
  - HealthKit integration (HR, calories)
  - SwiftUI premium UI
  - 9 complication types
  - Always-On Display support

- Paywall screen with RevenueCat integration
- Privacy Policy screen
- App rebranding: tabatago → TabataFit
- Bundle ID: com.millianlmx.tabatafit

## Changes
- New: ios/TabataFit Watch App/ (complete Watch app)
- New: app/paywall.tsx (subscription UI)
- New: app/privacy.tsx (privacy policy)
- New: src/features/watch/ (Watch sync hooks)
- New: admin-web/ (admin dashboard)
- Updated: app.json, package.json (branding)
- Updated: profile.tsx (paywall + privacy links)
- Updated: i18n translations (EN/FR/DE/ES)
- New: app icon 1024x1024

## Watch App Files
- TabataFitWatchApp.swift (entry point)
- ContentView.swift (premium UI)
- HealthKitManager.swift (HR + calories)
- WatchSessionManager.swift (communication)
- Complications/ (WidgetKit)
- UserDefaults+Shared.swift (data sharing)
This commit is contained in:
Millian Lamiaux
2026-03-11 09:43:53 +01:00
parent f80798069b
commit 2ad7ae3a34
86 changed files with 19648 additions and 365 deletions

View File

@@ -1,22 +1,22 @@
/**
* TabataFit Pre-Workout Detail Screen
* Dynamic data via route params
* Clean modal with workout info
*/
import { useState, useEffect, useMemo } from 'react'
import { View, Text as RNText, StyleSheet, ScrollView, Pressable } from 'react-native'
import { useRouter, useLocalSearchParams } from 'expo-router'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { LinearGradient } from 'expo-linear-gradient'
import { BlurView } from 'expo-blur'
import Ionicons from '@expo/vector-icons/Ionicons'
import { useTranslation } from 'react-i18next'
import { Host, Button, HStack } from '@expo/ui/swift-ui'
import { glassEffect, padding } from '@expo/ui/swift-ui/modifiers'
import { useHaptics } from '@/src/shared/hooks'
import { track } from '@/src/shared/services/analytics'
import { getWorkoutById } from '@/src/shared/data'
import { useTranslatedWorkout, useMusicVibeLabel } from '@/src/shared/data/useTranslatedData'
import { VideoPlayer } from '@/src/shared/components/VideoPlayer'
import { useThemeColors, BRAND } from '@/src/shared/theme'
import type { ThemeColors } from '@/src/shared/theme/types'
@@ -56,7 +56,7 @@ export default function WorkoutDetailScreen() {
if (!workout) {
return (
<View style={[styles.container, { paddingTop: insets.top, alignItems: 'center', justifyContent: 'center' }]}>
<View style={[styles.container, styles.centered]}>
<RNText style={{ color: colors.text.primary, fontSize: 17 }}>{t('screens:workout.notFound')}</RNText>
</View>
)
@@ -67,11 +67,6 @@ export default function WorkoutDetailScreen() {
router.push(`/player/${workout.id}`)
}
const handleGoBack = () => {
haptics.selection()
router.back()
}
const toggleSave = () => {
haptics.selection()
setIsSaved(!isSaved)
@@ -80,81 +75,59 @@ export default function WorkoutDetailScreen() {
const repeatCount = Math.max(1, Math.floor(workout.rounds / workout.exercises.length))
return (
<View style={[styles.container, { paddingTop: insets.top }]}>
<View style={styles.container}>
{/* Header with SwiftUI glass button */}
<View style={[styles.header, { paddingTop: insets.top + SPACING[3] }]}>
<RNText style={styles.headerTitle} numberOfLines={1}>
{workout.title}
</RNText>
{/* SwiftUI glass button */}
<View style={styles.glassButtonContainer}>
<Host matchContents useViewportSizeMeasurement colorScheme="dark">
<HStack
alignment="center"
modifiers={[
padding({ all: 8 }),
glassEffect({ glass: { variant: 'regular' } }),
]}
>
<Button
variant="borderless"
onPress={toggleSave}
color={isSaved ? '#FF3B30' : '#FFFFFF'}
>
{isSaved ? '♥' : '♡'}
</Button>
</HStack>
</Host>
</View>
</View>
{/* Content */}
<ScrollView
style={styles.scrollView}
contentContainerStyle={[styles.scrollContent, { paddingBottom: insets.bottom + 100 }]}
showsVerticalScrollIndicator={false}
>
{/* Video Preview */}
<View style={styles.videoPreview}>
<VideoPlayer
videoUrl={workout.videoUrl}
gradientColors={[BRAND.PRIMARY, BRAND.PRIMARY_DARK]}
mode="preview"
style={StyleSheet.absoluteFill}
/>
<LinearGradient
colors={['rgba(0,0,0,0.3)', 'transparent', 'rgba(0,0,0,0.7)']}
style={StyleSheet.absoluteFill}
/>
{/* Header overlay — on video, keep white */}
<View style={styles.headerOverlay}>
<Pressable onPress={handleGoBack} style={styles.headerButton}>
<BlurView intensity={colors.glass.blurLight} tint={colors.glass.blurTint} style={StyleSheet.absoluteFill} />
<Ionicons name="chevron-back" size={24} color="#FFFFFF" />
</Pressable>
<View style={styles.headerRight}>
<Pressable onPress={toggleSave} style={styles.headerButton}>
<BlurView intensity={colors.glass.blurLight} tint={colors.glass.blurTint} style={StyleSheet.absoluteFill} />
<Ionicons
name={isSaved ? 'heart' : 'heart-outline'}
size={24}
color={isSaved ? '#FF3B30' : '#FFFFFF'}
/>
</Pressable>
<Pressable style={styles.headerButton}>
<BlurView intensity={colors.glass.blurLight} tint={colors.glass.blurTint} style={StyleSheet.absoluteFill} />
<Ionicons name="ellipsis-horizontal" size={24} color="#FFFFFF" />
</Pressable>
</View>
{/* Quick stats */}
<View style={styles.quickStats}>
<View style={[styles.statBadge, { backgroundColor: 'rgba(255, 107, 53, 0.15)' }]}>
<Ionicons name="barbell" size={14} color={BRAND.PRIMARY} />
<RNText style={[styles.statBadgeText, { color: BRAND.PRIMARY }]}>
{t(`levels.${workout.level.toLowerCase()}`)}
</RNText>
</View>
{/* Workout icon — on brand bg, keep white */}
<View style={styles.trainerPreview}>
<View style={[styles.trainerAvatarLarge, { backgroundColor: BRAND.PRIMARY }]}>
<Ionicons name="flame" size={36} color="#FFFFFF" />
</View>
<View style={[styles.statBadge, { backgroundColor: colors.bg.surface }]}>
<Ionicons name="time" size={14} color={colors.text.secondary} />
<RNText style={styles.statBadgeText}>{t('units.minUnit', { count: workout.duration })}</RNText>
</View>
<View style={[styles.statBadge, { backgroundColor: colors.bg.surface }]}>
<Ionicons name="flame" size={14} color={colors.text.secondary} />
<RNText style={styles.statBadgeText}>{t('units.calUnit', { count: workout.calories })}</RNText>
</View>
</View>
{/* Title Section */}
<View style={styles.titleSection}>
<RNText style={styles.title}>{workout.title}</RNText>
{/* Quick stats */}
<View style={styles.quickStats}>
<View style={styles.statItem}>
<Ionicons name="barbell" size={16} color={BRAND.PRIMARY} />
<RNText style={styles.statText}>{t(`levels.${workout.level.toLowerCase()}`)}</RNText>
</View>
<RNText style={styles.statDot}></RNText>
<View style={styles.statItem}>
<Ionicons name="time" size={16} color={BRAND.PRIMARY} />
<RNText style={styles.statText}>{t('units.minUnit', { count: workout.duration })}</RNText>
</View>
<RNText style={styles.statDot}></RNText>
<View style={styles.statItem}>
<Ionicons name="flame" size={16} color={BRAND.PRIMARY} />
<RNText style={styles.statText}>{t('units.calUnit', { count: workout.calories })}</RNText>
</View>
</View>
</View>
<View style={styles.divider} />
{/* Equipment */}
<View style={styles.section}>
<RNText style={styles.sectionTitle}>{t('screens:workout.whatYoullNeed')}</RNText>
@@ -178,7 +151,7 @@ export default function WorkoutDetailScreen() {
<RNText style={styles.exerciseNumberText}>{index + 1}</RNText>
</View>
<RNText style={styles.exerciseName}>{exercise.name}</RNText>
<RNText style={styles.exerciseDuration}>{exercise.duration}s work</RNText>
<RNText style={styles.exerciseDuration}>{exercise.duration}s</RNText>
</View>
))}
<View style={styles.repeatNote}>
@@ -207,18 +180,16 @@ export default function WorkoutDetailScreen() {
{/* Fixed Start Button */}
<View style={[styles.bottomBar, { paddingBottom: insets.bottom + SPACING[4] }]}>
<BlurView intensity={colors.glass.blurHeavy} tint={colors.glass.blurTint} style={StyleSheet.absoluteFill} />
<View style={styles.startButtonContainer}>
<Pressable
style={({ pressed }) => [
styles.startButton,
pressed && styles.startButtonPressed,
]}
onPress={handleStartWorkout}
>
<RNText style={styles.startButtonText}>{t('screens:workout.startWorkout')}</RNText>
</Pressable>
</View>
<BlurView intensity={80} tint="dark" style={StyleSheet.absoluteFill} />
<Pressable
style={({ pressed }) => [
styles.startButton,
pressed && styles.startButtonPressed,
]}
onPress={handleStartWorkout}
>
<RNText style={styles.startButtonText}>{t('screens:workout.startWorkout')}</RNText>
</Pressable>
</View>
</View>
)
@@ -234,6 +205,10 @@ function createStyles(colors: ThemeColors) {
flex: 1,
backgroundColor: colors.bg.base,
},
centered: {
alignItems: 'center',
justifyContent: 'center',
},
scrollView: {
flex: 1,
},
@@ -241,80 +216,53 @@ function createStyles(colors: ThemeColors) {
paddingHorizontal: LAYOUT.SCREEN_PADDING,
},
// Video Preview
videoPreview: {
height: 280,
marginHorizontal: -LAYOUT.SCREEN_PADDING,
marginBottom: SPACING[4],
backgroundColor: colors.bg.surface,
},
headerOverlay: {
// Header
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
padding: SPACING[4],
justifyContent: 'space-between',
paddingHorizontal: SPACING[4],
paddingBottom: SPACING[3],
},
headerButton: {
headerTitle: {
flex: 1,
...TYPOGRAPHY.HEADLINE,
color: colors.text.primary,
marginRight: SPACING[3],
},
glassButtonContainer: {
width: 44,
height: 44,
borderRadius: 22,
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.1)',
},
headerRight: {
flexDirection: 'row',
gap: SPACING[2],
},
trainerPreview: {
position: 'absolute',
bottom: SPACING[4],
left: 0,
right: 0,
alignItems: 'center',
},
trainerAvatarLarge: {
width: 80,
height: 80,
borderRadius: 40,
alignItems: 'center',
justifyContent: 'center',
borderWidth: 3,
borderColor: '#FFFFFF',
},
trainerInitial: {
...TYPOGRAPHY.HERO,
color: '#FFFFFF',
},
// Title Section
titleSection: {
marginBottom: SPACING[4],
},
title: {
...TYPOGRAPHY.LARGE_TITLE,
color: colors.text.primary,
marginBottom: SPACING[3],
},
// Quick Stats
quickStats: {
flexDirection: 'row',
alignItems: 'center',
flexWrap: 'wrap',
gap: SPACING[2],
marginBottom: SPACING[5],
},
statItem: {
statBadge: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: SPACING[3],
paddingVertical: SPACING[2],
borderRadius: RADIUS.FULL,
gap: SPACING[1],
},
statText: {
...TYPOGRAPHY.BODY,
statBadgeText: {
...TYPOGRAPHY.CAPTION_1,
color: colors.text.secondary,
fontWeight: '600',
},
statDot: {
color: colors.text.tertiary,
// Section
section: {
paddingVertical: SPACING[3],
},
sectionTitle: {
...TYPOGRAPHY.HEADLINE,
color: colors.text.primary,
marginBottom: SPACING[3],
},
// Divider
@@ -324,16 +272,6 @@ function createStyles(colors: ThemeColors) {
marginVertical: SPACING[2],
},
// Section
section: {
paddingVertical: SPACING[4],
},
sectionTitle: {
...TYPOGRAPHY.HEADLINE,
color: colors.text.primary,
marginBottom: SPACING[3],
},
// Equipment
equipmentItem: {
flexDirection: 'row',
@@ -434,10 +372,6 @@ function createStyles(colors: ThemeColors) {
borderTopWidth: 1,
borderTopColor: colors.border.glass,
},
startButtonContainer: {
height: 56,
justifyContent: 'center',
},
// Start Button
startButton: {