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:
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user