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:
@@ -12,4 +12,22 @@
|
||||
| #5054 | " | ✅ | Re-added Host import to home screen | ~184 |
|
||||
| #5043 | 8:22 AM | ✅ | Removed closing Host tag from profile screen | ~210 |
|
||||
| #5042 | " | ✅ | Removed opening Host tag from profile screen | ~164 |
|
||||
| #5041 | " | ✅ | Removed closing Host tag from browse screen | ~187 |
|
||||
| #5040 | " | ✅ | Removed opening Host tag from browse screen | ~159 |
|
||||
| #5039 | 8:21 AM | ✅ | Removed closing Host tag from activity screen | ~193 |
|
||||
| #5038 | " | ✅ | Removed opening Host tag from activity screen | ~154 |
|
||||
| #5037 | " | ✅ | Removed closing Host tag from workouts screen | ~195 |
|
||||
| #5036 | " | ✅ | Removed opening Host tag from workouts screen | ~164 |
|
||||
| #5035 | " | ✅ | Removed closing Host tag from home screen JSX | ~197 |
|
||||
| #5034 | " | ✅ | Removed Host wrapper from home screen JSX | ~139 |
|
||||
| #5031 | 8:19 AM | ✅ | Removed Host import from profile screen | ~184 |
|
||||
| #5030 | " | ✅ | Removed Host import from browse screen | ~190 |
|
||||
| #5029 | 8:18 AM | ✅ | Removed Host import from activity screen | ~183 |
|
||||
| #5028 | " | ✅ | Removed Host import from workouts screen | ~189 |
|
||||
| #5027 | " | ✅ | Removed Host import from home screen index.tsx | ~180 |
|
||||
| #5024 | " | 🔵 | Activity screen properly wraps content with Host component | ~237 |
|
||||
| #5023 | " | 🔵 | Profile screen properly wraps content with Host component | ~246 |
|
||||
| #5022 | 8:14 AM | 🔵 | Browse screen properly wraps content with Host component | ~217 |
|
||||
| #5021 | " | 🔵 | Workouts screen properly wraps content with Host component | ~228 |
|
||||
| #5020 | 8:13 AM | 🔵 | Home screen properly wraps content with Host component | ~238 |
|
||||
</claude-mem-context>
|
||||
@@ -1,40 +1,35 @@
|
||||
/**
|
||||
* TabataFit Profile Screen
|
||||
* React Native + SwiftUI Islands — wired to shared data
|
||||
* SwiftUI-first settings with native iOS look
|
||||
*/
|
||||
|
||||
import { View, StyleSheet, ScrollView, Text as RNText } from 'react-native'
|
||||
import { View, StyleSheet, ScrollView } 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 {
|
||||
Host,
|
||||
List,
|
||||
Section,
|
||||
Switch,
|
||||
Text,
|
||||
LabeledContent,
|
||||
DateTimePicker,
|
||||
Button,
|
||||
VStack,
|
||||
Text,
|
||||
} from '@expo/ui/swift-ui'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useUserStore } from '@/src/shared/stores'
|
||||
import { requestNotificationPermissions, usePurchases } from '@/src/shared/hooks'
|
||||
import { StyledText } from '@/src/shared/components/StyledText'
|
||||
|
||||
import { useThemeColors, BRAND, GRADIENTS } from '@/src/shared/theme'
|
||||
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'
|
||||
|
||||
const FONTS = {
|
||||
LARGE_TITLE: 34,
|
||||
TITLE_2: 22,
|
||||
HEADLINE: 17,
|
||||
SUBHEADLINE: 15,
|
||||
CAPTION_1: 12,
|
||||
}
|
||||
|
||||
@@ -44,6 +39,7 @@ const FONTS = {
|
||||
|
||||
export default function ProfileScreen() {
|
||||
const { t } = useTranslation('screens')
|
||||
const router = useRouter()
|
||||
const insets = useSafeAreaInsets()
|
||||
const colors = useThemeColors()
|
||||
const styles = useMemo(() => createStyles(colors), [colors])
|
||||
@@ -53,7 +49,7 @@ export default function ProfileScreen() {
|
||||
const { restorePurchases } = usePurchases()
|
||||
|
||||
const isPremium = profile.subscription !== 'free'
|
||||
const planLabel = isPremium ? 'TabataFit+' : 'Free'
|
||||
const planLabel = isPremium ? 'TabataFit+' : t('profile.freePlan')
|
||||
|
||||
const handleRestore = async () => {
|
||||
await restorePurchases()
|
||||
@@ -79,7 +75,31 @@ export default function ProfileScreen() {
|
||||
const pickerDate = new Date(today.getFullYear(), today.getMonth(), today.getDate(), rh, rm)
|
||||
const pickerInitial = pickerDate.toISOString()
|
||||
|
||||
const settingsHeight = settings.reminders ? 430 : 385
|
||||
// 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
|
||||
|
||||
// Account section
|
||||
const accountRows = 1 + (isPremium ? 1 : 0) // plan, [+ restore]
|
||||
const accountHeight = 35 + accountRows * 44
|
||||
|
||||
// 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
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { paddingTop: insets.top }]}>
|
||||
@@ -93,76 +113,61 @@ export default function ProfileScreen() {
|
||||
{t('profile.title')}
|
||||
</StyledText>
|
||||
|
||||
{/* Profile Card */}
|
||||
<View style={styles.profileCard}>
|
||||
<BlurView intensity={colors.glass.blurMedium} tint={colors.glass.blurTint} style={StyleSheet.absoluteFill} />
|
||||
{/* Profile Header Card */}
|
||||
<View style={styles.profileHeader}>
|
||||
<View style={styles.avatarContainer}>
|
||||
<LinearGradient
|
||||
colors={GRADIENTS.CTA}
|
||||
style={StyleSheet.absoluteFill}
|
||||
/>
|
||||
<StyledText size={FONTS.LARGE_TITLE} weight="bold" color="#FFFFFF">
|
||||
{profile.name[0]}
|
||||
{profile.name?.[0] || '?'}
|
||||
</StyledText>
|
||||
</View>
|
||||
<StyledText size={FONTS.TITLE_2} weight="semibold" color={colors.text.primary}>
|
||||
{profile.name}
|
||||
</StyledText>
|
||||
<StyledText size={FONTS.SUBHEADLINE} color={colors.text.tertiary}>
|
||||
{profile.email}
|
||||
</StyledText>
|
||||
{isPremium && (
|
||||
<View style={styles.premiumBadge}>
|
||||
<Ionicons name="star" size={12} color={BRAND.PRIMARY} />
|
||||
<StyledText size={FONTS.CAPTION_1} weight="semibold" color={colors.text.primary}>
|
||||
{planLabel}
|
||||
</StyledText>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Subscription */}
|
||||
{isPremium && (
|
||||
<View style={styles.subscriptionCard}>
|
||||
<LinearGradient
|
||||
colors={GRADIENTS.CTA}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={StyleSheet.absoluteFill}
|
||||
/>
|
||||
<View style={styles.subscriptionContent}>
|
||||
<Ionicons name="ribbon" size={24} color="#FFFFFF" />
|
||||
<View style={styles.subscriptionInfo}>
|
||||
<StyledText size={FONTS.HEADLINE} weight="bold" color="#FFFFFF">
|
||||
<View style={styles.profileInfo}>
|
||||
<StyledText size={FONTS.TITLE_2} weight="semibold" color={colors.text.primary}>
|
||||
{profile.name || t('profile.guest')}
|
||||
</StyledText>
|
||||
{isPremium && (
|
||||
<View style={styles.premiumBadge}>
|
||||
<StyledText size={FONTS.CAPTION_1} weight="semibold" color={BRAND.PRIMARY}>
|
||||
{planLabel}
|
||||
</StyledText>
|
||||
<StyledText size={FONTS.CAPTION_1} color="rgba(255,255,255,0.8)">
|
||||
{t('profile.memberSince', { date: profile.joinDate })}
|
||||
</StyledText>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* SwiftUI Island: Subscription Section */}
|
||||
<Host useViewportSizeMeasurement colorScheme={colors.colorScheme} style={{ height: 60, marginTop: -20 }}>
|
||||
{/* All Settings in Single SwiftUI Island */}
|
||||
<Host useViewportSizeMeasurement colorScheme={colors.colorScheme} style={{ height: totalHeight }}>
|
||||
<List listStyle="insetGrouped" scrollEnabled={false}>
|
||||
<Section title={t('profile.sectionSubscription')}>
|
||||
<Button
|
||||
variant="borderless"
|
||||
onPress={handleRestore}
|
||||
color={colors.text.primary}
|
||||
>
|
||||
{t('profile.restorePurchases')}
|
||||
</Button>
|
||||
{/* 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>
|
||||
</List>
|
||||
</Host>
|
||||
|
||||
{/* SwiftUI Island: Settings */}
|
||||
<Host useViewportSizeMeasurement colorScheme={colors.colorScheme} style={{ height: settingsHeight }}>
|
||||
<List listStyle="insetGrouped" scrollEnabled={false}>
|
||||
<Section title={t('profile.sectionWorkout')}>
|
||||
{/* 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 */}
|
||||
<Section header={t('profile.sectionWorkout')} footer={t('profile.workoutSettingsFooter')}>
|
||||
<Switch
|
||||
label={t('profile.hapticFeedback')}
|
||||
value={settings.haptics}
|
||||
@@ -182,7 +187,9 @@ export default function ProfileScreen() {
|
||||
color={BRAND.PRIMARY}
|
||||
/>
|
||||
</Section>
|
||||
<Section title={t('profile.sectionNotifications')}>
|
||||
|
||||
{/* Notification Settings */}
|
||||
<Section header={t('profile.sectionNotifications')} footer={settings.reminders ? t('profile.reminderFooter') : undefined}>
|
||||
<Switch
|
||||
label={t('profile.dailyReminders')}
|
||||
value={settings.reminders}
|
||||
@@ -201,13 +208,25 @@ export default function ProfileScreen() {
|
||||
</LabeledContent>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* Sign Out */}
|
||||
<Section>
|
||||
<Button variant="borderless" role="destructive" onPress={() => {}}>
|
||||
{t('profile.signOut')}
|
||||
</Button>
|
||||
</Section>
|
||||
</List>
|
||||
</Host>
|
||||
|
||||
{/* Version */}
|
||||
<StyledText size={FONTS.CAPTION_1} color={colors.text.tertiary} style={styles.versionText}>
|
||||
{t('profile.version')}
|
||||
</StyledText>
|
||||
</ScrollView>
|
||||
</View>
|
||||
)
|
||||
@@ -230,61 +249,31 @@ function createStyles(colors: ThemeColors) {
|
||||
paddingHorizontal: LAYOUT.SCREEN_PADDING,
|
||||
},
|
||||
|
||||
// Profile Card
|
||||
profileCard: {
|
||||
borderRadius: RADIUS.GLASS_CARD,
|
||||
overflow: 'hidden',
|
||||
marginBottom: SPACING[6],
|
||||
marginTop: SPACING[4],
|
||||
alignItems: 'center',
|
||||
paddingVertical: SPACING[6],
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border.glass,
|
||||
...colors.shadow.md,
|
||||
},
|
||||
avatarContainer: {
|
||||
width: 80,
|
||||
height: 80,
|
||||
borderRadius: 40,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: SPACING[3],
|
||||
overflow: 'hidden',
|
||||
},
|
||||
premiumBadge: {
|
||||
// Profile Header
|
||||
profileHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: SPACING[5],
|
||||
gap: SPACING[4],
|
||||
},
|
||||
avatarContainer: {
|
||||
width: 60,
|
||||
height: 60,
|
||||
borderRadius: 30,
|
||||
backgroundColor: BRAND.PRIMARY,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
profileInfo: {
|
||||
flex: 1,
|
||||
},
|
||||
premiumBadge: {
|
||||
backgroundColor: 'rgba(255, 107, 53, 0.15)',
|
||||
paddingHorizontal: SPACING[3],
|
||||
paddingVertical: SPACING[1],
|
||||
borderRadius: RADIUS.FULL,
|
||||
marginTop: SPACING[3],
|
||||
gap: SPACING[1],
|
||||
},
|
||||
|
||||
// Subscription Card
|
||||
subscriptionCard: {
|
||||
height: 80,
|
||||
borderRadius: RADIUS.LG,
|
||||
overflow: 'hidden',
|
||||
marginBottom: SPACING[6],
|
||||
...colors.shadow.BRAND_GLOW,
|
||||
},
|
||||
subscriptionContent: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: SPACING[4],
|
||||
gap: SPACING[3],
|
||||
},
|
||||
subscriptionInfo: {
|
||||
flex: 1,
|
||||
},
|
||||
|
||||
// Version Text
|
||||
versionText: {
|
||||
textAlign: 'center',
|
||||
marginTop: SPACING[6],
|
||||
borderRadius: 12,
|
||||
alignSelf: 'flex-start',
|
||||
marginTop: SPACING[1],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user