feat: update mobile app screens
- Enhance browse tab with improved navigation - Update home tab with new features - Refactor profile tab - Update paywall screen styling
This commit is contained in:
@@ -1,32 +1,32 @@
|
||||
/**
|
||||
* TabataFit Browse Screen
|
||||
* React Native UI — wired to shared data
|
||||
* TabataFit Browse Screen - Premium Redesign
|
||||
* React Native UI with glassmorphism
|
||||
*/
|
||||
|
||||
import { View, StyleSheet, ScrollView, Pressable, Dimensions, Text as RNText } from 'react-native'
|
||||
import { View, StyleSheet, ScrollView, Pressable, Dimensions, TextInput } from 'react-native'
|
||||
import { useRouter } 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 { useMemo } from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useHaptics } from '@/src/shared/hooks'
|
||||
import {
|
||||
COLLECTIONS,
|
||||
PROGRAMS,
|
||||
getFeaturedCollection,
|
||||
COLLECTION_COLORS,
|
||||
WORKOUTS,
|
||||
} from '@/src/shared/data'
|
||||
import { useTranslatedCollections, useTranslatedPrograms, useTranslatedWorkouts } from '@/src/shared/data/useTranslatedData'
|
||||
import { useTranslatedCollections, useTranslatedWorkouts } from '@/src/shared/data/useTranslatedData'
|
||||
import { StyledText } from '@/src/shared/components/StyledText'
|
||||
import { WorkoutCard } from '@/src/shared/components/WorkoutCard'
|
||||
import { CollectionCard } from '@/src/shared/components/CollectionCard'
|
||||
|
||||
import { useThemeColors, BRAND } 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'
|
||||
import type { WorkoutCategory } from '@/src/shared/types'
|
||||
|
||||
const { width: SCREEN_WIDTH } = Dimensions.get('window')
|
||||
|
||||
@@ -40,18 +40,14 @@ const FONTS = {
|
||||
CAPTION_2: 11,
|
||||
}
|
||||
|
||||
function TextButton({ children, onPress }: { children: string; onPress?: () => void }) {
|
||||
return (
|
||||
<Pressable onPress={onPress} hitSlop={8}>
|
||||
<StyledText size={FONTS.SUBHEADLINE} color={BRAND.PRIMARY} weight="medium">
|
||||
{children}
|
||||
</StyledText>
|
||||
</Pressable>
|
||||
)
|
||||
}
|
||||
|
||||
// New Releases: last 4 workouts
|
||||
const NEW_RELEASES = WORKOUTS.slice(-4)
|
||||
const CATEGORIES: { id: WorkoutCategory | 'all'; translationKey: string }[] = [
|
||||
{ id: 'all', translationKey: 'common:categories.all' },
|
||||
{ id: 'full-body', translationKey: 'common:categories.fullBody' },
|
||||
{ id: 'core', translationKey: 'common:categories.core' },
|
||||
{ id: 'upper-body', translationKey: 'common:categories.upperBody' },
|
||||
{ id: 'lower-body', translationKey: 'common:categories.lowerBody' },
|
||||
{ id: 'cardio', translationKey: 'common:categories.cardio' },
|
||||
]
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// MAIN SCREEN
|
||||
@@ -65,10 +61,32 @@ export default function BrowseScreen() {
|
||||
const colors = useThemeColors()
|
||||
const styles = useMemo(() => createStyles(colors), [colors])
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [selectedCategory, setSelectedCategory] = useState<WorkoutCategory | 'all'>('all')
|
||||
|
||||
const featuredCollection = getFeaturedCollection()
|
||||
const translatedCollections = useTranslatedCollections(COLLECTIONS)
|
||||
const translatedPrograms = useTranslatedPrograms(PROGRAMS)
|
||||
const translatedNewReleases = useTranslatedWorkouts(NEW_RELEASES)
|
||||
const translatedWorkouts = useTranslatedWorkouts(WORKOUTS)
|
||||
|
||||
// Filter workouts based on search and category
|
||||
const filteredWorkouts = useMemo(() => {
|
||||
let filtered = translatedWorkouts
|
||||
|
||||
if (searchQuery.trim()) {
|
||||
const query = searchQuery.toLowerCase()
|
||||
filtered = filtered.filter(
|
||||
(w) =>
|
||||
w.title.toLowerCase().includes(query) ||
|
||||
w.category.toLowerCase().includes(query)
|
||||
)
|
||||
}
|
||||
|
||||
if (selectedCategory !== 'all') {
|
||||
filtered = filtered.filter((w) => w.category === selectedCategory)
|
||||
}
|
||||
|
||||
return filtered
|
||||
}, [translatedWorkouts, searchQuery, selectedCategory])
|
||||
|
||||
const handleWorkoutPress = (id: string) => {
|
||||
haptics.buttonTap()
|
||||
@@ -88,126 +106,151 @@ export default function BrowseScreen() {
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{/* Header */}
|
||||
<StyledText size={FONTS.LARGE_TITLE} weight="bold" color={colors.text.primary}>{t('screens:browse.title')}</StyledText>
|
||||
<View style={styles.header}>
|
||||
<StyledText size={FONTS.LARGE_TITLE} weight="bold" color={colors.text.primary}>
|
||||
{t('screens:browse.title')}
|
||||
</StyledText>
|
||||
</View>
|
||||
|
||||
{/* Search Bar */}
|
||||
<View style={styles.searchContainer}>
|
||||
<BlurView
|
||||
intensity={colors.glass.blurLight}
|
||||
tint={colors.glass.blurTint}
|
||||
style={StyleSheet.absoluteFill}
|
||||
/>
|
||||
<Ionicons name="search" size={20} color={colors.text.tertiary} />
|
||||
<TextInput
|
||||
style={styles.searchInput}
|
||||
placeholder={t('screens:browse.searchPlaceholder') || 'Search workouts...'}
|
||||
placeholderTextColor={colors.text.tertiary}
|
||||
value={searchQuery}
|
||||
onChangeText={setSearchQuery}
|
||||
/>
|
||||
{searchQuery.length > 0 && (
|
||||
<Pressable
|
||||
onPress={() => setSearchQuery('')}
|
||||
hitSlop={8}
|
||||
>
|
||||
<Ionicons name="close-circle" size={20} color={colors.text.tertiary} />
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Category Filter Chips */}
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.categoriesScroll}
|
||||
style={styles.categoriesContainer}
|
||||
>
|
||||
{CATEGORIES.map((cat) => (
|
||||
<Pressable
|
||||
key={cat.id}
|
||||
style={[
|
||||
styles.categoryChip,
|
||||
selectedCategory === cat.id && styles.categoryChipActive,
|
||||
]}
|
||||
onPress={() => {
|
||||
haptics.buttonTap()
|
||||
setSelectedCategory(cat.id)
|
||||
}}
|
||||
>
|
||||
{selectedCategory === cat.id && (
|
||||
<BlurView
|
||||
intensity={colors.glass.blurMedium}
|
||||
tint={colors.glass.blurTint}
|
||||
style={StyleSheet.absoluteFill}
|
||||
/>
|
||||
)}
|
||||
<StyledText
|
||||
size={14}
|
||||
weight={selectedCategory === cat.id ? 'semibold' : 'medium'}
|
||||
color={selectedCategory === cat.id ? '#FFFFFF' : colors.text.secondary}
|
||||
>
|
||||
{t(cat.translationKey)}
|
||||
</StyledText>
|
||||
</Pressable>
|
||||
))}
|
||||
</ScrollView>
|
||||
|
||||
{/* Featured Collection */}
|
||||
{featuredCollection && (
|
||||
<Pressable style={styles.featuredCard} onPress={() => handleCollectionPress(featuredCollection.id)}>
|
||||
<LinearGradient
|
||||
colors={featuredCollection.gradient ?? [BRAND.PRIMARY, '#FF3B30']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={StyleSheet.absoluteFill}
|
||||
{featuredCollection && !searchQuery && selectedCategory === 'all' && (
|
||||
<View style={styles.section}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<StyledText size={FONTS.TITLE_2} weight="semibold" color={colors.text.primary}>
|
||||
{t('screens:browse.featured')}
|
||||
</StyledText>
|
||||
</View>
|
||||
<CollectionCard
|
||||
collection={featuredCollection}
|
||||
onPress={() => handleCollectionPress(featuredCollection.id)}
|
||||
/>
|
||||
|
||||
<View style={styles.featuredBadge}>
|
||||
<Ionicons name="star" size={12} color="#FFFFFF" />
|
||||
<RNText style={styles.featuredBadgeText}>{t('screens:browse.featured')}</RNText>
|
||||
</View>
|
||||
|
||||
<View style={styles.featuredInfo}>
|
||||
<StyledText size={FONTS.TITLE} weight="bold" color="#FFFFFF">{t(`content:collections.${featuredCollection.id}.title`, { defaultValue: featuredCollection.title })}</StyledText>
|
||||
<StyledText size={FONTS.HEADLINE} color="rgba(255,255,255,0.8)">{t(`content:collections.${featuredCollection.id}.description`, { defaultValue: featuredCollection.description })}</StyledText>
|
||||
|
||||
<View style={styles.featuredStats}>
|
||||
<View style={styles.featuredStat}>
|
||||
<Ionicons name="fitness" size={14} color="#FFFFFF" />
|
||||
<StyledText size={FONTS.CAPTION_1} color="#FFFFFF">{t('plurals.workout', { count: featuredCollection.workoutIds.length })}</StyledText>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Pressable>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Collections Grid */}
|
||||
<View style={styles.section}>
|
||||
<StyledText size={FONTS.TITLE_2} weight="semibold" color={colors.text.primary}>{t('screens:browse.collections')}</StyledText>
|
||||
<View style={styles.collectionsGrid}>
|
||||
{translatedCollections.map((collection) => {
|
||||
const color = COLLECTION_COLORS[collection.id] ?? BRAND.PRIMARY
|
||||
return (
|
||||
<Pressable
|
||||
{!searchQuery && selectedCategory === 'all' && (
|
||||
<View style={styles.section}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<StyledText size={FONTS.TITLE_2} weight="semibold" color={colors.text.primary}>
|
||||
{t('screens:browse.collections')}
|
||||
</StyledText>
|
||||
</View>
|
||||
<View style={styles.collectionsGrid}>
|
||||
{translatedCollections.map((collection) => (
|
||||
<CollectionCard
|
||||
key={collection.id}
|
||||
style={styles.collectionCard}
|
||||
collection={collection}
|
||||
onPress={() => handleCollectionPress(collection.id)}
|
||||
>
|
||||
<BlurView intensity={colors.glass.blurLight} tint={colors.glass.blurTint} style={StyleSheet.absoluteFill} />
|
||||
<View style={[styles.collectionIconBg, { backgroundColor: `${color}20` }]}>
|
||||
<RNText style={styles.collectionEmoji}>{collection.icon}</RNText>
|
||||
</View>
|
||||
<StyledText size={FONTS.SUBHEADLINE} weight="semibold" color={colors.text.primary} numberOfLines={1}>
|
||||
{collection.title}
|
||||
</StyledText>
|
||||
<StyledText size={FONTS.CAPTION_1} color={color}>
|
||||
{t('plurals.workout', { count: collection.workoutIds.length })}
|
||||
</StyledText>
|
||||
</Pressable>
|
||||
)
|
||||
})}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Programs */}
|
||||
{/* All Workouts Grid */}
|
||||
<View style={styles.section}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<StyledText size={FONTS.TITLE_2} weight="semibold" color={colors.text.primary}>{t('screens:browse.programs')}</StyledText>
|
||||
<TextButton>{t('seeAll')}</TextButton>
|
||||
<StyledText size={FONTS.TITLE_2} weight="semibold" color={colors.text.primary}>
|
||||
{searchQuery ? t('screens:browse.searchResults') || 'Results' : t('screens:browse.allWorkouts') || 'All Workouts'}
|
||||
</StyledText>
|
||||
<StyledText size={FONTS.CAPTION_1} color={colors.text.tertiary}>
|
||||
{filteredWorkouts.length} {t('plurals.workout', { count: filteredWorkouts.length })}
|
||||
</StyledText>
|
||||
</View>
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.programsScroll}
|
||||
>
|
||||
{translatedPrograms.map((program) => (
|
||||
<View key={program.id} style={styles.programCard}>
|
||||
<BlurView intensity={colors.glass.blurMedium} tint={colors.glass.blurTint} style={StyleSheet.absoluteFill} />
|
||||
|
||||
<View style={styles.programHeader}>
|
||||
<View style={styles.programLevelBadge}>
|
||||
<StyledText size={FONTS.CAPTION_2} weight="semibold" color={colors.text.primary}>{t(`levels.${program.level.toLowerCase()}`)}</StyledText>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<StyledText size={FONTS.HEADLINE} weight="semibold" color={colors.text.primary}>{program.title}</StyledText>
|
||||
|
||||
<View style={styles.programMeta}>
|
||||
<View style={styles.programMetaItem}>
|
||||
<Ionicons name="calendar" size={14} color={colors.text.tertiary} />
|
||||
<StyledText size={FONTS.CAPTION_1} color={colors.text.tertiary}>{t('screens:browse.weeksCount', { count: program.weeks })}</StyledText>
|
||||
</View>
|
||||
<View style={styles.programMetaItem}>
|
||||
<Ionicons name="repeat" size={14} color={colors.text.tertiary} />
|
||||
<StyledText size={FONTS.CAPTION_1} color={colors.text.tertiary}>{t('screens:browse.timesPerWeek', { count: program.workoutsPerWeek })}</StyledText>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
|
||||
{/* New Releases */}
|
||||
<View style={styles.section}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<StyledText size={FONTS.TITLE_2} weight="semibold" color={colors.text.primary}>{t('screens:browse.newReleases')}</StyledText>
|
||||
</View>
|
||||
{translatedNewReleases.map((workout) => (
|
||||
<Pressable
|
||||
key={workout.id}
|
||||
style={styles.releaseRow}
|
||||
onPress={() => handleWorkoutPress(workout.id)}
|
||||
>
|
||||
<View style={[styles.releaseAvatar, { backgroundColor: BRAND.PRIMARY }]}>
|
||||
<Ionicons name="flame" size={20} color="#FFFFFF" />
|
||||
</View>
|
||||
<View style={styles.releaseInfo}>
|
||||
<StyledText size={FONTS.HEADLINE} weight="semibold" color={colors.text.primary}>{workout.title}</StyledText>
|
||||
<StyledText size={FONTS.CAPTION_1} color={colors.text.tertiary}>
|
||||
{t('durationLevel', { duration: workout.duration, level: t(`levels.${workout.level.toLowerCase()}`) })}
|
||||
</StyledText>
|
||||
</View>
|
||||
<Ionicons name="play-circle" size={32} color={BRAND.PRIMARY} />
|
||||
</Pressable>
|
||||
))}
|
||||
{filteredWorkouts.length > 0 ? (
|
||||
<View style={styles.workoutsGrid}>
|
||||
{filteredWorkouts.map((workout) => (
|
||||
<WorkoutCard
|
||||
key={workout.id}
|
||||
workout={workout}
|
||||
variant="grid"
|
||||
onPress={() => handleWorkoutPress(workout.id)}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.emptyState}>
|
||||
<Ionicons name="fitness-outline" size={48} color={colors.text.tertiary} />
|
||||
<StyledText
|
||||
size={FONTS.HEADLINE}
|
||||
weight="medium"
|
||||
color={colors.text.secondary}
|
||||
style={{ marginTop: SPACING[4] }}
|
||||
>
|
||||
{t('screens:browse.noResults') || 'No workouts found'}
|
||||
</StyledText>
|
||||
<StyledText
|
||||
size={FONTS.SUBHEADLINE}
|
||||
color={colors.text.tertiary}
|
||||
style={{ marginTop: SPACING[2], textAlign: 'center' }}
|
||||
>
|
||||
{t('screens:browse.tryDifferentSearch') || 'Try a different search or category'}
|
||||
</StyledText>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
@@ -218,8 +261,6 @@ export default function BrowseScreen() {
|
||||
// STYLES
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
const COLLECTION_CARD_WIDTH = (SCREEN_WIDTH - LAYOUT.SCREEN_PADDING * 2 - SPACING[3]) / 2
|
||||
|
||||
function createStyles(colors: ThemeColors) {
|
||||
return StyleSheet.create({
|
||||
container: {
|
||||
@@ -233,52 +274,57 @@ function createStyles(colors: ThemeColors) {
|
||||
paddingHorizontal: LAYOUT.SCREEN_PADDING,
|
||||
},
|
||||
|
||||
// Featured Collection
|
||||
featuredCard: {
|
||||
height: 200,
|
||||
borderRadius: RADIUS.GLASS_CARD,
|
||||
overflow: 'hidden',
|
||||
marginBottom: SPACING[8],
|
||||
marginTop: SPACING[4],
|
||||
...colors.shadow.lg,
|
||||
},
|
||||
featuredBadge: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.3)',
|
||||
paddingHorizontal: SPACING[2],
|
||||
paddingVertical: SPACING[1],
|
||||
borderRadius: RADIUS.SM,
|
||||
alignSelf: 'flex-start',
|
||||
margin: SPACING[4],
|
||||
gap: SPACING[1],
|
||||
},
|
||||
featuredBadgeText: {
|
||||
fontSize: 11,
|
||||
fontWeight: 'bold',
|
||||
color: '#FFFFFF',
|
||||
},
|
||||
featuredInfo: {
|
||||
position: 'absolute',
|
||||
bottom: SPACING[5],
|
||||
left: SPACING[5],
|
||||
right: SPACING[5],
|
||||
},
|
||||
featuredStats: {
|
||||
flexDirection: 'row',
|
||||
gap: SPACING[4],
|
||||
marginTop: SPACING[3],
|
||||
},
|
||||
featuredStat: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: SPACING[1],
|
||||
// Header
|
||||
header: {
|
||||
marginBottom: SPACING[4],
|
||||
},
|
||||
|
||||
// Section
|
||||
section: {
|
||||
// Search Bar
|
||||
searchContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
height: 48,
|
||||
borderRadius: RADIUS.LG,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border.glass,
|
||||
paddingHorizontal: SPACING[4],
|
||||
marginBottom: SPACING[4],
|
||||
overflow: 'hidden',
|
||||
},
|
||||
searchInput: {
|
||||
flex: 1,
|
||||
marginLeft: SPACING[3],
|
||||
marginRight: SPACING[2],
|
||||
fontSize: FONTS.HEADLINE,
|
||||
color: colors.text.primary,
|
||||
height: '100%',
|
||||
},
|
||||
|
||||
// Categories
|
||||
categoriesContainer: {
|
||||
marginBottom: SPACING[6],
|
||||
},
|
||||
categoriesScroll: {
|
||||
gap: SPACING[2],
|
||||
paddingRight: SPACING[4],
|
||||
},
|
||||
categoryChip: {
|
||||
paddingHorizontal: SPACING[4],
|
||||
paddingVertical: SPACING[2],
|
||||
borderRadius: RADIUS.FULL,
|
||||
overflow: 'hidden',
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border.glass,
|
||||
},
|
||||
categoryChipActive: {
|
||||
borderColor: BRAND.PRIMARY,
|
||||
backgroundColor: `${BRAND.PRIMARY}30`,
|
||||
},
|
||||
|
||||
// Sections
|
||||
section: {
|
||||
marginBottom: SPACING[8],
|
||||
},
|
||||
sectionHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
@@ -286,96 +332,25 @@ function createStyles(colors: ThemeColors) {
|
||||
marginBottom: SPACING[4],
|
||||
},
|
||||
|
||||
// Collections Grid
|
||||
// Collections
|
||||
collectionsGrid: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: SPACING[3],
|
||||
marginTop: SPACING[3],
|
||||
},
|
||||
collectionCard: {
|
||||
width: COLLECTION_CARD_WIDTH,
|
||||
paddingVertical: SPACING[4],
|
||||
paddingHorizontal: SPACING[3],
|
||||
borderRadius: RADIUS.LG,
|
||||
overflow: 'hidden',
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border.glass,
|
||||
gap: SPACING[1],
|
||||
},
|
||||
collectionIconBg: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 12,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: SPACING[2],
|
||||
},
|
||||
collectionEmoji: {
|
||||
fontSize: 22,
|
||||
},
|
||||
|
||||
// Programs
|
||||
programsScroll: {
|
||||
// Workouts Grid
|
||||
workoutsGrid: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: SPACING[3],
|
||||
},
|
||||
programCard: {
|
||||
width: 200,
|
||||
height: 140,
|
||||
borderRadius: RADIUS.LG,
|
||||
overflow: 'hidden',
|
||||
padding: SPACING[4],
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border.glass,
|
||||
},
|
||||
programHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-end',
|
||||
marginBottom: SPACING[2],
|
||||
},
|
||||
programLevelBadge: {
|
||||
backgroundColor: 'rgba(255, 107, 53, 0.2)',
|
||||
paddingHorizontal: SPACING[2],
|
||||
paddingVertical: SPACING[1],
|
||||
borderRadius: RADIUS.SM,
|
||||
},
|
||||
programMeta: {
|
||||
flexDirection: 'row',
|
||||
gap: SPACING[3],
|
||||
marginTop: SPACING[3],
|
||||
},
|
||||
programMetaItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: SPACING[1],
|
||||
},
|
||||
|
||||
// New Releases
|
||||
releaseRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: SPACING[3],
|
||||
paddingHorizontal: SPACING[4],
|
||||
backgroundColor: colors.bg.surface,
|
||||
borderRadius: RADIUS.LG,
|
||||
marginBottom: SPACING[2],
|
||||
gap: SPACING[3],
|
||||
},
|
||||
releaseAvatar: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 22,
|
||||
// Empty State
|
||||
emptyState: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
releaseInitial: {
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
color: '#FFFFFF',
|
||||
},
|
||||
releaseInfo: {
|
||||
flex: 1,
|
||||
gap: 2,
|
||||
paddingVertical: SPACING[12],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
/**
|
||||
* TabataFit Home Screen
|
||||
* React Native UI — wired to shared data
|
||||
* TabataFit Home Screen - Premium Redesign
|
||||
* React Native UI with glassmorphism
|
||||
*/
|
||||
|
||||
import { View, StyleSheet, ScrollView, Pressable, Dimensions, Text as RNText, Image as RNImage } from 'react-native'
|
||||
import { View, StyleSheet, ScrollView, Pressable, Dimensions, Text as RNText } from 'react-native'
|
||||
import { useRouter } 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 { useMemo } from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useHaptics } from '@/src/shared/hooks'
|
||||
import { useUserStore, useActivityStore } from '@/src/shared/stores'
|
||||
@@ -22,50 +22,35 @@ import {
|
||||
} from '@/src/shared/data'
|
||||
import { useTranslatedWorkouts, useTranslatedCollections } from '@/src/shared/data/useTranslatedData'
|
||||
import { StyledText } from '@/src/shared/components/StyledText'
|
||||
import { WorkoutCard } from '@/src/shared/components/WorkoutCard'
|
||||
import { CollectionCard } from '@/src/shared/components/CollectionCard'
|
||||
|
||||
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'
|
||||
import type { WorkoutCategory } from '@/src/shared/types'
|
||||
|
||||
const { width: SCREEN_WIDTH } = Dimensions.get('window')
|
||||
const CARD_WIDTH = SCREEN_WIDTH - LAYOUT.SCREEN_PADDING * 2
|
||||
|
||||
const FONTS = {
|
||||
LARGE_TITLE: 34,
|
||||
TITLE: 28,
|
||||
TITLE_2: 22,
|
||||
HEADLINE: 17,
|
||||
BODY: 17,
|
||||
SUBHEADLINE: 15,
|
||||
CAPTION_1: 12,
|
||||
CAPTION_2: 11,
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// HELPERS
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
function PrimaryButton({ children, onPress }: { children: string; onPress?: () => void }) {
|
||||
const colors = useThemeColors()
|
||||
const styles = useMemo(() => createStyles(colors), [colors])
|
||||
return (
|
||||
<Pressable style={styles.primaryButton} onPress={onPress}>
|
||||
<Ionicons name="play" size={16} color="#FFFFFF" style={styles.buttonIcon} />
|
||||
<RNText style={styles.primaryButtonText}>{children}</RNText>
|
||||
</Pressable>
|
||||
)
|
||||
}
|
||||
|
||||
function PlainButton({ children, onPress }: { children: string; onPress?: () => void }) {
|
||||
const colors = useThemeColors()
|
||||
const styles = useMemo(() => createStyles(colors), [colors])
|
||||
return (
|
||||
<Pressable onPress={onPress}>
|
||||
<RNText style={styles.plainButtonText}>{children}</RNText>
|
||||
</Pressable>
|
||||
)
|
||||
}
|
||||
const CATEGORIES: { id: WorkoutCategory | 'all'; key: string }[] = [
|
||||
{ id: 'all', key: 'all' },
|
||||
{ id: 'full-body', key: 'fullBody' },
|
||||
{ id: 'core', key: 'core' },
|
||||
{ id: 'upper-body', key: 'upperBody' },
|
||||
{ id: 'lower-body', key: 'lowerBody' },
|
||||
{ id: 'cardio', key: 'cardio' },
|
||||
]
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// MAIN SCREEN
|
||||
@@ -82,8 +67,10 @@ export default function HomeScreen() {
|
||||
const history = useActivityStore((s) => s.history)
|
||||
const recentWorkouts = useMemo(() => history.slice(0, 3), [history])
|
||||
|
||||
const [selectedCategory, setSelectedCategory] = useState<WorkoutCategory | 'all'>('all')
|
||||
|
||||
const featured = getFeaturedWorkouts()[0] ?? WORKOUTS[0]
|
||||
const popular = getPopularWorkouts(4)
|
||||
const popular = getPopularWorkouts(6)
|
||||
const translatedPopular = useTranslatedWorkouts(popular)
|
||||
const translatedCollections = useTranslatedCollections(COLLECTIONS)
|
||||
|
||||
@@ -94,13 +81,21 @@ export default function HomeScreen() {
|
||||
return t('greetings.evening')
|
||||
})()
|
||||
|
||||
const featuredTitle = t(`content:workouts.${featured.id}`, { defaultValue: featured.title })
|
||||
|
||||
const handleWorkoutPress = (id: string) => {
|
||||
haptics.buttonTap()
|
||||
router.push(`/workout/${id}`)
|
||||
}
|
||||
|
||||
const handleCollectionPress = (id: string) => {
|
||||
haptics.buttonTap()
|
||||
router.push(`/collection/${id}`)
|
||||
}
|
||||
|
||||
const filteredWorkouts = useMemo(() => {
|
||||
if (selectedCategory === 'all') return translatedPopular
|
||||
return translatedPopular.filter((w) => w.category === selectedCategory)
|
||||
}, [translatedPopular, selectedCategory])
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { paddingTop: insets.top }]}>
|
||||
<ScrollView
|
||||
@@ -108,151 +103,124 @@ export default function HomeScreen() {
|
||||
contentContainerStyle={[styles.scrollContent, { paddingBottom: insets.bottom + 100 }]}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{/* Header */}
|
||||
<View style={styles.header}>
|
||||
<View style={{ flex: 1, marginRight: SPACING[3] }}>
|
||||
<StyledText size={FONTS.SUBHEADLINE} color={colors.text.tertiary}>
|
||||
{greeting}
|
||||
</StyledText>
|
||||
{/* Hero Section */}
|
||||
<View style={styles.heroSection}>
|
||||
<StyledText size={FONTS.SUBHEADLINE} color={colors.text.tertiary}>
|
||||
{greeting}
|
||||
</StyledText>
|
||||
<View style={styles.heroHeader}>
|
||||
<StyledText
|
||||
size={FONTS.LARGE_TITLE}
|
||||
weight="bold"
|
||||
color={colors.text.primary}
|
||||
numberOfLines={1}
|
||||
style={styles.heroTitle}
|
||||
>
|
||||
{userName}
|
||||
</StyledText>
|
||||
<Pressable style={styles.profileButton}>
|
||||
<Ionicons name="person-circle-outline" size={40} color={colors.text.primary} />
|
||||
</Pressable>
|
||||
</View>
|
||||
<Pressable style={styles.profileButton}>
|
||||
<Ionicons name="person-circle-outline" size={32} color={colors.text.primary} />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{/* Featured */}
|
||||
<Pressable
|
||||
style={styles.featuredCard}
|
||||
onPress={() => handleWorkoutPress(featured.id)}
|
||||
>
|
||||
{featured.thumbnailUrl ? (
|
||||
<RNImage
|
||||
source={{ uri: featured.thumbnailUrl }}
|
||||
style={StyleSheet.absoluteFill}
|
||||
resizeMode="cover"
|
||||
/>
|
||||
) : (
|
||||
<LinearGradient
|
||||
colors={[BRAND.PRIMARY, BRAND.PRIMARY_DARK]}
|
||||
style={StyleSheet.absoluteFill}
|
||||
/>
|
||||
)}
|
||||
<LinearGradient
|
||||
colors={GRADIENTS.VIDEO_OVERLAY}
|
||||
style={StyleSheet.absoluteFill}
|
||||
/>
|
||||
|
||||
<View style={styles.featuredBadge}>
|
||||
<RNText style={styles.featuredBadgeText}>{'🔥 ' + t('screens:home.featured')}</RNText>
|
||||
</View>
|
||||
|
||||
<View style={styles.featuredContent}>
|
||||
<StyledText size={FONTS.TITLE} weight="bold" color="#FFFFFF">
|
||||
{featuredTitle}
|
||||
</StyledText>
|
||||
<StyledText size={FONTS.SUBHEADLINE} color="rgba(255,255,255,0.8)">
|
||||
{t('workoutMeta', { duration: featured.duration, level: t(`levels.${featured.level.toLowerCase()}`), calories: featured.calories })}
|
||||
</StyledText>
|
||||
|
||||
<View style={styles.featuredButtons}>
|
||||
<PrimaryButton onPress={() => handleWorkoutPress(featured.id)}>{t('start')}</PrimaryButton>
|
||||
<Pressable style={styles.saveButton}>
|
||||
<Ionicons name="heart-outline" size={24} color="#FFFFFF" />
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
</Pressable>
|
||||
|
||||
{/* Continue Watching — from activity store */}
|
||||
{recentWorkouts.length > 0 && (
|
||||
<View style={styles.section}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<StyledText size={FONTS.TITLE_2} weight="semibold" color={colors.text.primary}>{t('screens:home.recent')}</StyledText>
|
||||
<PlainButton>{t('seeAll')}</PlainButton>
|
||||
</View>
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.horizontalScroll}
|
||||
>
|
||||
{recentWorkouts.map((result) => {
|
||||
const workout = WORKOUTS.find(w => w.id === result.workoutId)
|
||||
if (!workout) return null
|
||||
const workoutTitle = t(`content:workouts.${workout.id}`, { defaultValue: workout.title })
|
||||
return (
|
||||
<Pressable
|
||||
key={result.id}
|
||||
style={styles.continueCard}
|
||||
onPress={() => handleWorkoutPress(result.workoutId)}
|
||||
>
|
||||
<View style={styles.continueThumb}>
|
||||
<LinearGradient
|
||||
colors={[BRAND.PRIMARY, BRAND.PRIMARY_LIGHT]}
|
||||
style={StyleSheet.absoluteFill}
|
||||
/>
|
||||
<Ionicons name="flame" size={32} color="#FFFFFF" />
|
||||
</View>
|
||||
<StyledText size={FONTS.SUBHEADLINE} weight="semibold" color={colors.text.primary}>{workoutTitle}</StyledText>
|
||||
<StyledText size={FONTS.CAPTION_2} color={colors.text.tertiary}>
|
||||
{t('calMin', { calories: result.calories, duration: result.durationMinutes })}
|
||||
</StyledText>
|
||||
</Pressable>
|
||||
)
|
||||
})}
|
||||
</ScrollView>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Popular This Week */}
|
||||
{/* Featured Workout */}
|
||||
<View style={styles.section}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<StyledText size={FONTS.TITLE_2} weight="semibold" color={colors.text.primary}>
|
||||
{t('screens:home.featured')}
|
||||
</StyledText>
|
||||
</View>
|
||||
<WorkoutCard
|
||||
workout={featured}
|
||||
variant="featured"
|
||||
onPress={() => handleWorkoutPress(featured.id)}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Category Filter */}
|
||||
<View style={styles.section}>
|
||||
<StyledText size={FONTS.TITLE_2} weight="semibold" color={colors.text.primary}>{t('screens:home.popularThisWeek')}</StyledText>
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.horizontalScroll}
|
||||
contentContainerStyle={styles.categoriesScroll}
|
||||
>
|
||||
{translatedPopular.map((item) => (
|
||||
{CATEGORIES.map((cat) => (
|
||||
<Pressable
|
||||
key={item.id}
|
||||
style={styles.popularCard}
|
||||
onPress={() => handleWorkoutPress(item.id)}
|
||||
key={cat.id}
|
||||
style={[
|
||||
styles.categoryChip,
|
||||
selectedCategory === cat.id && styles.categoryChipActive,
|
||||
]}
|
||||
onPress={() => {
|
||||
haptics.buttonTap()
|
||||
setSelectedCategory(cat.id)
|
||||
}}
|
||||
>
|
||||
<View style={styles.popularThumb}>
|
||||
<Ionicons name="flame" size={24} color={BRAND.PRIMARY} />
|
||||
</View>
|
||||
<StyledText size={FONTS.SUBHEADLINE} weight="medium" color={colors.text.primary}>{item.title}</StyledText>
|
||||
<StyledText size={FONTS.CAPTION_2} color={colors.text.tertiary}>{t('units.minUnit', { count: item.duration })}</StyledText>
|
||||
{selectedCategory === cat.id && (
|
||||
<BlurView
|
||||
intensity={colors.glass.blurMedium}
|
||||
tint={colors.glass.blurTint}
|
||||
style={StyleSheet.absoluteFill}
|
||||
/>
|
||||
)}
|
||||
<StyledText
|
||||
size={14}
|
||||
weight={selectedCategory === cat.id ? 'semibold' : 'medium'}
|
||||
color={selectedCategory === cat.id ? colors.text.primary : colors.text.secondary}
|
||||
>
|
||||
{t(`categories.${cat.key}`)}
|
||||
</StyledText>
|
||||
</Pressable>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
|
||||
{/* Collections */}
|
||||
{/* Popular Workouts - Horizontal */}
|
||||
{filteredWorkouts.length > 0 && (
|
||||
<View style={styles.section}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<StyledText size={FONTS.TITLE_2} weight="semibold" color={colors.text.primary}>
|
||||
{t('screens:home.popularThisWeek')}
|
||||
</StyledText>
|
||||
<Pressable hitSlop={8}>
|
||||
<StyledText size={FONTS.SUBHEADLINE} color={BRAND.PRIMARY} weight="medium">
|
||||
{t('seeAll')}
|
||||
</StyledText>
|
||||
</Pressable>
|
||||
</View>
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.workoutsScroll}
|
||||
>
|
||||
{filteredWorkouts.map((workout) => (
|
||||
<WorkoutCard
|
||||
key={workout.id}
|
||||
workout={workout}
|
||||
variant="horizontal"
|
||||
onPress={() => handleWorkoutPress(workout.id)}
|
||||
/>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Collections Grid */}
|
||||
<View style={styles.section}>
|
||||
<StyledText size={FONTS.TITLE_2} weight="semibold" color={colors.text.primary}>{t('screens:home.collections')}</StyledText>
|
||||
{translatedCollections.map((item) => (
|
||||
<Pressable key={item.id} style={styles.collectionCard} onPress={() => { haptics.buttonTap(); router.push(`/collection/${item.id}`) }}>
|
||||
<BlurView intensity={colors.glass.blurMedium} tint={colors.glass.blurTint} style={StyleSheet.absoluteFill} />
|
||||
<View style={styles.collectionContent}>
|
||||
<RNText style={styles.collectionIcon}>{item.icon}</RNText>
|
||||
<View style={styles.collectionText}>
|
||||
<StyledText size={FONTS.HEADLINE} weight="semibold" color={colors.text.primary}>{item.title}</StyledText>
|
||||
<StyledText size={FONTS.CAPTION_1} color={colors.text.tertiary}>
|
||||
{t('plurals.workout', { count: item.workoutIds.length }) + ' \u00B7 ' + item.description}
|
||||
</StyledText>
|
||||
</View>
|
||||
<Ionicons name="chevron-forward" size={20} color={colors.text.tertiary} />
|
||||
</View>
|
||||
</Pressable>
|
||||
))}
|
||||
<View style={styles.sectionHeader}>
|
||||
<StyledText size={FONTS.TITLE_2} weight="semibold" color={colors.text.primary}>
|
||||
{t('screens:home.collections')}
|
||||
</StyledText>
|
||||
</View>
|
||||
<View style={styles.collectionsGrid}>
|
||||
{translatedCollections.map((collection) => (
|
||||
<CollectionCard
|
||||
key={collection.id}
|
||||
collection={collection}
|
||||
onPress={() => handleCollectionPress(collection.id)}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
@@ -276,89 +244,25 @@ function createStyles(colors: ThemeColors) {
|
||||
paddingHorizontal: LAYOUT.SCREEN_PADDING,
|
||||
},
|
||||
|
||||
// Header
|
||||
header: {
|
||||
// Hero Section
|
||||
heroSection: {
|
||||
marginBottom: SPACING[6],
|
||||
},
|
||||
heroHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: SPACING[6],
|
||||
marginTop: SPACING[2],
|
||||
},
|
||||
heroTitle: {
|
||||
flex: 1,
|
||||
marginRight: SPACING[3],
|
||||
},
|
||||
profileButton: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
|
||||
// Buttons
|
||||
primaryButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: BRAND.PRIMARY,
|
||||
paddingHorizontal: SPACING[4],
|
||||
paddingVertical: SPACING[3],
|
||||
borderRadius: RADIUS.SM,
|
||||
},
|
||||
buttonIcon: {
|
||||
marginRight: SPACING[2],
|
||||
},
|
||||
primaryButtonText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: '#FFFFFF',
|
||||
},
|
||||
plainButtonText: {
|
||||
fontSize: FONTS.BODY,
|
||||
color: BRAND.PRIMARY,
|
||||
},
|
||||
|
||||
// Featured
|
||||
featuredCard: {
|
||||
width: CARD_WIDTH,
|
||||
height: 220,
|
||||
borderRadius: RADIUS.GLASS_CARD,
|
||||
overflow: 'hidden',
|
||||
marginBottom: SPACING[8],
|
||||
...colors.shadow.lg,
|
||||
},
|
||||
featuredBadge: {
|
||||
position: 'absolute',
|
||||
top: SPACING[4],
|
||||
left: SPACING[4],
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.15)',
|
||||
paddingHorizontal: SPACING[3],
|
||||
paddingVertical: SPACING[1],
|
||||
borderRadius: RADIUS.SM,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.2)',
|
||||
},
|
||||
featuredBadgeText: {
|
||||
fontSize: 11,
|
||||
fontWeight: 'bold',
|
||||
color: '#FFFFFF',
|
||||
},
|
||||
featuredContent: {
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
padding: SPACING[5],
|
||||
},
|
||||
featuredButtons: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: SPACING[3],
|
||||
marginTop: SPACING[4],
|
||||
},
|
||||
saveButton: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.1)',
|
||||
borderRadius: 22,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.15)',
|
||||
},
|
||||
|
||||
// Sections
|
||||
@@ -371,61 +275,36 @@ function createStyles(colors: ThemeColors) {
|
||||
alignItems: 'center',
|
||||
marginBottom: SPACING[4],
|
||||
},
|
||||
horizontalScroll: {
|
||||
|
||||
// Categories
|
||||
categoriesScroll: {
|
||||
gap: SPACING[2],
|
||||
paddingRight: SPACING[4],
|
||||
},
|
||||
categoryChip: {
|
||||
paddingHorizontal: SPACING[4],
|
||||
paddingVertical: SPACING[2],
|
||||
borderRadius: RADIUS.FULL,
|
||||
overflow: 'hidden',
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border.glass,
|
||||
},
|
||||
categoryChipActive: {
|
||||
borderColor: BRAND.PRIMARY,
|
||||
backgroundColor: `${BRAND.PRIMARY}30`,
|
||||
},
|
||||
|
||||
// Workouts Scroll
|
||||
workoutsScroll: {
|
||||
gap: SPACING[3],
|
||||
paddingRight: SPACING[4],
|
||||
},
|
||||
|
||||
// Continue Card
|
||||
continueCard: {
|
||||
width: 140,
|
||||
},
|
||||
continueThumb: {
|
||||
width: 140,
|
||||
height: 200,
|
||||
borderRadius: RADIUS.LG,
|
||||
overflow: 'hidden',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: SPACING[2],
|
||||
},
|
||||
|
||||
// Popular Card
|
||||
popularCard: {
|
||||
width: 120,
|
||||
},
|
||||
popularThumb: {
|
||||
width: 120,
|
||||
height: 120,
|
||||
borderRadius: RADIUS.LG,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: SPACING[2],
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border.glass,
|
||||
backgroundColor: colors.bg.overlay1,
|
||||
},
|
||||
|
||||
// Collection Card
|
||||
collectionCard: {
|
||||
height: 80,
|
||||
borderRadius: RADIUS.LG,
|
||||
overflow: 'hidden',
|
||||
marginBottom: SPACING[3],
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border.glass,
|
||||
},
|
||||
collectionContent: {
|
||||
flex: 1,
|
||||
// Collections Grid
|
||||
collectionsGrid: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: SPACING[5],
|
||||
},
|
||||
collectionIcon: {
|
||||
fontSize: 28,
|
||||
marginRight: SPACING[4],
|
||||
},
|
||||
collectionText: {
|
||||
flex: 1,
|
||||
flexWrap: 'wrap',
|
||||
gap: SPACING[3],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,40 +1,62 @@
|
||||
/**
|
||||
* TabataFit Profile Screen
|
||||
* SwiftUI-first settings with native iOS look
|
||||
* TabataFit Profile Screen — Premium React Native
|
||||
* Apple Fitness+ inspired design, pure React Native components
|
||||
*/
|
||||
|
||||
import { View, StyleSheet, ScrollView } from 'react-native'
|
||||
import { useRouter } from 'expo-router'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import {
|
||||
Host,
|
||||
List,
|
||||
Section,
|
||||
View,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
Switch,
|
||||
LabeledContent,
|
||||
DateTimePicker,
|
||||
Button,
|
||||
VStack,
|
||||
Text,
|
||||
} from '@expo/ui/swift-ui'
|
||||
import { useMemo } from 'react'
|
||||
Text as RNText,
|
||||
TextStyle,
|
||||
} from 'react-native'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import * as Linking from 'expo-linking'
|
||||
import Constants from 'expo-constants'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useMemo } from 'react'
|
||||
import { useUserStore } from '@/src/shared/stores'
|
||||
import { requestNotificationPermissions, usePurchases } from '@/src/shared/hooks'
|
||||
import { StyledText } from '@/src/shared/components/StyledText'
|
||||
|
||||
import { useThemeColors, BRAND } from '@/src/shared/theme'
|
||||
import type { ThemeColors } from '@/src/shared/theme/types'
|
||||
import { SPACING, LAYOUT } from '@/src/shared/constants/spacing'
|
||||
|
||||
const FONTS = {
|
||||
LARGE_TITLE: 34,
|
||||
TITLE_2: 22,
|
||||
CAPTION_1: 12,
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// STYLED TEXT COMPONENT
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
interface TextProps {
|
||||
children: React.ReactNode
|
||||
style?: TextStyle
|
||||
size?: number
|
||||
weight?: 'normal' | 'bold' | '600' | '700' | '800' | '900'
|
||||
color?: string
|
||||
center?: boolean
|
||||
}
|
||||
|
||||
function Text({ children, style, size, weight, color, center }: TextProps) {
|
||||
const colors = useThemeColors()
|
||||
return (
|
||||
<RNText
|
||||
style={[
|
||||
{
|
||||
fontSize: size ?? 17,
|
||||
fontWeight: weight ?? 'normal',
|
||||
color: color ?? colors.text.primary,
|
||||
textAlign: center ? 'center' : 'left',
|
||||
},
|
||||
style,
|
||||
]}
|
||||
>
|
||||
{children}
|
||||
</RNText>
|
||||
)
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// MAIN SCREEN
|
||||
// COMPONENT: PROFILE SCREEN
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
export default function ProfileScreen() {
|
||||
@@ -46,10 +68,28 @@ export default function ProfileScreen() {
|
||||
const profile = useUserStore((s) => s.profile)
|
||||
const settings = useUserStore((s) => s.settings)
|
||||
const updateSettings = useUserStore((s) => s.updateSettings)
|
||||
const { restorePurchases } = usePurchases()
|
||||
const updateProfile = useUserStore((s) => s.updateProfile)
|
||||
const { restorePurchases, isPremium } = usePurchases()
|
||||
|
||||
const isPremium = profile.subscription !== 'free'
|
||||
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,
|
||||
}
|
||||
|
||||
const handleSignOut = () => {
|
||||
updateProfile({
|
||||
name: '',
|
||||
email: '',
|
||||
subscription: 'free',
|
||||
onboardingCompleted: false,
|
||||
})
|
||||
router.replace('/onboarding')
|
||||
}
|
||||
|
||||
const handleRestore = async () => {
|
||||
await restorePurchases()
|
||||
@@ -63,43 +103,24 @@ export default function ProfileScreen() {
|
||||
updateSettings({ reminders: enabled })
|
||||
}
|
||||
|
||||
const handleTimeChange = (date: Date) => {
|
||||
const hh = String(date.getHours()).padStart(2, '0')
|
||||
const mm = String(date.getMinutes()).padStart(2, '0')
|
||||
updateSettings({ reminderTime: `${hh}:${mm}` })
|
||||
const handleRateApp = () => {
|
||||
Linking.openURL('https://apps.apple.com/app/tabatafit/id1234567890')
|
||||
}
|
||||
|
||||
// Build initial date string for the picker (today at reminderTime)
|
||||
const today = new Date()
|
||||
const [rh, rm] = settings.reminderTime.split(':').map(Number)
|
||||
const pickerDate = new Date(today.getFullYear(), today.getMonth(), today.getDate(), rh, rm)
|
||||
const pickerInitial = pickerDate.toISOString()
|
||||
const handleContactUs = () => {
|
||||
Linking.openURL('mailto:contact@tabatafit.app')
|
||||
}
|
||||
|
||||
// Calculate total height for single SwiftUI island
|
||||
// insetGrouped style: ~50px top/bottom margins, section header ~35px, row ~44px
|
||||
const basePadding = 100 // top + bottom margins for insetGrouped
|
||||
const handlePrivacyPolicy = () => {
|
||||
router.push('/privacy')
|
||||
}
|
||||
|
||||
// Account section
|
||||
const accountRows = 1 + (isPremium ? 1 : 0) // plan, [+ restore]
|
||||
const accountHeight = 35 + accountRows * 44
|
||||
const handleFAQ = () => {
|
||||
Linking.openURL('https://tabatafit.app/faq')
|
||||
}
|
||||
|
||||
// Upgrade section (free users only)
|
||||
const upgradeHeight = isPremium ? 0 : 35 + 80 // header + VStack content
|
||||
|
||||
// Workout section
|
||||
const workoutHeight = 35 + 3 * 44 // haptics, sound, voice
|
||||
|
||||
// Notifications section
|
||||
const notificationRows = settings.reminders ? 2 : 1
|
||||
const notificationHeight = 35 + notificationRows * 44
|
||||
|
||||
// About section
|
||||
const aboutHeight = 35 + 2 * 44 // version, privacy
|
||||
|
||||
// Sign out section
|
||||
const signOutHeight = 44 // single button row
|
||||
|
||||
const totalHeight = basePadding + accountHeight + upgradeHeight + workoutHeight + notificationHeight + aboutHeight + signOutHeight
|
||||
// App version
|
||||
const appVersion = Constants.expoConfig?.version ?? '1.0.0'
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { paddingTop: insets.top }]}>
|
||||
@@ -108,125 +129,195 @@ export default function ProfileScreen() {
|
||||
contentContainerStyle={[styles.scrollContent, { paddingBottom: insets.bottom + 100 }]}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{/* Header */}
|
||||
<StyledText size={FONTS.LARGE_TITLE} weight="bold" color={colors.text.primary}>
|
||||
{t('profile.title')}
|
||||
</StyledText>
|
||||
|
||||
{/* Profile Header Card */}
|
||||
<View style={styles.profileHeader}>
|
||||
{/* ════════════════════════════════════════════════════════════════════
|
||||
PROFILE HEADER CARD
|
||||
═══════════════════════════════════════════════════════════════════ */}
|
||||
<View style={styles.section}>
|
||||
<View style={styles.headerContainer}>
|
||||
{/* Avatar with gradient background */}
|
||||
<View style={styles.avatarContainer}>
|
||||
<StyledText size={FONTS.LARGE_TITLE} weight="bold" color="#FFFFFF">
|
||||
{profile.name?.[0] || '?'}
|
||||
</StyledText>
|
||||
<Text size={48} weight="bold" color="#FFFFFF">
|
||||
{avatarInitial}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.profileInfo}>
|
||||
<StyledText size={FONTS.TITLE_2} weight="semibold" color={colors.text.primary}>
|
||||
|
||||
{/* Name & Plan */}
|
||||
<View style={styles.nameContainer}>
|
||||
<Text size={22} weight="600" center>
|
||||
{profile.name || t('profile.guest')}
|
||||
</StyledText>
|
||||
{isPremium && (
|
||||
<View style={styles.premiumBadge}>
|
||||
<StyledText size={FONTS.CAPTION_1} weight="semibold" color={BRAND.PRIMARY}>
|
||||
{planLabel}
|
||||
</StyledText>
|
||||
</View>
|
||||
)}
|
||||
</Text>
|
||||
<View style={styles.planContainer}>
|
||||
<Text size={15} color={isPremium ? BRAND.PRIMARY : colors.text.tertiary}>
|
||||
{planLabel}
|
||||
</Text>
|
||||
{isPremium && (
|
||||
<Text size={12} color={BRAND.PRIMARY}>
|
||||
✓
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Stats Row */}
|
||||
<View style={styles.statsContainer}>
|
||||
<View style={styles.statItem}>
|
||||
<Text size={20} weight="bold" color={BRAND.PRIMARY} center>
|
||||
🔥 {stats.workouts}
|
||||
</Text>
|
||||
<Text size={12} color={colors.text.tertiary} center>
|
||||
{t('profile.statsWorkouts')}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.statItem}>
|
||||
<Text size={20} weight="bold" color={BRAND.PRIMARY} center>
|
||||
📅 {stats.streak}
|
||||
</Text>
|
||||
<Text size={12} color={colors.text.tertiary} center>
|
||||
{t('profile.statsStreak')}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.statItem}>
|
||||
<Text size={20} weight="bold" color={BRAND.PRIMARY} center>
|
||||
⚡️ {Math.round(stats.calories / 1000)}k
|
||||
</Text>
|
||||
<Text size={12} color={colors.text.tertiary} center>
|
||||
{t('profile.statsCalories')}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* All Settings in Single SwiftUI Island */}
|
||||
<Host useViewportSizeMeasurement colorScheme={colors.colorScheme} style={{ height: totalHeight }}>
|
||||
<List listStyle="insetGrouped" scrollEnabled={false}>
|
||||
{/* Account Section */}
|
||||
<Section header={t('profile.sectionAccount')}>
|
||||
<LabeledContent label={t('profile.plan')}>
|
||||
<Text color={isPremium ? BRAND.PRIMARY : undefined}>{planLabel}</Text>
|
||||
</LabeledContent>
|
||||
{isPremium && (
|
||||
<Button variant="borderless" onPress={handleRestore} color={colors.text.tertiary}>
|
||||
{t('profile.restorePurchases')}
|
||||
</Button>
|
||||
)}
|
||||
</Section>
|
||||
{/* ════════════════════════════════════════════════════════════════════
|
||||
UPGRADE CTA (FREE USERS ONLY)
|
||||
═══════════════════════════════════════════════════════════════════ */}
|
||||
{!isPremium && (
|
||||
<View style={styles.section}>
|
||||
<TouchableOpacity
|
||||
style={styles.premiumContainer}
|
||||
onPress={() => router.push('/paywall')}
|
||||
>
|
||||
<View style={styles.premiumContent}>
|
||||
<Text size={17} weight="600" color={BRAND.PRIMARY}>
|
||||
✨ {t('profile.upgradeTitle')}
|
||||
</Text>
|
||||
<Text size={15} color={colors.text.tertiary} style={{ marginTop: 4 }}>
|
||||
{t('profile.upgradeDescription')}
|
||||
</Text>
|
||||
</View>
|
||||
<Text size={15} color={BRAND.PRIMARY} style={{ marginTop: 12 }}>
|
||||
{t('profile.learnMore')} →
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Upgrade CTA for Free Users */}
|
||||
{!isPremium && (
|
||||
<Section>
|
||||
<VStack alignment="leading" spacing={8}>
|
||||
<Text font="headline" color={BRAND.PRIMARY}>
|
||||
{t('profile.upgradeTitle')}
|
||||
</Text>
|
||||
<Text color="systemSecondary" font="subheadline">
|
||||
{t('profile.upgradeDescription')}
|
||||
</Text>
|
||||
</VStack>
|
||||
<Button variant="borderless" onPress={() => router.push('/paywall')} color={BRAND.PRIMARY}>
|
||||
{t('profile.learnMore')}
|
||||
</Button>
|
||||
</Section>
|
||||
)}
|
||||
{/* ════════════════════════════════════════════════════════════════════
|
||||
WORKOUT SETTINGS
|
||||
═══════════════════════════════════════════════════════════════════ */}
|
||||
<Text style={styles.sectionHeader}>{t('profile.sectionWorkout')}</Text>
|
||||
<View style={styles.section}>
|
||||
<View style={styles.row}>
|
||||
<Text style={styles.rowLabel}>{t('profile.hapticFeedback')}</Text>
|
||||
<Switch
|
||||
value={settings.haptics}
|
||||
onValueChange={(v) => updateSettings({ haptics: v })}
|
||||
trackColor={{ false: colors.bg.overlay1, true: BRAND.PRIMARY }}
|
||||
thumbColor="#FFFFFF"
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.row}>
|
||||
<Text style={styles.rowLabel}>{t('profile.soundEffects')}</Text>
|
||||
<Switch
|
||||
value={settings.soundEffects}
|
||||
onValueChange={(v) => updateSettings({ soundEffects: v })}
|
||||
trackColor={{ false: colors.bg.overlay1, true: BRAND.PRIMARY }}
|
||||
thumbColor="#FFFFFF"
|
||||
/>
|
||||
</View>
|
||||
<View style={[styles.row, styles.rowLast]}>
|
||||
<Text style={styles.rowLabel}>{t('profile.voiceCoaching')}</Text>
|
||||
<Switch
|
||||
value={settings.voiceCoaching}
|
||||
onValueChange={(v) => updateSettings({ voiceCoaching: v })}
|
||||
trackColor={{ false: colors.bg.overlay1, true: BRAND.PRIMARY }}
|
||||
thumbColor="#FFFFFF"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Workout Settings */}
|
||||
<Section header={t('profile.sectionWorkout')} footer={t('profile.workoutSettingsFooter')}>
|
||||
<Switch
|
||||
label={t('profile.hapticFeedback')}
|
||||
value={settings.haptics}
|
||||
onValueChange={(v) => updateSettings({ haptics: v })}
|
||||
color={BRAND.PRIMARY}
|
||||
/>
|
||||
<Switch
|
||||
label={t('profile.soundEffects')}
|
||||
value={settings.soundEffects}
|
||||
onValueChange={(v) => updateSettings({ soundEffects: v })}
|
||||
color={BRAND.PRIMARY}
|
||||
/>
|
||||
<Switch
|
||||
label={t('profile.voiceCoaching')}
|
||||
value={settings.voiceCoaching}
|
||||
onValueChange={(v) => updateSettings({ voiceCoaching: v })}
|
||||
color={BRAND.PRIMARY}
|
||||
/>
|
||||
</Section>
|
||||
{/* ════════════════════════════════════════════════════════════════════
|
||||
NOTIFICATIONS
|
||||
═══════════════════════════════════════════════════════════════════ */}
|
||||
<Text style={styles.sectionHeader}>{t('profile.sectionNotifications')}</Text>
|
||||
<View style={styles.section}>
|
||||
<View style={styles.row}>
|
||||
<Text style={styles.rowLabel}>{t('profile.dailyReminders')}</Text>
|
||||
<Switch
|
||||
value={settings.reminders}
|
||||
onValueChange={handleReminderToggle}
|
||||
trackColor={{ false: colors.bg.overlay1, true: BRAND.PRIMARY }}
|
||||
thumbColor="#FFFFFF"
|
||||
/>
|
||||
</View>
|
||||
{settings.reminders && (
|
||||
<View style={styles.rowTime}>
|
||||
<Text style={styles.rowLabel}>{t('profile.reminderTime')}</Text>
|
||||
<Text style={styles.rowValue}>{settings.reminderTime}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Notification Settings */}
|
||||
<Section header={t('profile.sectionNotifications')} footer={settings.reminders ? t('profile.reminderFooter') : undefined}>
|
||||
<Switch
|
||||
label={t('profile.dailyReminders')}
|
||||
value={settings.reminders}
|
||||
onValueChange={handleReminderToggle}
|
||||
color={BRAND.PRIMARY}
|
||||
/>
|
||||
{settings.reminders && (
|
||||
<LabeledContent label={t('profile.reminderTime')}>
|
||||
<DateTimePicker
|
||||
displayedComponents="hourAndMinute"
|
||||
variant="compact"
|
||||
initialDate={pickerInitial}
|
||||
onDateSelected={handleTimeChange}
|
||||
color={BRAND.PRIMARY}
|
||||
/>
|
||||
</LabeledContent>
|
||||
)}
|
||||
</Section>
|
||||
{/* ════════════════════════════════════════════════════════════════════
|
||||
ABOUT
|
||||
═══════════════════════════════════════════════════════════════════ */}
|
||||
<Text style={styles.sectionHeader}>{t('profile.sectionAbout')}</Text>
|
||||
<View style={styles.section}>
|
||||
<View style={styles.row}>
|
||||
<Text style={styles.rowLabel}>{t('profile.version')}</Text>
|
||||
<Text style={styles.rowValue}>{appVersion}</Text>
|
||||
</View>
|
||||
<TouchableOpacity style={styles.row} onPress={handleRateApp}>
|
||||
<Text style={styles.rowLabel}>{t('profile.rateApp')}</Text>
|
||||
<Text style={styles.rowValue}>›</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={styles.row} onPress={handleContactUs}>
|
||||
<Text style={styles.rowLabel}>{t('profile.contactUs')}</Text>
|
||||
<Text style={styles.rowValue}>›</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={styles.row} onPress={handleFAQ}>
|
||||
<Text style={styles.rowLabel}>{t('profile.faq')}</Text>
|
||||
<Text style={styles.rowValue}>›</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={[styles.row, styles.rowLast]} onPress={handlePrivacyPolicy}>
|
||||
<Text style={styles.rowLabel}>{t('profile.privacyPolicy')}</Text>
|
||||
<Text style={styles.rowValue}>›</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* About Section */}
|
||||
<Section header={t('profile.sectionAbout')}>
|
||||
<LabeledContent label={t('profile.version')}>
|
||||
<Text color="systemSecondary">1.0.0</Text>
|
||||
</LabeledContent>
|
||||
<Button variant="borderless" color="systemSecondary" onPress={() => router.push('/privacy')}>
|
||||
{t('profile.privacyPolicy')}
|
||||
</Button>
|
||||
</Section>
|
||||
{/* ════════════════════════════════════════════════════════════════════
|
||||
ACCOUNT (PREMIUM USERS ONLY)
|
||||
═══════════════════════════════════════════════════════════════════ */}
|
||||
{isPremium && (
|
||||
<>
|
||||
<Text style={styles.sectionHeader}>{t('profile.sectionAccount')}</Text>
|
||||
<View style={styles.section}>
|
||||
<TouchableOpacity style={[styles.row, styles.rowLast]} onPress={handleRestore}>
|
||||
<Text style={styles.rowLabel}>{t('profile.restorePurchases')}</Text>
|
||||
<Text style={styles.rowValue}>›</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Sign Out */}
|
||||
<Section>
|
||||
<Button variant="borderless" role="destructive" onPress={() => {}}>
|
||||
{t('profile.signOut')}
|
||||
</Button>
|
||||
</Section>
|
||||
</List>
|
||||
</Host>
|
||||
{/* ════════════════════════════════════════════════════════════════════
|
||||
SIGN OUT
|
||||
═══════════════════════════════════════════════════════════════════ */}
|
||||
<View style={[styles.section, styles.signOutSection]}>
|
||||
<TouchableOpacity style={styles.button} onPress={handleSignOut}>
|
||||
<Text style={styles.destructive}>{t('profile.signOut')}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
)
|
||||
@@ -246,34 +337,107 @@ function createStyles(colors: ThemeColors) {
|
||||
flex: 1,
|
||||
},
|
||||
scrollContent: {
|
||||
paddingHorizontal: LAYOUT.SCREEN_PADDING,
|
||||
flexGrow: 1,
|
||||
},
|
||||
|
||||
// Profile Header
|
||||
profileHeader: {
|
||||
flexDirection: 'row',
|
||||
section: {
|
||||
marginHorizontal: 16,
|
||||
marginTop: 20,
|
||||
backgroundColor: colors.bg.surface,
|
||||
borderRadius: 10,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
sectionHeader: {
|
||||
fontSize: 13,
|
||||
fontWeight: '600',
|
||||
color: colors.text.tertiary,
|
||||
textTransform: 'uppercase',
|
||||
marginLeft: 32,
|
||||
marginTop: 20,
|
||||
marginBottom: 8,
|
||||
},
|
||||
headerContainer: {
|
||||
alignItems: 'center',
|
||||
paddingVertical: SPACING[5],
|
||||
gap: SPACING[4],
|
||||
paddingVertical: 24,
|
||||
paddingHorizontal: 16,
|
||||
},
|
||||
avatarContainer: {
|
||||
width: 60,
|
||||
height: 60,
|
||||
borderRadius: 30,
|
||||
width: 90,
|
||||
height: 90,
|
||||
borderRadius: 45,
|
||||
backgroundColor: BRAND.PRIMARY,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
shadowColor: BRAND.PRIMARY,
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.5,
|
||||
shadowRadius: 20,
|
||||
elevation: 10,
|
||||
},
|
||||
profileInfo: {
|
||||
flex: 1,
|
||||
nameContainer: {
|
||||
marginTop: 16,
|
||||
alignItems: 'center',
|
||||
},
|
||||
premiumBadge: {
|
||||
backgroundColor: 'rgba(255, 107, 53, 0.15)',
|
||||
paddingHorizontal: SPACING[3],
|
||||
paddingVertical: SPACING[1],
|
||||
borderRadius: 12,
|
||||
alignSelf: 'flex-start',
|
||||
marginTop: SPACING[1],
|
||||
planContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginTop: 4,
|
||||
gap: 4,
|
||||
},
|
||||
statsContainer: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
marginTop: 16,
|
||||
gap: 32,
|
||||
},
|
||||
statItem: {
|
||||
alignItems: 'center',
|
||||
},
|
||||
premiumContainer: {
|
||||
paddingVertical: 16,
|
||||
paddingHorizontal: 16,
|
||||
},
|
||||
premiumContent: {
|
||||
gap: 4,
|
||||
},
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 16,
|
||||
borderBottomWidth: 0.5,
|
||||
borderBottomColor: colors.border.glassLight,
|
||||
},
|
||||
rowLast: {
|
||||
borderBottomWidth: 0,
|
||||
},
|
||||
rowLabel: {
|
||||
fontSize: 17,
|
||||
color: colors.text.primary,
|
||||
},
|
||||
rowValue: {
|
||||
fontSize: 17,
|
||||
color: colors.text.tertiary,
|
||||
},
|
||||
rowTime: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 16,
|
||||
borderTopWidth: 0.5,
|
||||
borderTopColor: colors.border.glassLight,
|
||||
},
|
||||
button: {
|
||||
paddingVertical: 14,
|
||||
alignItems: 'center',
|
||||
},
|
||||
destructive: {
|
||||
fontSize: 17,
|
||||
color: BRAND.DANGER,
|
||||
},
|
||||
signOutSection: {
|
||||
marginTop: 20,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
393
app/paywall.tsx
393
app/paywall.tsx
@@ -3,7 +3,7 @@
|
||||
* Premium subscription purchase flow
|
||||
*/
|
||||
|
||||
import React from 'react'
|
||||
import React, { useMemo } from 'react'
|
||||
import {
|
||||
View,
|
||||
StyleSheet,
|
||||
@@ -18,7 +18,8 @@ import Ionicons from '@expo/vector-icons/Ionicons'
|
||||
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useHaptics, usePurchases } from '@/src/shared/hooks'
|
||||
import { BRAND, darkColors } from '@/src/shared/theme'
|
||||
import { useThemeColors, BRAND, GRADIENTS } from '@/src/shared/theme'
|
||||
import type { ThemeColors } from '@/src/shared/theme/types'
|
||||
import { SPACING } from '@/src/shared/constants/spacing'
|
||||
import { RADIUS } from '@/src/shared/constants/borderRadius'
|
||||
|
||||
@@ -39,6 +40,18 @@ const PREMIUM_FEATURES = [
|
||||
// COMPONENTS
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
interface PlanCardStyles {
|
||||
planCard: object
|
||||
planCardPressed: object
|
||||
savingsBadge: object
|
||||
savingsText: object
|
||||
planInfo: object
|
||||
planTitle: object
|
||||
planPeriod: object
|
||||
planPrice: object
|
||||
checkmark: object
|
||||
}
|
||||
|
||||
function PlanCard({
|
||||
title,
|
||||
price,
|
||||
@@ -46,6 +59,8 @@ function PlanCard({
|
||||
savings,
|
||||
isSelected,
|
||||
onPress,
|
||||
colors,
|
||||
styles,
|
||||
}: {
|
||||
title: string
|
||||
price: string
|
||||
@@ -53,6 +68,8 @@ function PlanCard({
|
||||
savings?: string
|
||||
isSelected: boolean
|
||||
onPress: () => void
|
||||
colors: ThemeColors
|
||||
styles: PlanCardStyles
|
||||
}) {
|
||||
const haptics = useHaptics()
|
||||
|
||||
@@ -66,8 +83,12 @@ function PlanCard({
|
||||
onPress={handlePress}
|
||||
style={({ pressed }) => [
|
||||
styles.planCard,
|
||||
isSelected && styles.planCardSelected,
|
||||
isSelected && { borderColor: BRAND.PRIMARY },
|
||||
pressed && styles.planCardPressed,
|
||||
{
|
||||
backgroundColor: colors.bg.surface,
|
||||
borderColor: isSelected ? BRAND.PRIMARY : colors.border.glass,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{savings && (
|
||||
@@ -76,10 +97,16 @@ function PlanCard({
|
||||
</View>
|
||||
)}
|
||||
<View style={styles.planInfo}>
|
||||
<Text style={styles.planTitle}>{title}</Text>
|
||||
<Text style={styles.planPeriod}>{period}</Text>
|
||||
<Text style={[styles.planTitle, { color: colors.text.primary }]}>
|
||||
{title}
|
||||
</Text>
|
||||
<Text style={[styles.planPeriod, { color: colors.text.tertiary }]}>
|
||||
{period}
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={styles.planPrice}>{price}</Text>
|
||||
<Text style={[styles.planPrice, { color: BRAND.PRIMARY }]}>
|
||||
{price}
|
||||
</Text>
|
||||
{isSelected && (
|
||||
<View style={styles.checkmark}>
|
||||
<Ionicons name="checkmark-circle" size={24} color={BRAND.PRIMARY} />
|
||||
@@ -98,6 +125,25 @@ export default function PaywallScreen() {
|
||||
const router = useRouter()
|
||||
const insets = useSafeAreaInsets()
|
||||
const haptics = useHaptics()
|
||||
const colors = useThemeColors()
|
||||
const styles = useMemo(() => createStyles(colors), [colors])
|
||||
|
||||
// Extract plan card styles for the child component
|
||||
const planCardStyles = useMemo(
|
||||
() => ({
|
||||
planCard: styles.planCard,
|
||||
planCardPressed: styles.planCardPressed,
|
||||
savingsBadge: styles.savingsBadge,
|
||||
savingsText: styles.savingsText,
|
||||
planInfo: styles.planInfo,
|
||||
planTitle: styles.planTitle,
|
||||
planPeriod: styles.planPeriod,
|
||||
planPrice: styles.planPrice,
|
||||
checkmark: styles.checkmark,
|
||||
}),
|
||||
[styles],
|
||||
)
|
||||
|
||||
const {
|
||||
monthlyPackage,
|
||||
annualPackage,
|
||||
@@ -148,15 +194,9 @@ export default function PaywallScreen() {
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { paddingTop: insets.top }]}>
|
||||
{/* Background Gradient */}
|
||||
<LinearGradient
|
||||
colors={['#1a1a2e', '#16213e', '#0f0f1a']}
|
||||
style={styles.gradient}
|
||||
/>
|
||||
|
||||
{/* Close Button */}
|
||||
<Pressable style={styles.closeButton} onPress={handleClose}>
|
||||
<Ionicons name="close" size={28} color={darkColors.text.secondary} />
|
||||
<Pressable style={[styles.closeButton, { top: insets.top + SPACING[2] }]} onPress={handleClose}>
|
||||
<Ionicons name="close" size={28} color={colors.text.secondary} />
|
||||
</Pressable>
|
||||
|
||||
<ScrollView
|
||||
@@ -177,10 +217,10 @@ export default function PaywallScreen() {
|
||||
<View style={styles.featuresGrid}>
|
||||
{PREMIUM_FEATURES.map((feature) => (
|
||||
<View key={feature.key} style={styles.featureItem}>
|
||||
<View style={styles.featureIcon}>
|
||||
<View style={[styles.featureIcon, { backgroundColor: colors.glass.tinted.backgroundColor }]}>
|
||||
<Ionicons name={feature.icon as any} size={22} color={BRAND.PRIMARY} />
|
||||
</View>
|
||||
<Text style={styles.featureText}>
|
||||
<Text style={[styles.featureText, { color: colors.text.secondary }]}>
|
||||
{t(`paywall.features.${feature.key}`)}
|
||||
</Text>
|
||||
</View>
|
||||
@@ -196,6 +236,8 @@ export default function PaywallScreen() {
|
||||
savings={t('paywall.save50')}
|
||||
isSelected={selectedPlan === 'annual'}
|
||||
onPress={() => setSelectedPlan('annual')}
|
||||
colors={colors}
|
||||
styles={planCardStyles}
|
||||
/>
|
||||
<PlanCard
|
||||
title={t('paywall.monthly')}
|
||||
@@ -203,12 +245,14 @@ export default function PaywallScreen() {
|
||||
period={t('paywall.perMonth')}
|
||||
isSelected={selectedPlan === 'monthly'}
|
||||
onPress={() => setSelectedPlan('monthly')}
|
||||
colors={colors}
|
||||
styles={planCardStyles}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Price Note */}
|
||||
{selectedPlan === 'annual' && (
|
||||
<Text style={styles.priceNote}>
|
||||
<Text style={[styles.priceNote, { color: colors.text.tertiary }]}>
|
||||
{t('paywall.equivalent', { price: annualMonthlyEquivalent })}
|
||||
</Text>
|
||||
)}
|
||||
@@ -220,12 +264,12 @@ export default function PaywallScreen() {
|
||||
disabled={isLoading}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={[BRAND.PRIMARY, '#FF8A5B']}
|
||||
colors={GRADIENTS.CTA}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={styles.ctaGradient}
|
||||
>
|
||||
<Text style={styles.ctaText}>
|
||||
<Text style={[styles.ctaText, { color: colors.text.primary }]}>
|
||||
{isLoading ? t('paywall.processing') : t('paywall.subscribe')}
|
||||
</Text>
|
||||
</LinearGradient>
|
||||
@@ -234,10 +278,14 @@ export default function PaywallScreen() {
|
||||
{/* Restore & Terms */}
|
||||
<View style={styles.footer}>
|
||||
<Pressable onPress={handleRestore}>
|
||||
<Text style={styles.restoreText}>{t('paywall.restore')}</Text>
|
||||
<Text style={[styles.restoreText, { color: colors.text.tertiary }]}>
|
||||
{t('paywall.restore')}
|
||||
</Text>
|
||||
</Pressable>
|
||||
|
||||
<Text style={styles.termsText}>{t('paywall.terms')}</Text>
|
||||
<Text style={[styles.termsText, { color: colors.text.tertiary }]}>
|
||||
{t('paywall.terms')}
|
||||
</Text>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
@@ -248,162 +296,147 @@ export default function PaywallScreen() {
|
||||
// STYLES
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
gradient: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
},
|
||||
closeButton: {
|
||||
position: 'absolute',
|
||||
top: SPACING[4],
|
||||
right: SPACING[4],
|
||||
width: 44,
|
||||
height: 44,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 10,
|
||||
},
|
||||
scrollView: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollContent: {
|
||||
paddingHorizontal: SPACING[5],
|
||||
paddingTop: SPACING[8],
|
||||
},
|
||||
header: {
|
||||
alignItems: 'center',
|
||||
},
|
||||
title: {
|
||||
fontSize: 32,
|
||||
fontWeight: '700',
|
||||
color: '#FFF',
|
||||
textAlign: 'center',
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 16,
|
||||
color: darkColors.text.secondary,
|
||||
textAlign: 'center',
|
||||
marginTop: SPACING[2],
|
||||
},
|
||||
featuresGrid: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
marginTop: SPACING[6],
|
||||
marginHorizontal: -SPACING[2],
|
||||
},
|
||||
featureItem: {
|
||||
width: '33%',
|
||||
alignItems: 'center',
|
||||
paddingVertical: SPACING[3],
|
||||
},
|
||||
featureIcon: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 24,
|
||||
backgroundColor: 'rgba(255, 107, 53, 0.15)',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: SPACING[2],
|
||||
},
|
||||
featureText: {
|
||||
fontSize: 13,
|
||||
color: darkColors.text.secondary,
|
||||
textAlign: 'center',
|
||||
},
|
||||
plansContainer: {
|
||||
marginTop: SPACING[6],
|
||||
gap: SPACING[3],
|
||||
},
|
||||
planCard: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.08)',
|
||||
borderRadius: RADIUS.LG,
|
||||
padding: SPACING[4],
|
||||
borderWidth: 2,
|
||||
borderColor: 'transparent',
|
||||
},
|
||||
planCardSelected: {
|
||||
borderColor: BRAND.PRIMARY,
|
||||
backgroundColor: 'rgba(255, 107, 53, 0.1)',
|
||||
},
|
||||
planCardPressed: {
|
||||
opacity: 0.8,
|
||||
},
|
||||
savingsBadge: {
|
||||
position: 'absolute',
|
||||
top: -8,
|
||||
right: SPACING[3],
|
||||
backgroundColor: BRAND.PRIMARY,
|
||||
paddingHorizontal: SPACING[2],
|
||||
paddingVertical: 2,
|
||||
borderRadius: RADIUS.SM,
|
||||
},
|
||||
savingsText: {
|
||||
fontSize: 10,
|
||||
fontWeight: '700',
|
||||
color: '#FFF',
|
||||
},
|
||||
planInfo: {
|
||||
flex: 1,
|
||||
},
|
||||
planTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: darkColors.text.primary,
|
||||
},
|
||||
planPeriod: {
|
||||
fontSize: 13,
|
||||
color: darkColors.text.tertiary,
|
||||
marginTop: 2,
|
||||
},
|
||||
planPrice: {
|
||||
fontSize: 20,
|
||||
fontWeight: '700',
|
||||
color: BRAND.PRIMARY,
|
||||
},
|
||||
checkmark: {
|
||||
marginLeft: SPACING[2],
|
||||
},
|
||||
priceNote: {
|
||||
fontSize: 13,
|
||||
color: darkColors.text.tertiary,
|
||||
textAlign: 'center',
|
||||
marginTop: SPACING[3],
|
||||
},
|
||||
ctaButton: {
|
||||
borderRadius: RADIUS.LG,
|
||||
overflow: 'hidden',
|
||||
marginTop: SPACING[6],
|
||||
},
|
||||
ctaButtonDisabled: {
|
||||
opacity: 0.6,
|
||||
},
|
||||
ctaGradient: {
|
||||
paddingVertical: SPACING[4],
|
||||
alignItems: 'center',
|
||||
},
|
||||
ctaText: {
|
||||
fontSize: 17,
|
||||
fontWeight: '600',
|
||||
color: '#FFF',
|
||||
},
|
||||
footer: {
|
||||
marginTop: SPACING[5],
|
||||
alignItems: 'center',
|
||||
gap: SPACING[4],
|
||||
},
|
||||
restoreText: {
|
||||
fontSize: 14,
|
||||
color: darkColors.text.tertiary,
|
||||
},
|
||||
termsText: {
|
||||
fontSize: 11,
|
||||
color: darkColors.text.tertiary,
|
||||
textAlign: 'center',
|
||||
lineHeight: 18,
|
||||
paddingHorizontal: SPACING[4],
|
||||
},
|
||||
})
|
||||
function createStyles(colors: ThemeColors) {
|
||||
return StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.bg.base,
|
||||
},
|
||||
closeButton: {
|
||||
position: 'absolute',
|
||||
top: SPACING[4],
|
||||
right: SPACING[4],
|
||||
width: 44,
|
||||
height: 44,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 10,
|
||||
},
|
||||
scrollView: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollContent: {
|
||||
paddingHorizontal: SPACING[5],
|
||||
paddingTop: SPACING[8],
|
||||
},
|
||||
header: {
|
||||
alignItems: 'center',
|
||||
},
|
||||
title: {
|
||||
fontSize: 32,
|
||||
fontWeight: '700',
|
||||
color: colors.text.primary,
|
||||
textAlign: 'center',
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 16,
|
||||
color: colors.text.secondary,
|
||||
textAlign: 'center',
|
||||
marginTop: SPACING[2],
|
||||
},
|
||||
featuresGrid: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
marginTop: SPACING[6],
|
||||
marginHorizontal: -SPACING[2],
|
||||
},
|
||||
featureItem: {
|
||||
width: '33%',
|
||||
alignItems: 'center',
|
||||
paddingVertical: SPACING[3],
|
||||
},
|
||||
featureIcon: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 24,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: SPACING[2],
|
||||
},
|
||||
featureText: {
|
||||
fontSize: 13,
|
||||
textAlign: 'center',
|
||||
},
|
||||
plansContainer: {
|
||||
marginTop: SPACING[6],
|
||||
gap: SPACING[3],
|
||||
},
|
||||
planCard: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
borderRadius: RADIUS.LG,
|
||||
padding: SPACING[4],
|
||||
borderWidth: 2,
|
||||
},
|
||||
planCardPressed: {
|
||||
opacity: 0.8,
|
||||
},
|
||||
savingsBadge: {
|
||||
position: 'absolute',
|
||||
top: -8,
|
||||
right: SPACING[3],
|
||||
backgroundColor: BRAND.PRIMARY,
|
||||
paddingHorizontal: SPACING[2],
|
||||
paddingVertical: 2,
|
||||
borderRadius: RADIUS.SM,
|
||||
},
|
||||
savingsText: {
|
||||
fontSize: 10,
|
||||
fontWeight: '700',
|
||||
color: colors.text.primary,
|
||||
},
|
||||
planInfo: {
|
||||
flex: 1,
|
||||
},
|
||||
planTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
planPeriod: {
|
||||
fontSize: 13,
|
||||
marginTop: 2,
|
||||
},
|
||||
planPrice: {
|
||||
fontSize: 20,
|
||||
fontWeight: '700',
|
||||
},
|
||||
checkmark: {
|
||||
marginLeft: SPACING[2],
|
||||
},
|
||||
priceNote: {
|
||||
fontSize: 13,
|
||||
textAlign: 'center',
|
||||
marginTop: SPACING[3],
|
||||
},
|
||||
ctaButton: {
|
||||
borderRadius: RADIUS.LG,
|
||||
overflow: 'hidden',
|
||||
marginTop: SPACING[6],
|
||||
},
|
||||
ctaButtonDisabled: {
|
||||
opacity: 0.6,
|
||||
},
|
||||
ctaGradient: {
|
||||
paddingVertical: SPACING[4],
|
||||
alignItems: 'center',
|
||||
},
|
||||
ctaText: {
|
||||
fontSize: 17,
|
||||
fontWeight: '600',
|
||||
},
|
||||
footer: {
|
||||
marginTop: SPACING[5],
|
||||
alignItems: 'center',
|
||||
gap: SPACING[4],
|
||||
},
|
||||
restoreText: {
|
||||
fontSize: 14,
|
||||
},
|
||||
termsText: {
|
||||
fontSize: 11,
|
||||
textAlign: 'center',
|
||||
lineHeight: 18,
|
||||
paddingHorizontal: SPACING[4],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user