diff --git a/.env.example b/.env.example index f7104c4..459ffac 100644 --- a/.env.example +++ b/.env.example @@ -1,9 +1,13 @@ # TabataFit Environment Variables -# Copy this file to .env and fill in your Supabase credentials +# Copy this file to .env and fill in your credentials # Supabase Configuration EXPO_PUBLIC_SUPABASE_URL=your_supabase_project_url EXPO_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key +# RevenueCat (Apple subscriptions) +# Defaults to test_ sandbox key if not set +EXPO_PUBLIC_REVENUECAT_API_KEY=your_revenuecat_api_key + # Admin Dashboard (optional - for admin authentication) EXPO_PUBLIC_ADMIN_EMAIL=admin@tabatafit.app diff --git a/app.json b/app.json index b58866c..d073ee3 100644 --- a/app.json +++ b/app.json @@ -11,7 +11,17 @@ "ios": { "supportsTablet": true, "bundleIdentifier": "com.millianlmx.tabatafit", - "buildNumber": "1" + "buildNumber": "1", + "infoPlist": { + "NSHealthShareUsageDescription": "TabataFit uses HealthKit to read and write workout data including heart rate, calories burned, and exercise minutes.", + "NSHealthUpdateUsageDescription": "TabataFit saves your workout sessions to Apple Health so you can track your fitness progress.", + "NSCameraUsageDescription": "TabataFit uses the camera for profile photos and workout form checks.", + "NSUserTrackingUsageDescription": "TabataFit uses this to provide personalized workout recommendations.", + "ITSAppUsesNonExemptEncryption": false + }, + "config": { + "usesNonExemptEncryption": false + } }, "android": { "adaptiveIcon": { diff --git a/app/(tabs)/explore.tsx b/app/(tabs)/explore.tsx index 921a211..d14ffcc 100644 --- a/app/(tabs)/explore.tsx +++ b/app/(tabs)/explore.tsx @@ -8,7 +8,9 @@ import Ionicons from '@expo/vector-icons/Ionicons' import { useTranslation } from 'react-i18next' import { useHaptics } from '@/src/shared/hooks' +import { usePurchases } from '@/src/shared/hooks/usePurchases' import { useWorkouts, useCollections, useFeaturedWorkouts, useTrainers } from '@/src/shared/hooks/useSupabaseData' +import { isFreeWorkout } from '@/src/shared/services/access' import { StyledText } from '@/src/shared/components/StyledText' import { useThemeColors, BRAND } from '@/src/shared/theme' @@ -108,9 +110,11 @@ function CollectionCard({ function WorkoutCard({ workout, onPress, + isLocked, }: { workout: Workout onPress: () => void + isLocked?: boolean }) { const colors = useThemeColors() const categoryColor = CATEGORY_COLORS[workout.category] @@ -133,9 +137,15 @@ function WorkoutCard({ + {isLocked && ( + + + + )} + - + @@ -209,6 +219,7 @@ export default function ExploreScreen() { const router = useRouter() const haptics = useHaptics() const colors = useThemeColors() + const { isPremium } = usePurchases() const [filters, setFilters] = useState({ category: 'all', @@ -319,6 +330,7 @@ export default function ExploreScreen() { key={workout.id} workout={workout} onPress={() => handleWorkoutPress(workout.id)} + isLocked={!isPremium && !isFreeWorkout(workout.id)} /> ))} @@ -398,6 +410,7 @@ export default function ExploreScreen() { key={workout.id} workout={workout} onPress={() => handleWorkoutPress(workout.id)} + isLocked={!isPremium && !isFreeWorkout(workout.id)} /> ))} @@ -516,6 +529,17 @@ const styles = StyleSheet.create({ paddingVertical: 3, borderRadius: RADIUS.SM, }, + lockBadge: { + position: 'absolute', + top: SPACING[3], + left: SPACING[3], + width: 22, + height: 22, + borderRadius: 11, + backgroundColor: 'rgba(0,0,0,0.6)', + alignItems: 'center', + justifyContent: 'center', + }, playArea: { position: 'absolute', top: 0, diff --git a/app/(tabs)/profile.tsx b/app/(tabs)/profile.tsx index af5111d..41dc323 100644 --- a/app/(tabs)/profile.tsx +++ b/app/(tabs)/profile.tsx @@ -18,7 +18,7 @@ import * as Linking from 'expo-linking' import Constants from 'expo-constants' import { useTranslation } from 'react-i18next' import { useMemo, useState } from 'react' -import { useUserStore } from '@/src/shared/stores' +import { useUserStore, useActivityStore } from '@/src/shared/stores' import { requestNotificationPermissions, usePurchases } from '@/src/shared/hooks' import { useThemeColors, BRAND } from '@/src/shared/theme' import type { ThemeColors } from '@/src/shared/theme/types' @@ -78,12 +78,14 @@ export default function ProfileScreen() { const planLabel = isPremium ? 'TabataFit+' : t('profile.freePlan') const avatarInitial = profile.name?.[0]?.toUpperCase() || 'U' - // Mock stats (replace with real data from activityStore when available) - const stats = { - workouts: 47, - streak: 12, - calories: 12500, - } + // Real stats from activity store + const history = useActivityStore((s) => s.history) + const streak = useActivityStore((s) => s.streak) + const stats = useMemo(() => ({ + workouts: history.length, + streak: streak.current, + calories: history.reduce((sum, r) => sum + (r.calories ?? 0), 0), + }), [history, streak]) const handleSignOut = () => { updateProfile({ diff --git a/app/collection/[id].tsx b/app/collection/[id].tsx new file mode 100644 index 0000000..45d1516 --- /dev/null +++ b/app/collection/[id].tsx @@ -0,0 +1,249 @@ +/** + * TabataFit Collection Detail Screen + * Shows collection info + list of workouts in that collection + */ + +import { useMemo } from 'react' +import { View, 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 Ionicons from '@expo/vector-icons/Ionicons' +import { useTranslation } from 'react-i18next' + +import { useHaptics } from '@/src/shared/hooks' +import { useCollection } from '@/src/shared/hooks/useSupabaseData' +import { getWorkoutById } from '@/src/shared/data' +import { useTranslatedWorkouts } from '@/src/shared/data/useTranslatedData' +import { StyledText } from '@/src/shared/components/StyledText' +import { track } from '@/src/shared/services/analytics' + +import { useThemeColors, BRAND, GRADIENTS } from '@/src/shared/theme' +import type { ThemeColors } from '@/src/shared/theme/types' +import { SPACING, LAYOUT } from '@/src/shared/constants/spacing' +import { RADIUS } from '@/src/shared/constants/borderRadius' + +export default function CollectionDetailScreen() { + const insets = useSafeAreaInsets() + const router = useRouter() + const haptics = useHaptics() + const { t } = useTranslation() + const { id } = useLocalSearchParams<{ id: string }>() + + const colors = useThemeColors() + const styles = useMemo(() => createStyles(colors), [colors]) + + const { data: collection, isLoading } = useCollection(id) + + // Resolve workouts from collection's workoutIds + const rawWorkouts = useMemo(() => { + if (!collection) return [] + return collection.workoutIds + .map((wId) => getWorkoutById(wId)) + .filter(Boolean) as NonNullable>[] + }, [collection]) + + const workouts = useTranslatedWorkouts(rawWorkouts) + + const handleBack = () => { + haptics.selection() + router.back() + } + + const handleWorkoutPress = (workoutId: string) => { + haptics.buttonTap() + track('collection_workout_tapped', { + collection_id: id, + workout_id: workoutId, + }) + router.push(`/workout/${workoutId}`) + } + + if (isLoading) { + return ( + + Loading... + + ) + } + + if (!collection) { + return ( + + + + Collection not found + + + ) + } + + return ( + + {/* Header */} + + + + + + {collection.title} + + + + + + {/* Hero Card */} + + + + + {collection.icon} + + + {collection.title} + + + {collection.description} + + + {t('plurals.workout', { count: workouts.length })} + + + + + {/* Workout List */} + + {t('screens:explore.workouts')} + + + {workouts.map((workout) => ( + handleWorkoutPress(workout.id)} + > + + + + + + {workout.title} + + + {t('durationLevel', { + duration: workout.duration, + level: t(`levels.${workout.level.toLowerCase()}`), + })} + + + + + {t('units.calUnit', { count: workout.calories })} + + + + + ))} + + {workouts.length === 0 && ( + + + + No workouts in this collection + + + )} + + + ) +} + +function createStyles(colors: ThemeColors) { + return StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.bg.base, + }, + centered: { + alignItems: 'center', + justifyContent: 'center', + }, + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: LAYOUT.SCREEN_PADDING, + paddingVertical: SPACING[3], + }, + backButton: { + width: 44, + height: 44, + alignItems: 'center', + justifyContent: 'center', + }, + heroCard: { + height: 200, + borderRadius: RADIUS.XL, + overflow: 'hidden', + ...colors.shadow.lg, + }, + heroContent: { + flex: 1, + padding: SPACING[5], + justifyContent: 'flex-end', + }, + heroIcon: { + marginBottom: SPACING[2], + }, + scrollView: { + flex: 1, + }, + scrollContent: { + paddingHorizontal: LAYOUT.SCREEN_PADDING, + }, + workoutCard: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: SPACING[3], + paddingHorizontal: SPACING[4], + backgroundColor: colors.bg.surface, + borderRadius: RADIUS.LG, + marginBottom: SPACING[2], + gap: SPACING[3], + }, + workoutAvatar: { + width: 44, + height: 44, + borderRadius: 22, + alignItems: 'center', + justifyContent: 'center', + }, + workoutInfo: { + flex: 1, + gap: 2, + }, + workoutMeta: { + alignItems: 'flex-end', + gap: 4, + }, + emptyState: { + alignItems: 'center', + justifyContent: 'center', + paddingVertical: SPACING[12], + }, + }) +} diff --git a/app/player/[id].tsx b/app/player/[id].tsx index 8f7538a..e9b3616 100644 --- a/app/player/[id].tsx +++ b/app/player/[id].tsx @@ -36,6 +36,7 @@ import { useTranslatedWorkout } from '@/src/shared/data/useTranslatedData' import { useWatchSync } from '@/src/features/watch' import { track } from '@/src/shared/services/analytics' +import { VideoPlayer } from '@/src/shared/components/VideoPlayer' import { BRAND, PHASE_COLORS, GRADIENTS, darkColors } from '@/src/shared/theme' import type { ThemeColors } from '@/src/shared/theme/types' import { TYPOGRAPHY } from '@/src/shared/constants/typography' @@ -461,9 +462,12 @@ export default function PlayerScreen() {