Update mobile app UI with premium design system

- Add design guidelines to CLAUDE.md
- Redesign navigation with boutique styling
- Add video support to chat with compression
- Update matches/dares/quiz screens with gold/rose accents
- Improve packs browsing with category tabs
- Add SwipeCardPremium component
- Update theme with premium color palette
- Add video cleanup edge function and migration

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Jamie BruntonandClaude Opus 4.5 committed 2026-01-01 20:26:33 +00:00
1 parent feb7657562
commit cac7a2a31d
31 files changed
+6638 -1827

No files matched your search

+126 -46
View File
@@ -44,13 +44,28 @@ interface RadialMenuItem {
icon: keyof typeof Ionicons.glyphMap;
label: string;
angle: number; // degrees from top (0 = top, -45 = left, 45 = right)
color?: string;
variant: 'primary' | 'gold' | 'rose' | 'muted';
}
// Brand color palette for navigation
const NAV_COLORS = {
// Primary brand colors (from logo)
primary: colors.primary,
primaryRgba: 'rgba(233, 69, 96, ',
secondary: colors.secondary,
secondaryRgba: 'rgba(155, 89, 182, ',
// Feature-specific colors
gold: colors.premium.gold,
goldRgba: 'rgba(212, 175, 55, ',
rose: colors.premium.rose,
roseRgba: 'rgba(232, 164, 174, ',
dark: '#0d0d1a',
};
const RADIAL_MENU_ITEMS: RadialMenuItem[] = [
{ id: 'dares', icon: 'flash', label: 'Dares', angle: -50, color: '#FF6B35' },
{ id: 'match', icon: 'flame', label: 'Match', angle: 0 },
{ id: 'quiz', icon: 'help-circle', label: 'Quiz', angle: 50, color: '#7B68EE' },
{ id: 'dares', icon: 'flash', label: 'Dares', angle: -50, variant: 'gold' },
{ id: 'match', icon: 'flame', label: 'Match', angle: 0, variant: 'primary' },
{ id: 'quiz', icon: 'help-circle', label: 'Quiz', angle: 50, variant: 'rose' },
];
const RADIAL_DISTANCE = 100; // Distance from center button
@@ -73,24 +88,26 @@ function PlayTabButton({ accessibilityState }: PlayTabButtonProps) {
return (
<View style={styles.playButtonContainer}>
{/* Outer glow effect */}
{/* Outer glow effect - primary brand */}
<View style={[styles.playButtonGlow, isSelected && styles.playButtonGlowActive]} />
{/* Main circle button */}
{/* Main circle button - premium dark with primary gradient border */}
<Pressable onPress={() => globalMenuToggle?.()}>
<Animated.View style={styles.playButtonCircle}>
<LinearGradient
colors={gradients.primary as [string, string]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.playButtonGradient}
>
<View style={styles.playButtonInner}>
{/* Silk highlight at top - primary gradient */}
<LinearGradient
colors={[`${NAV_COLORS.primaryRgba}0.2)`, `${NAV_COLORS.secondaryRgba}0.1)`, 'transparent']}
style={styles.playButtonHighlight}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
/>
<Ionicons
name={globalMenuOpen ? "close" : "flame"}
size={32}
color={colors.text}
size={28}
color={globalMenuOpen ? colors.textSecondary : NAV_COLORS.primary}
/>
</LinearGradient>
</View>
</Animated.View>
</Pressable>
</View>
@@ -524,8 +541,8 @@ export default function AppLayout() {
tabBarBackground: () => <TabBarBackground />,
tabBarStyle: shouldHideTabBar ? { display: 'none' } : {
position: 'absolute',
backgroundColor: 'rgba(18, 18, 18, 0.85)',
borderTopColor: colors.glass.border,
backgroundColor: 'rgba(13, 13, 26, 0.92)',
borderTopColor: 'rgba(233, 69, 96, 0.2)', // primary brand color
borderTopWidth: 1,
paddingTop: 8,
paddingBottom: Platform.OS === 'ios' ? 28 : 8,
@@ -533,10 +550,11 @@ export default function AppLayout() {
elevation: 0,
},
tabBarActiveTintColor: colors.primary,
tabBarInactiveTintColor: '#fff',
tabBarInactiveTintColor: colors.textSecondary,
tabBarLabelStyle: {
fontSize: 11,
fontWeight: "600",
fontSize: 10,
fontWeight: "500",
letterSpacing: 0.5,
},
}}
>
@@ -574,9 +592,12 @@ export default function AppLayout() {
),
tabBarBadge: (newMatchesCount + unreadCount) > 0 ? (newMatchesCount + unreadCount) : undefined,
tabBarBadgeStyle: {
backgroundColor: colors.primary,
fontSize: 10,
backgroundColor: colors.premium.rose,
fontSize: 9,
fontWeight: '600',
minWidth: 16,
height: 16,
borderRadius: 8,
},
}}
/>
@@ -658,6 +679,43 @@ export default function AppLayout() {
outputRange: [0, translateY],
});
// Brand and feature variant colors
const getVariantStyles = () => {
switch (item.variant) {
case 'primary':
return {
bg: `${NAV_COLORS.primaryRgba}0.15)`,
border: `${NAV_COLORS.primaryRgba}0.35)`,
icon: NAV_COLORS.primary,
label: NAV_COLORS.primary,
};
case 'gold':
return {
bg: `${NAV_COLORS.goldRgba}0.15)`,
border: `${NAV_COLORS.goldRgba}0.35)`,
icon: NAV_COLORS.gold,
label: NAV_COLORS.gold,
};
case 'rose':
return {
bg: `${NAV_COLORS.roseRgba}0.15)`,
border: `${NAV_COLORS.roseRgba}0.35)`,
icon: NAV_COLORS.rose,
label: NAV_COLORS.rose,
};
case 'muted':
default:
return {
bg: 'rgba(255, 255, 255, 0.08)',
border: 'rgba(255, 255, 255, 0.15)',
icon: colors.textSecondary,
label: colors.textSecondary,
};
}
};
const variantStyles = getVariantStyles();
return (
<Animated.View
key={item.id}
@@ -678,15 +736,20 @@ export default function AppLayout() {
>
<View style={[
radialStyles.menuItemCircle,
item.color ? { backgroundColor: item.color } : null,
{
backgroundColor: variantStyles.bg,
borderColor: variantStyles.border,
},
]}>
<Ionicons
name={item.icon}
size={22}
color={colors.text}
color={variantStyles.icon}
/>
</View>
<Text style={radialStyles.menuItemLabel}>{item.label}</Text>
<Text style={[radialStyles.menuItemLabel, { color: variantStyles.label }]}>
{item.label}
</Text>
</Pressable>
</Animated.View>
);
@@ -890,7 +953,7 @@ const styles = StyleSheet.create({
color: colors.textSecondary,
marginTop: 2,
},
// Play button styles
// Play button styles - Premium boutique
playButtonContainer: {
flex: 1,
alignItems: 'center',
@@ -899,23 +962,38 @@ const styles = StyleSheet.create({
},
playButtonGlow: {
position: 'absolute',
width: 72,
height: 72,
borderRadius: 36,
width: 76,
height: 76,
borderRadius: 38,
backgroundColor: colors.primaryGlow,
opacity: 0.5,
opacity: 0.4,
},
playButtonGlowActive: {
opacity: 1,
opacity: 0.7,
...shadows.glow(colors.primary),
},
playButtonCircle: {
width: 60,
height: 60,
borderRadius: 30,
width: 64,
height: 64,
borderRadius: 32,
overflow: 'hidden',
borderWidth: 1.5,
borderColor: 'rgba(233, 69, 96, 0.4)', // primary brand
...shadows.lg,
},
playButtonInner: {
flex: 1,
backgroundColor: '#0d0d1a',
alignItems: 'center',
justifyContent: 'center',
},
playButtonHighlight: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: '50%',
},
playButtonGradient: {
flex: 1,
alignItems: 'center',
@@ -923,13 +1001,13 @@ const styles = StyleSheet.create({
},
});
// Radial menu styles
// Radial menu styles - Premium boutique
const TAB_BAR_HEIGHT = Platform.OS === 'ios' ? 88 : 64;
const radialStyles = StyleSheet.create({
overlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0, 0, 0, 0.6)',
backgroundColor: 'rgba(13, 13, 26, 0.85)',
zIndex: 998,
},
menuContainer: {
@@ -948,21 +1026,23 @@ const radialStyles = StyleSheet.create({
alignItems: 'center',
},
menuItemCircle: {
width: 52,
height: 52,
borderRadius: 26,
backgroundColor: colors.primary,
width: 56,
height: 56,
borderRadius: 28,
backgroundColor: 'rgba(22, 33, 62, 0.9)',
justifyContent: 'center',
alignItems: 'center',
...shadows.lg,
borderWidth: 2,
borderColor: 'rgba(255, 255, 255, 0.2)',
...shadows.md,
borderWidth: 1.5,
borderColor: 'rgba(255, 255, 255, 0.15)',
},
menuItemLabel: {
...typography.caption1,
...typography.caption2,
color: colors.text,
marginTop: 6,
fontWeight: '600',
marginTop: 8,
fontWeight: '500',
letterSpacing: 1,
textTransform: 'uppercase',
textShadowColor: 'rgba(0, 0, 0, 0.5)',
textShadowOffset: { width: 0, height: 1 },
textShadowRadius: 2,
File diff suppressed because it is too large. Load diff
+35 -51
View File
@@ -33,11 +33,6 @@ export default function DaresScreen() {
entering={FadeInDown.duration(600).springify()}
style={styles.content}
>
{/* Icon */}
<View style={styles.iconContainer}>
<Ionicons name="flame" size={36} color={ACCENT_COLOR} />
</View>
{/* Title section */}
<Text style={styles.label}>ADVENTURE</Text>
<Text style={styles.title}>Dares</Text>
@@ -81,40 +76,39 @@ export default function DaresScreen() {
{/* Bottom teaser */}
<Text style={styles.teaser}>Are you ready to play?</Text>
{/* Interest button */}
{isAuthenticated && !isLoading && (
<Animated.View
entering={FadeIn.delay(500).duration(400)}
style={styles.interestButtonContainer}
>
<AnimatedPressable
onPress={toggleInterest}
onPressIn={handlePressIn}
onPressOut={handlePressOut}
disabled={isToggling}
style={[
styles.interestButton,
isInterested && styles.interestButtonActive,
animatedButtonStyle,
]}
>
{isToggling ? (
<ActivityIndicator size="small" color={ACCENT_COLOR} />
) : (
<>
<Ionicons
name={isInterested ? "checkmark-circle" : "notifications-outline"}
size={16}
color={ACCENT_COLOR}
/>
<Text style={styles.interestButtonText}>
{isInterested ? "You're on the list!" : "I'm interested"}
</Text>
</>
)}
</AnimatedPressable>
</Animated.View>
)}
{/* Interest button - always reserve space to prevent layout shift */}
<View style={styles.interestButtonContainer}>
{isAuthenticated && !isLoading && (
<Animated.View entering={FadeIn.delay(500).duration(400)}>
<AnimatedPressable
onPress={toggleInterest}
onPressIn={handlePressIn}
onPressOut={handlePressOut}
disabled={isToggling}
style={[
styles.interestButton,
isInterested && styles.interestButtonActive,
animatedButtonStyle,
]}
>
{isToggling ? (
<ActivityIndicator size="small" color={ACCENT_COLOR} />
) : (
<>
<Ionicons
name={isInterested ? "checkmark-circle" : "notifications-outline"}
size={16}
color={ACCENT_COLOR}
/>
<Text style={styles.interestButtonText}>
{isInterested ? "You're on the list!" : "I'm interested"}
</Text>
</>
)}
</AnimatedPressable>
</Animated.View>
)}
</View>
</Animated.View>
</View>
</GradientBackground>
@@ -127,7 +121,7 @@ const styles = StyleSheet.create({
justifyContent: 'center',
alignItems: 'center',
paddingHorizontal: spacing.lg,
paddingTop: Platform.OS === 'ios' ? 60 : 40,
paddingTop: 60,
paddingBottom: Platform.OS === 'ios' ? 120 : 100,
},
content: {
@@ -135,17 +129,6 @@ const styles = StyleSheet.create({
alignItems: 'center',
paddingHorizontal: spacing.md,
},
iconContainer: {
width: 72,
height: 72,
borderRadius: 36,
backgroundColor: 'rgba(212, 175, 55, 0.1)',
borderWidth: 1,
borderColor: 'rgba(212, 175, 55, 0.2)',
justifyContent: 'center',
alignItems: 'center',
marginBottom: spacing.xl,
},
label: {
...typography.caption1,
fontWeight: '600',
@@ -223,6 +206,7 @@ const styles = StyleSheet.create({
},
interestButtonContainer: {
marginTop: spacing.xl,
minHeight: 44,
},
interestButton: {
flexDirection: 'row',
+256 -197
View File
@@ -3,6 +3,7 @@ import { View, Text, StyleSheet, TouchableOpacity, ScrollView, Platform, useWind
import Animated, {
FadeInDown,
FadeInRight,
FadeIn,
useSharedValue,
useAnimatedStyle,
withRepeat,
@@ -17,6 +18,7 @@ import { GradientBackground, GlassCard } from "../../src/components/ui";
import { colors, gradients, spacing, radius, typography, shadows } from "../../src/theme";
const MAX_CONTENT_WIDTH = 500;
const ACCENT_COLOR = colors.primary;
export default function HomeScreen() {
const { user, partner, couple } = useAuthStore();
@@ -35,12 +37,18 @@ export default function HomeScreen() {
// Subtle pulsating animation for the CTA icon
const pulseScale = useSharedValue(1);
const glowOpacity = useSharedValue(0.3);
useEffect(() => {
const startPulse = () => {
pulseScale.value = 1;
pulseScale.value = withRepeat(
withTiming(1.1, { duration: 1000, easing: Easing.inOut(Easing.ease) }),
withTiming(1.1, { duration: 1500, easing: Easing.inOut(Easing.ease) }),
-1,
true
);
glowOpacity.value = withRepeat(
withTiming(0.6, { duration: 1500, easing: Easing.inOut(Easing.ease) }),
-1,
true
);
@@ -49,11 +57,13 @@ export default function HomeScreen() {
return () => clearTimeout(timeout);
}, []);
const pulseAnimatedStyle = useAnimatedStyle(() => {
return {
transform: [{ scale: pulseScale.value }],
};
});
const pulseAnimatedStyle = useAnimatedStyle(() => ({
transform: [{ scale: pulseScale.value }],
}));
const glowAnimatedStyle = useAnimatedStyle(() => ({
opacity: glowOpacity.value,
}));
return (
<GradientBackground>
@@ -66,57 +76,53 @@ export default function HomeScreen() {
showsVerticalScrollIndicator={false}
>
<View style={[styles.innerContainer, isWideScreen && styles.innerContainerWide]}>
{/* Header */}
{/* Premium Header */}
<Animated.View
entering={FadeInDown.delay(100).duration(500)}
entering={FadeInDown.delay(100).duration(600).springify()}
style={styles.header}
>
<View style={styles.headerLeft}>
<Text style={styles.greeting}>
Hey, {user?.name || "there"}
</Text>
{partner ? (
<View style={styles.partnerBadge}>
<Ionicons name="heart" size={12} color={colors.primary} />
<Text style={styles.partnerText}>
{partner.name || partner.email?.split('@')[0] || 'Partner'}
</Text>
</View>
) : couple ? (
<TouchableOpacity
style={styles.waitingBadge}
onPress={() => router.push("/(app)/pairing")}
>
<Ionicons name="hourglass-outline" size={12} color={colors.primary} />
<Text style={styles.waitingText}>Waiting for partner</Text>
</TouchableOpacity>
) : (
<TouchableOpacity
style={styles.pairBadge}
onPress={() => router.push("/(app)/pairing")}
>
<Ionicons name="add-circle-outline" size={12} color={colors.textTertiary} />
<Text style={styles.pairText}>Pair with partner</Text>
</TouchableOpacity>
)}
{/* Greeting section */}
<Text style={styles.label}>WELCOME BACK</Text>
<Text style={styles.greeting}>
{user?.name || "Beautiful"}
</Text>
{/* Decorative separator */}
<View style={styles.separator}>
<View style={styles.separatorLine} />
<View style={styles.separatorDiamond} />
<View style={styles.separatorLine} />
</View>
<TouchableOpacity
onPress={() => router.push("/(app)/profile")}
activeOpacity={0.8}
>
<LinearGradient
colors={gradients.primary as [string, string]}
style={styles.avatarGradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
{/* Partner badge */}
{partner ? (
<Animated.View
entering={FadeIn.delay(300).duration(400)}
style={styles.partnerBadge}
>
<View style={styles.avatarInner}>
<Text style={styles.avatarText}>
{user?.name?.[0]?.toUpperCase() || "U"}
</Text>
</View>
</LinearGradient>
</TouchableOpacity>
<Ionicons name="heart" size={12} color={ACCENT_COLOR} />
<Text style={styles.partnerText}>
Connected with {partner.name || partner.email?.split('@')[0] || 'Partner'}
</Text>
</Animated.View>
) : couple ? (
<TouchableOpacity
style={styles.waitingBadge}
onPress={() => router.push("/(app)/pairing")}
>
<Ionicons name="hourglass-outline" size={12} color={ACCENT_COLOR} />
<Text style={styles.waitingText}>Waiting for partner</Text>
</TouchableOpacity>
) : (
<TouchableOpacity
style={styles.pairBadge}
onPress={() => router.push("/(app)/pairing")}
>
<Ionicons name="add-circle-outline" size={12} color={colors.textTertiary} />
<Text style={styles.pairText}>Connect with your partner</Text>
</TouchableOpacity>
)}
</Animated.View>
{/* Stats Row */}
@@ -129,24 +135,17 @@ export default function HomeScreen() {
onPress={() => router.push("/(app)/matches")}
activeOpacity={0.7}
>
<GlassCard>
<View style={styles.statContent}>
<LinearGradient
colors={matches.length > 0 ? gradients.primary as [string, string] : [colors.glass.background, colors.glass.background]}
style={styles.statIcon}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
<Ionicons
name="heart"
size={18}
color={matches.length > 0 ? colors.text : colors.primary}
/>
</LinearGradient>
<Text style={styles.statNumber}>{matches.length}</Text>
<Text style={styles.statLabel}>Matches</Text>
<View style={[styles.statCard, matches.length > 0 && styles.statCardActive]}>
<View style={[styles.statIcon, matches.length > 0 && styles.statIconActive]}>
<Ionicons
name="heart"
size={18}
color={matches.length > 0 ? colors.text : ACCENT_COLOR}
/>
</View>
</GlassCard>
<Text style={styles.statNumber}>{matches.length}</Text>
<Text style={styles.statLabel}>Matches</Text>
</View>
</TouchableOpacity>
<TouchableOpacity
@@ -154,24 +153,17 @@ export default function HomeScreen() {
onPress={() => router.push("/(app)/packs")}
activeOpacity={0.7}
>
<GlassCard>
<View style={styles.statContent}>
<LinearGradient
colors={enabledPacksCount > 0 ? gradients.primary as [string, string] : [colors.glass.background, colors.glass.background]}
style={styles.statIcon}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
<Ionicons
name="layers"
size={18}
color={enabledPacksCount > 0 ? colors.text : colors.primary}
/>
</LinearGradient>
<Text style={styles.statNumber}>{enabledPacksCount}</Text>
<Text style={styles.statLabel}>Packs</Text>
<View style={[styles.statCard, enabledPacksCount > 0 && styles.statCardActive]}>
<View style={[styles.statIcon, enabledPacksCount > 0 && styles.statIconActive]}>
<Ionicons
name="layers"
size={18}
color={enabledPacksCount > 0 ? colors.text : ACCENT_COLOR}
/>
</View>
</GlassCard>
<Text style={styles.statNumber}>{enabledPacksCount}</Text>
<Text style={styles.statLabel}>Packs</Text>
</View>
</TouchableOpacity>
<TouchableOpacity
@@ -179,24 +171,17 @@ export default function HomeScreen() {
onPress={() => router.push("/(app)/matches")}
activeOpacity={0.7}
>
<GlassCard>
<View style={styles.statContent}>
<LinearGradient
colors={newMatchesCount > 0 ? gradients.primary as [string, string] : [colors.glass.background, colors.glass.background]}
style={styles.statIcon}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
<Ionicons
name="sparkles"
size={18}
color={newMatchesCount > 0 ? colors.text : colors.primary}
/>
</LinearGradient>
<Text style={styles.statNumber}>{newMatchesCount}</Text>
<Text style={styles.statLabel}>New</Text>
<View style={[styles.statCard, newMatchesCount > 0 && styles.statCardActive]}>
<View style={[styles.statIcon, newMatchesCount > 0 && styles.statIconActive]}>
<Ionicons
name="sparkles"
size={18}
color={newMatchesCount > 0 ? colors.text : ACCENT_COLOR}
/>
</View>
</GlassCard>
<Text style={styles.statNumber}>{newMatchesCount}</Text>
<Text style={styles.statLabel}>New</Text>
</View>
</TouchableOpacity>
</Animated.View>
@@ -209,22 +194,28 @@ export default function HomeScreen() {
onPress={() => router.push("/(app)/swipe")}
activeOpacity={0.9}
>
<LinearGradient
colors={gradients.primary as [string, string]}
style={styles.ctaCard}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
<View style={styles.ctaContent}>
<Text style={styles.ctaTitle}>Ready to explore?</Text>
<Text style={styles.ctaSubtitle}>
Swipe through questions and discover what you both enjoy
</Text>
</View>
<Animated.View style={[styles.ctaIconContainer, pulseAnimatedStyle]}>
<Ionicons name="flame" size={24} color={colors.text} />
</Animated.View>
</LinearGradient>
<View style={styles.ctaCard}>
{/* Glow effect */}
<Animated.View style={[styles.ctaGlow, glowAnimatedStyle]} />
<LinearGradient
colors={gradients.primary as [string, string]}
style={styles.ctaGradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
<View style={styles.ctaContent}>
<Text style={styles.ctaLabel}>EXPLORE</Text>
<Text style={styles.ctaTitle}>Ready to explore?</Text>
<Text style={styles.ctaSubtitle}>
Discover new experiences
</Text>
</View>
<Animated.View style={[styles.ctaIconContainer, pulseAnimatedStyle]}>
<Ionicons name="flame" size={28} color={colors.text} />
</Animated.View>
</LinearGradient>
</View>
</TouchableOpacity>
</Animated.View>
@@ -235,13 +226,18 @@ export default function HomeScreen() {
style={styles.section}
>
<View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}>Recent Matches</Text>
<View style={styles.sectionTitleRow}>
<View style={styles.sectionIcon}>
<Ionicons name="heart" size={14} color={ACCENT_COLOR} />
</View>
<Text style={styles.sectionTitle}>Recent Matches</Text>
</View>
<TouchableOpacity
onPress={() => router.push("/(app)/matches")}
style={styles.seeAllButton}
>
<Text style={styles.seeAllText}>See all</Text>
<Ionicons name="chevron-forward" size={14} color={colors.primary} />
<Ionicons name="chevron-forward" size={14} color={ACCENT_COLOR} />
</TouchableOpacity>
</View>
{recentMatches.map((match, index) => (
@@ -253,7 +249,7 @@ export default function HomeScreen() {
onPress={() => router.push(`/chat/${match.id}`)}
activeOpacity={0.7}
>
<GlassCard style={styles.matchCard}>
<View style={styles.matchCard}>
<View style={styles.matchRow}>
<LinearGradient
colors={gradients.primary as [string, string]}
@@ -261,14 +257,14 @@ export default function HomeScreen() {
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
<Ionicons name="heart" size={16} color={colors.text} />
<Ionicons name="heart" size={14} color={colors.text} />
</LinearGradient>
<Text style={styles.matchText} numberOfLines={1}>
{(match as any).question?.text || "A new match!"}
</Text>
{match.is_new && <View style={styles.newBadge} />}
</View>
</GlassCard>
</View>
</TouchableOpacity>
</Animated.View>
))}
@@ -281,40 +277,48 @@ export default function HomeScreen() {
entering={FadeInDown.delay(400).duration(500)}
style={styles.section}
>
<Text style={styles.tipsTitle}>Getting Started</Text>
<GlassCard>
<View style={styles.sectionHeader}>
<View style={styles.sectionTitleRow}>
<View style={styles.sectionIcon}>
<Ionicons name="compass" size={14} color={ACCENT_COLOR} />
</View>
<Text style={styles.sectionTitle}>Getting Started</Text>
</View>
</View>
<View style={styles.tipCard}>
<View style={styles.tipRow}>
<View style={styles.tipIcon}>
<Ionicons name="layers-outline" size={18} color={colors.primary} />
<Ionicons name="layers-outline" size={18} color={ACCENT_COLOR} />
</View>
<View style={styles.tipContent}>
<Text style={styles.tipText}>Enable question packs</Text>
<Text style={styles.tipSubtext}>Choose topics you want to explore</Text>
<Text style={styles.tipSubtext}>Choose topics to explore together</Text>
</View>
</View>
</GlassCard>
<GlassCard style={styles.tipCard}>
</View>
<View style={[styles.tipCard, styles.tipCardSpaced]}>
<View style={styles.tipRow}>
<View style={styles.tipIcon}>
<Ionicons name="swap-horizontal-outline" size={18} color={colors.primary} />
<Ionicons name="swap-horizontal-outline" size={18} color={ACCENT_COLOR} />
</View>
<View style={styles.tipContent}>
<Text style={styles.tipText}>Swipe on questions</Text>
<Text style={styles.tipSubtext}>Answer yes, no, or maybe</Text>
</View>
</View>
</GlassCard>
<GlassCard style={styles.tipCard}>
</View>
<View style={[styles.tipCard, styles.tipCardSpaced]}>
<View style={styles.tipRow}>
<View style={styles.tipIcon}>
<Ionicons name="heart-outline" size={18} color={colors.primary} />
<Ionicons name="heart-outline" size={18} color={ACCENT_COLOR} />
</View>
<View style={styles.tipContent}>
<Text style={styles.tipText}>Discover matches</Text>
<Text style={styles.tipSubtext}>See where you both agree</Text>
</View>
</View>
</GlassCard>
</View>
</Animated.View>
)}
@@ -346,27 +350,51 @@ const styles = StyleSheet.create({
header: {
paddingTop: 60,
paddingHorizontal: spacing.lg,
paddingBottom: spacing.lg,
flexDirection: 'row',
justifyContent: 'space-between',
paddingBottom: spacing.xl,
alignItems: 'center',
},
headerLeft: {
flex: 1,
label: {
...typography.caption2,
fontWeight: '600',
letterSpacing: 2.5,
color: ACCENT_COLOR,
textAlign: 'center',
marginBottom: spacing.xs,
},
greeting: {
...typography.title1,
...typography.largeTitle,
color: colors.text,
textAlign: 'center',
},
separator: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
marginVertical: spacing.md,
width: 120,
},
separatorLine: {
flex: 1,
height: 1,
backgroundColor: 'rgba(233, 69, 96, 0.3)',
},
separatorDiamond: {
width: 5,
height: 5,
backgroundColor: ACCENT_COLOR,
transform: [{ rotate: '45deg' }],
marginHorizontal: spacing.sm,
opacity: 0.6,
},
partnerBadge: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.primaryLight,
paddingHorizontal: spacing.sm,
paddingVertical: spacing.xs,
backgroundColor: 'rgba(233, 69, 96, 0.1)',
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: radius.full,
alignSelf: 'flex-start',
marginTop: spacing.sm,
borderWidth: 1,
borderColor: 'rgba(233, 69, 96, 0.2)',
gap: spacing.xs,
},
partnerText: {
@@ -378,11 +406,11 @@ const styles = StyleSheet.create({
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.glass.background,
paddingHorizontal: spacing.sm,
paddingVertical: spacing.xs,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: radius.full,
alignSelf: 'flex-start',
marginTop: spacing.sm,
borderWidth: 1,
borderColor: colors.glass.border,
gap: spacing.xs,
},
pairText: {
@@ -392,12 +420,12 @@ const styles = StyleSheet.create({
waitingBadge: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.primaryLight,
paddingHorizontal: spacing.sm,
paddingVertical: spacing.xs,
backgroundColor: 'rgba(233, 69, 96, 0.1)',
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: radius.full,
alignSelf: 'flex-start',
marginTop: spacing.sm,
borderWidth: 1,
borderColor: 'rgba(233, 69, 96, 0.2)',
gap: spacing.xs,
},
waitingText: {
@@ -405,26 +433,6 @@ const styles = StyleSheet.create({
color: colors.text,
fontWeight: '500',
},
avatarGradient: {
width: 48,
height: 48,
borderRadius: 24,
justifyContent: 'center',
alignItems: 'center',
...shadows.md,
},
avatarInner: {
width: 42,
height: 42,
borderRadius: 21,
backgroundColor: colors.background,
justifyContent: 'center',
alignItems: 'center',
},
avatarText: {
...typography.headline,
color: colors.text,
},
statsContainer: {
flexDirection: "row",
paddingHorizontal: spacing.lg,
@@ -433,18 +441,30 @@ const styles = StyleSheet.create({
statCardWrapper: {
flex: 1,
},
statContent: {
statCard: {
alignItems: "center",
paddingVertical: spacing.sm,
paddingVertical: spacing.md,
backgroundColor: 'rgba(233, 69, 96, 0.05)',
borderRadius: radius.lg,
borderWidth: 1,
borderColor: 'rgba(233, 69, 96, 0.1)',
},
statCardActive: {
backgroundColor: 'rgba(233, 69, 96, 0.1)',
borderColor: 'rgba(233, 69, 96, 0.2)',
},
statIcon: {
width: 36,
height: 36,
borderRadius: 18,
backgroundColor: 'rgba(233, 69, 96, 0.1)',
justifyContent: "center",
alignItems: "center",
marginBottom: spacing.sm,
},
statIconActive: {
backgroundColor: ACCENT_COLOR,
},
statNumber: {
...typography.title2,
color: colors.text,
@@ -456,9 +476,23 @@ const styles = StyleSheet.create({
},
ctaSection: {
paddingHorizontal: spacing.lg,
marginTop: spacing.lg,
marginTop: spacing.xl,
},
ctaCard: {
position: 'relative',
borderRadius: radius.xl,
overflow: 'hidden',
},
ctaGlow: {
position: 'absolute',
top: -20,
left: -20,
right: -20,
bottom: -20,
backgroundColor: ACCENT_COLOR,
borderRadius: radius.xl + 20,
},
ctaGradient: {
borderRadius: radius.xl,
padding: spacing.lg,
flexDirection: "row",
@@ -468,21 +502,29 @@ const styles = StyleSheet.create({
ctaContent: {
flex: 1,
},
ctaLabel: {
...typography.caption2,
fontWeight: '600',
letterSpacing: 2,
color: 'rgba(255,255,255,0.8)',
marginBottom: spacing.xs,
},
ctaTitle: {
...typography.title3,
...typography.title2,
color: colors.text,
marginBottom: spacing.xs,
},
ctaSubtitle: {
...typography.subhead,
color: "rgba(255,255,255,0.8)",
lineHeight: 20,
},
ctaIconContainer: {
width: 48,
height: 48,
borderRadius: 24,
width: 56,
height: 56,
borderRadius: 28,
backgroundColor: "rgba(255,255,255,0.2)",
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.3)',
justifyContent: "center",
alignItems: "center",
marginLeft: spacing.md,
@@ -497,6 +539,19 @@ const styles = StyleSheet.create({
alignItems: "center",
marginBottom: spacing.md,
},
sectionTitleRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
},
sectionIcon: {
width: 24,
height: 24,
borderRadius: 12,
backgroundColor: 'rgba(233, 69, 96, 0.1)',
justifyContent: 'center',
alignItems: 'center',
},
sectionTitle: {
...typography.headline,
color: colors.text,
@@ -508,19 +563,24 @@ const styles = StyleSheet.create({
},
seeAllText: {
...typography.subhead,
color: colors.primary,
color: ACCENT_COLOR,
},
matchCard: {
marginBottom: spacing.sm,
backgroundColor: 'rgba(233, 69, 96, 0.05)',
borderRadius: radius.md,
borderWidth: 1,
borderColor: 'rgba(233, 69, 96, 0.1)',
padding: spacing.md,
},
matchRow: {
flexDirection: "row",
alignItems: "center",
},
matchIcon: {
width: 32,
height: 32,
borderRadius: 16,
width: 28,
height: 28,
borderRadius: 14,
justifyContent: "center",
alignItems: "center",
marginRight: spacing.md,
@@ -534,17 +594,16 @@ const styles = StyleSheet.create({
width: 8,
height: 8,
borderRadius: 4,
backgroundColor: colors.primary,
},
tipsTitle: {
...typography.caption1,
color: colors.textTertiary,
textTransform: 'uppercase',
letterSpacing: 1.5,
marginBottom: spacing.sm,
marginLeft: spacing.xs,
backgroundColor: ACCENT_COLOR,
},
tipCard: {
backgroundColor: 'rgba(233, 69, 96, 0.05)',
borderRadius: radius.md,
borderWidth: 1,
borderColor: 'rgba(233, 69, 96, 0.1)',
padding: spacing.md,
},
tipCardSpaced: {
marginTop: spacing.sm,
},
tipRow: {
@@ -555,7 +614,7 @@ const styles = StyleSheet.create({
width: 36,
height: 36,
borderRadius: 18,
backgroundColor: colors.primaryLight,
backgroundColor: 'rgba(233, 69, 96, 0.1)',
justifyContent: 'center',
alignItems: 'center',
marginRight: spacing.md,
+416 -144
View File
@@ -5,12 +5,30 @@ import { useFocusEffect } from "expo-router";
import { Ionicons } from "@expo/vector-icons";
import { useRouter } from "expo-router";
import { LinearGradient } from "expo-linear-gradient";
import Animated, { FadeInDown, FadeInRight } from "react-native-reanimated";
import { GradientBackground, GlassCard } from "../../src/components/ui";
import Animated, {
FadeIn,
FadeInDown,
FadeInRight,
FadeInUp,
useSharedValue,
useAnimatedStyle,
withRepeat,
withSequence,
withTiming,
interpolate,
Easing,
} from "react-native-reanimated";
import { GradientBackground, GlassCard, GlassButton } from "../../src/components/ui";
import { colors, gradients, spacing, typography, radius, shadows } from "../../src/theme";
import MatchesTutorial from "../../src/components/MatchesTutorial";
import { hasSeenMatchesTutorial, markMatchesTutorialSeen } from "../../src/lib/matchesTutorialSeen";
// Premium color palette
const ACCENT = colors.premium.gold;
const ACCENT_RGBA = 'rgba(212, 175, 55, ';
const ROSE = colors.premium.rose;
const ROSE_RGBA = 'rgba(232, 164, 174, ';
const MAX_CONTENT_WIDTH = 500;
export default function MatchesScreen() {
@@ -21,6 +39,60 @@ export default function MatchesScreen() {
const isWideScreen = width > MAX_CONTENT_WIDTH;
const [showTutorial, setShowTutorial] = useState(false);
// Ambient orb breathing animations
const orbBreathing1 = useSharedValue(0);
const orbBreathing2 = useSharedValue(0);
const orbDrift = useSharedValue(0);
useEffect(() => {
// Primary orb breathing - 6 second cycle
orbBreathing1.value = withRepeat(
withSequence(
withTiming(1, { duration: 3000, easing: Easing.inOut(Easing.sin) }),
withTiming(0, { duration: 3000, easing: Easing.inOut(Easing.sin) })
),
-1,
true
);
// Secondary orb breathing - offset timing for variation
orbBreathing2.value = withRepeat(
withSequence(
withTiming(0, { duration: 2000, easing: Easing.inOut(Easing.sin) }),
withTiming(1, { duration: 4000, easing: Easing.inOut(Easing.sin) }),
withTiming(0, { duration: 2000, easing: Easing.inOut(Easing.sin) })
),
-1,
true
);
// Subtle vertical drift - 8 second cycle
orbDrift.value = withRepeat(
withSequence(
withTiming(1, { duration: 4000, easing: Easing.inOut(Easing.sin) }),
withTiming(0, { duration: 4000, easing: Easing.inOut(Easing.sin) })
),
-1,
true
);
}, []);
const orbStyle1 = useAnimatedStyle(() => ({
opacity: interpolate(orbBreathing1.value, [0, 1], [0.25, 0.5]),
transform: [
{ translateY: interpolate(orbDrift.value, [0, 1], [0, -20]) },
{ scale: interpolate(orbBreathing1.value, [0, 1], [1, 1.1]) },
],
}));
const orbStyle2 = useAnimatedStyle(() => ({
opacity: interpolate(orbBreathing2.value, [0, 1], [0.2, 0.4]),
transform: [
{ translateY: interpolate(orbDrift.value, [0, 1], [20, 0]) },
{ scale: interpolate(orbBreathing2.value, [0, 1], [1, 1.1]) },
],
}));
// Check if tutorial should be shown when screen is focused or matches change
useFocusEffect(
useCallback(() => {
@@ -78,52 +150,77 @@ export default function MatchesScreen() {
onPress={() => router.push(`/chat/${item.id}`)}
activeOpacity={0.8}
>
<GlassCard style={styles.matchCard}>
<View style={styles.matchCardPremium}>
{/* Subtle gradient background */}
<LinearGradient
colors={['rgba(22, 33, 62, 0.6)', 'rgba(13, 13, 26, 0.8)']}
style={StyleSheet.absoluteFill}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
/>
{/* Top silk highlight */}
<LinearGradient
colors={[`${ACCENT_RGBA}0.06)`, 'transparent']}
style={styles.cardSilkHighlight}
start={{ x: 0.5, y: 0 }}
end={{ x: 0.5, y: 1 }}
/>
<View style={styles.matchRow}>
<LinearGradient
colors={isYesYes ? gradients.primary as [string, string] : [colors.glass.background, colors.glass.background]}
style={styles.iconContainer}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
{/* Premium icon container */}
<View style={[
styles.iconContainerPremium,
isYesYes && styles.iconContainerYesYes
]}>
<Ionicons
name={isYesYes ? "heart" : "heart-half"}
size={22}
color={isYesYes ? colors.text : colors.primary}
size={20}
color={isYesYes ? ACCENT : ROSE}
/>
</LinearGradient>
</View>
<View style={styles.content}>
<Text style={styles.questionText} numberOfLines={2}>
<Text style={styles.questionTextPremium} numberOfLines={2}>
{userText}
</Text>
{item.question.partner_text && (
<Text style={styles.partnerText} numberOfLines={1}>
<Text style={styles.partnerTextPremium} numberOfLines={1}>
Partner: {partnerText}
</Text>
)}
<View style={styles.metaRow}>
<View style={[styles.tag, isYesYes && styles.tagHighlight]}>
<Text style={[styles.tagText, isYesYes && styles.tagTextHighlight]}>
<View style={[
styles.tagPremium,
isYesYes && styles.tagPremiumHighlight
]}>
<Text style={[
styles.tagTextPremium,
isYesYes && styles.tagTextPremiumHighlight
]}>
{isYesYes ? "YES + YES" : "YES + MAYBE"}
</Text>
</View>
<Text style={styles.date}>
<Text style={styles.datePremium}>
{new Date(item.created_at).toLocaleDateString()}
</Text>
</View>
</View>
<View style={styles.rightSection}>
{item.unreadCount > 0 && (
<View style={styles.unreadBadge}>
<Ionicons name="chatbubble" size={12} color={colors.text} />
<View style={styles.unreadBadgePremium}>
<Ionicons name="chatbubble" size={10} color={colors.text} />
</View>
)}
<View style={styles.chevronContainer}>
<Ionicons name="chevron-forward" size={18} color={colors.textTertiary} />
<View style={styles.chevronContainerPremium}>
<Ionicons name="chevron-forward" size={16} color={`${ACCENT_RGBA}0.6)`} />
</View>
</View>
</View>
</GlassCard>
{/* Premium border */}
<View style={styles.cardPremiumBorder} pointerEvents="none" />
</View>
</TouchableOpacity>
</Animated.View>
);
@@ -141,21 +238,48 @@ export default function MatchesScreen() {
return (
<GradientBackground>
{/* Ambient Orbs - Premium gold/rose */}
<Animated.View style={[styles.ambientOrb, styles.orbTopRight, orbStyle1]} pointerEvents="none">
<LinearGradient
colors={[colors.premium.goldGlow, 'transparent']}
style={styles.orbGradient}
start={{ x: 0.5, y: 0.5 }}
end={{ x: 1, y: 1 }}
/>
</Animated.View>
<Animated.View style={[styles.ambientOrb, styles.orbBottomLeft, orbStyle2]} pointerEvents="none">
<LinearGradient
colors={[`${ROSE_RGBA}0.2)`, 'transparent']}
style={styles.orbGradient}
start={{ x: 0.5, y: 0.5 }}
end={{ x: 0, y: 0 }}
/>
</Animated.View>
<View style={styles.container}>
{/* Premium Header */}
<Animated.View
entering={FadeInDown.duration(400)}
entering={FadeIn.duration(400)}
style={[styles.header, isWideScreen && styles.headerWide]}
>
<View style={styles.headerTop}>
<Text style={styles.title}>Matches</Text>
<View style={styles.countBadge}>
<Ionicons name="heart" size={14} color={colors.primary} />
<Text style={styles.countText}>{matches.length}</Text>
<View style={styles.headerContent}>
{/* Premium label */}
<Text style={styles.headerLabel}>DISCOVER</Text>
<Text style={styles.headerTitle}>Matches</Text>
{/* Decorative separator */}
<View style={styles.headerSeparator}>
<View style={styles.headerSeparatorLine} />
<View style={styles.headerSeparatorDiamond} />
<View style={styles.headerSeparatorLine} />
</View>
{/* Count badge */}
<View style={styles.countBadgePremium}>
<Ionicons name="heart" size={12} color={ACCENT} />
<Text style={styles.countTextPremium}>{matches.length} {matches.length === 1 ? 'MATCH' : 'MATCHES'}</Text>
</View>
</View>
<Text style={styles.subtitle}>
Discover what you both enjoy together
</Text>
</Animated.View>
<FlatList
@@ -174,38 +298,63 @@ export default function MatchesScreen() {
}
ListEmptyComponent={
<Animated.View
entering={FadeInDown.delay(200).duration(400)}
style={styles.emptyContainer}
entering={FadeInUp.duration(600).springify()}
style={styles.emptyContent}
>
<LinearGradient
colors={gradients.primary as [string, string]}
style={styles.emptyIconGradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
{/* Premium icon */}
<View style={styles.emptyIconContainerPremium}>
<Ionicons name="heart-outline" size={36} color={ROSE} />
</View>
{/* Title section */}
<Text style={styles.emptyLabel}>BEGIN</Text>
<Text style={styles.emptyTitlePremium}>No Matches Yet</Text>
{/* Decorative separator */}
<View style={styles.emptySeparator}>
<View style={styles.emptySeparatorLine} />
<View style={styles.emptySeparatorDiamond} />
<View style={styles.emptySeparatorLine} />
</View>
{/* Status badge */}
<Animated.View
entering={FadeIn.delay(300).duration(400)}
style={styles.emptyBadge}
>
<View style={styles.emptyIconInner}>
<Ionicons name="heart-outline" size={40} color={colors.primary} />
</View>
</LinearGradient>
<Text style={styles.emptyTitle}>No matches yet</Text>
<Text style={styles.emptyText}>
Keep swiping to find things you both enjoy!
<Text style={styles.emptyBadgeText}>START EXPLORING</Text>
</Animated.View>
{/* Description */}
<Text style={styles.emptyDescription}>
Answer questions together to discover what you both enjoy. Matches appear when you agree!
</Text>
<TouchableOpacity
style={styles.emptyButton}
{/* Feature hints */}
<View style={styles.emptyFeatures}>
<View style={styles.emptyFeatureItem}>
<Ionicons name="heart" size={16} color={ROSE} />
<Text style={styles.emptyFeatureText}>Swipe right for yes</Text>
</View>
<View style={styles.emptyFeatureItem}>
<Ionicons name="sparkles" size={16} color={ROSE} />
<Text style={styles.emptyFeatureText}>Match when you both agree</Text>
</View>
<View style={styles.emptyFeatureItem}>
<Ionicons name="chatbubbles-outline" size={16} color={ROSE} />
<Text style={styles.emptyFeatureText}>Chat about your matches</Text>
</View>
</View>
{/* Bottom teaser */}
<Text style={styles.emptyTeaser}>Your first match is just a swipe away</Text>
<GlassButton
onPress={() => router.push("/(app)/swipe")}
activeOpacity={0.8}
style={{ marginTop: spacing.lg }}
>
<LinearGradient
colors={gradients.primary as [string, string]}
style={styles.emptyButtonGradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
<Ionicons name="flame" size={18} color={colors.text} />
<Text style={styles.emptyButtonText}>Start Swiping</Text>
</LinearGradient>
</TouchableOpacity>
Start Swiping
</GlassButton>
</Animated.View>
}
/>
@@ -228,47 +377,93 @@ const styles = StyleSheet.create({
justifyContent: "center",
alignItems: "center",
},
// Ambient orbs
ambientOrb: {
position: 'absolute',
width: 300,
height: 300,
borderRadius: 150,
},
orbTopRight: {
top: 60,
right: -40,
},
orbBottomLeft: {
bottom: 180,
left: -40,
},
orbGradient: {
width: '100%',
height: '100%',
borderRadius: 150,
},
// Premium Header
header: {
paddingTop: 60,
paddingHorizontal: spacing.lg,
paddingBottom: spacing.lg,
paddingBottom: spacing.md,
},
headerWide: {
alignSelf: 'center',
width: '100%',
maxWidth: MAX_CONTENT_WIDTH,
},
headerTop: {
flexDirection: 'row',
headerContent: {
alignItems: 'center',
justifyContent: 'space-between',
},
title: {
...typography.title1,
headerLabel: {
...typography.caption2,
fontWeight: '600',
letterSpacing: 3,
color: ACCENT,
marginBottom: spacing.xs,
},
headerTitle: {
...typography.largeTitle,
color: colors.text,
textAlign: 'center',
},
countBadge: {
headerSeparator: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.primaryLight,
paddingHorizontal: spacing.sm,
paddingVertical: spacing.xs,
justifyContent: 'center',
marginVertical: spacing.md,
width: 100,
},
headerSeparatorLine: {
flex: 1,
height: 1,
backgroundColor: `${ACCENT_RGBA}0.3)`,
},
headerSeparatorDiamond: {
width: 6,
height: 6,
backgroundColor: ACCENT,
transform: [{ rotate: '45deg' }],
marginHorizontal: spacing.sm,
opacity: 0.6,
},
countBadgePremium: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: `${ACCENT_RGBA}0.1)`,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: radius.full,
borderWidth: 1,
borderColor: `${ACCENT_RGBA}0.2)`,
gap: spacing.xs,
},
countText: {
...typography.subhead,
color: colors.text,
countTextPremium: {
...typography.caption2,
fontWeight: '600',
letterSpacing: 2,
color: ACCENT,
},
subtitle: {
...typography.subhead,
color: colors.textSecondary,
marginTop: spacing.xs,
},
// List
list: {
padding: spacing.lg,
paddingTop: 0,
paddingTop: spacing.sm,
paddingBottom: Platform.OS === 'ios' ? 120 : 100,
},
listWide: {
@@ -276,32 +471,60 @@ const styles = StyleSheet.create({
width: '100%',
maxWidth: MAX_CONTENT_WIDTH,
},
matchCard: {
// Premium Match Card
matchCardPremium: {
marginBottom: spacing.sm,
borderRadius: radius.lg,
overflow: 'hidden',
padding: spacing.md,
},
cardSilkHighlight: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: 60,
},
cardPremiumBorder: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
borderRadius: radius.lg,
borderWidth: 1,
borderColor: `${ACCENT_RGBA}0.15)`,
},
matchRow: {
flexDirection: "row",
alignItems: "center",
},
iconContainer: {
width: 48,
height: 48,
borderRadius: 24,
iconContainerPremium: {
width: 44,
height: 44,
borderRadius: 22,
backgroundColor: `${ROSE_RGBA}0.1)`,
borderWidth: 1,
borderColor: `${ROSE_RGBA}0.2)`,
justifyContent: "center",
alignItems: "center",
marginRight: spacing.md,
},
iconContainerYesYes: {
backgroundColor: `${ACCENT_RGBA}0.1)`,
borderColor: `${ACCENT_RGBA}0.2)`,
},
content: {
flex: 1,
},
questionText: {
...typography.body,
questionTextPremium: {
...typography.subhead,
color: colors.text,
fontWeight: "600",
marginBottom: spacing.xs,
lineHeight: 22,
lineHeight: 20,
},
partnerText: {
partnerTextPremium: {
...typography.caption1,
color: colors.textSecondary,
fontStyle: "italic",
@@ -312,27 +535,31 @@ const styles = StyleSheet.create({
alignItems: "center",
justifyContent: "space-between",
},
tag: {
backgroundColor: colors.glass.background,
tagPremium: {
backgroundColor: `${ROSE_RGBA}0.1)`,
paddingHorizontal: spacing.sm,
paddingVertical: 4,
paddingVertical: 3,
borderRadius: radius.sm,
borderWidth: 1,
borderColor: `${ROSE_RGBA}0.15)`,
},
tagHighlight: {
backgroundColor: colors.primaryLight,
tagPremiumHighlight: {
backgroundColor: `${ACCENT_RGBA}0.1)`,
borderColor: `${ACCENT_RGBA}0.2)`,
},
tagText: {
tagTextPremium: {
...typography.caption2,
color: colors.textSecondary,
fontWeight: "700",
letterSpacing: 0.5,
color: ROSE,
fontWeight: "600",
letterSpacing: 1,
},
tagTextHighlight: {
color: colors.primary,
tagTextPremiumHighlight: {
color: ACCENT,
},
date: {
datePremium: {
...typography.caption2,
color: colors.textTertiary,
fontStyle: 'italic',
},
rightSection: {
flexDirection: 'row',
@@ -340,70 +567,115 @@ const styles = StyleSheet.create({
gap: spacing.sm,
marginLeft: spacing.sm,
},
unreadBadge: {
width: 24,
height: 24,
borderRadius: 12,
backgroundColor: colors.primary,
unreadBadgePremium: {
width: 22,
height: 22,
borderRadius: 11,
backgroundColor: ACCENT,
justifyContent: 'center',
alignItems: 'center',
},
chevronContainer: {
chevronContainerPremium: {
width: 28,
height: 28,
borderRadius: 14,
backgroundColor: colors.glass.background,
backgroundColor: `${ACCENT_RGBA}0.08)`,
borderWidth: 1,
borderColor: `${ACCENT_RGBA}0.15)`,
justifyContent: 'center',
alignItems: 'center',
},
emptyContainer: {
alignItems: "center",
paddingVertical: spacing.xxl,
paddingHorizontal: spacing.lg,
// Premium Empty State
emptyContent: {
width: '100%',
alignItems: 'center',
paddingHorizontal: spacing.md,
paddingVertical: spacing.xl,
},
emptyIconGradient: {
width: 100,
height: 100,
borderRadius: 50,
emptyIconContainerPremium: {
width: 72,
height: 72,
borderRadius: 36,
backgroundColor: `${ROSE_RGBA}0.1)`,
borderWidth: 1,
borderColor: `${ROSE_RGBA}0.2)`,
justifyContent: 'center',
alignItems: 'center',
marginBottom: spacing.lg,
...shadows.lg,
},
emptyIconInner: {
width: 88,
height: 88,
borderRadius: 44,
backgroundColor: colors.background,
justifyContent: 'center',
alignItems: 'center',
},
emptyTitle: {
...typography.title2,
color: colors.text,
marginBottom: spacing.sm,
},
emptyText: {
...typography.body,
color: colors.textSecondary,
textAlign: "center",
marginBottom: spacing.xl,
},
emptyButton: {
borderRadius: radius.full,
overflow: 'hidden',
...shadows.md,
emptyLabel: {
...typography.caption1,
fontWeight: '600',
letterSpacing: 3,
color: ROSE,
textAlign: 'center',
marginBottom: spacing.xs,
},
emptyButtonGradient: {
emptyTitlePremium: {
...typography.largeTitle,
color: colors.text,
textAlign: 'center',
},
emptySeparator: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.lg,
paddingVertical: spacing.md,
gap: spacing.sm,
justifyContent: 'center',
marginVertical: spacing.lg,
width: 140,
},
emptyButtonText: {
...typography.body,
color: colors.text,
emptySeparatorLine: {
flex: 1,
height: 1,
backgroundColor: `${ROSE_RGBA}0.3)`,
},
emptySeparatorDiamond: {
width: 6,
height: 6,
backgroundColor: ROSE,
transform: [{ rotate: '45deg' }],
marginHorizontal: spacing.md,
opacity: 0.6,
},
emptyBadge: {
backgroundColor: `${ROSE_RGBA}0.1)`,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
borderRadius: radius.full,
borderWidth: 1,
borderColor: `${ROSE_RGBA}0.2)`,
marginBottom: spacing.xl,
},
emptyBadgeText: {
...typography.caption2,
fontWeight: '600',
letterSpacing: 2,
color: ROSE,
},
emptyDescription: {
...typography.body,
color: colors.textSecondary,
textAlign: 'center',
marginBottom: spacing.xl,
paddingHorizontal: spacing.md,
},
emptyFeatures: {
marginBottom: spacing.xl,
},
emptyFeatureItem: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
marginBottom: spacing.sm,
},
emptyFeatureText: {
...typography.subhead,
color: colors.text,
marginLeft: spacing.sm,
},
emptyTeaser: {
...typography.footnote,
fontStyle: 'italic',
color: colors.textTertiary,
textAlign: 'center',
},
});
+1 -1
View File
@@ -605,7 +605,7 @@ const styles = StyleSheet.create({
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingTop: Platform.OS === 'ios' ? 60 : 40,
paddingTop: 60,
paddingBottom: spacing.md,
paddingHorizontal: spacing.md,
},
-300
View File
@@ -1,300 +0,0 @@
import { View, Text, StyleSheet, ScrollView, RefreshControl, Platform, Alert } from "react-native";
import { usePacksStore, useAuthStore, useSubscriptionStore } from "../../src/store";
import { Ionicons } from "@expo/vector-icons";
import { router, useLocalSearchParams } from "expo-router";
import { useEffect, useMemo, useState, useCallback } from "react";
import Animated, { FadeInDown, FadeIn, Layout } from "react-native-reanimated";
import { GradientBackground } from "../../src/components/ui";
import { Paywall } from "../../src/components/Paywall";
import { PackTeaser } from "../../src/components/PackTeaser";
import { CategoryTabs, FilterType } from "../../src/components/CategoryTabs";
import { BoutiquePackCard } from "../../src/components/BoutiquePackCard";
import { colors, spacing, typography } from "../../src/theme";
import type { QuestionPack } from "../../src/types";
export default function PacksScreen() {
const { category } = useLocalSearchParams<{ category?: string }>();
const { packs, categories, enabledPackIds, togglePack, isLoading, fetchPacks } = usePacksStore();
const { user, partner } = useAuthStore();
const { subscription } = useSubscriptionStore();
const [selectedFilter, setSelectedFilter] = useState<FilterType>('all');
const [showPaywall, setShowPaywall] = useState(false);
const [showTeaser, setShowTeaser] = useState(false);
const [teaserPack, setTeaserPack] = useState<QuestionPack | null>(null);
// Check if user or partner has premium access
const hasPremiumAccess = useMemo(() => {
return user?.is_premium || partner?.is_premium || subscription.isProUser;
}, [user?.is_premium, partner?.is_premium, subscription.isProUser]);
useEffect(() => {
fetchPacks();
}, []);
// Apply category filter from URL param
useEffect(() => {
if (category) {
setSelectedFilter(category);
}
}, [category]);
// Filter packs by selected filter (all, enabled, or category id)
const filteredPacks = useMemo(() => {
if (selectedFilter === 'all') return packs;
if (selectedFilter === 'enabled') return packs.filter(p => enabledPackIds.includes(p.id));
// Otherwise it's a category id
return packs.filter(p => p.category_id === selectedFilter);
}, [packs, selectedFilter, enabledPackIds]);
// Split packs into two columns for masonry layout
const { leftColumn, rightColumn } = useMemo(() => {
const left: { pack: QuestionPack; index: number }[] = [];
const right: { pack: QuestionPack; index: number }[] = [];
let leftHeight = 0;
let rightHeight = 0;
filteredPacks.forEach((pack, index) => {
// Estimate card height based on description length
const baseHeight = 180;
const descLength = pack.description?.length || 0;
const extraHeight = Math.min(Math.ceil(descLength / 40) * 10, 40);
const cardHeight = baseHeight + extraHeight;
if (leftHeight <= rightHeight) {
left.push({ pack, index });
leftHeight += cardHeight;
} else {
right.push({ pack, index });
rightHeight += cardHeight;
}
});
return { leftColumn: left, rightColumn: right };
}, [filteredPacks]);
// Handle toggling a pack - show teaser if premium pack without access
const handleTogglePack = useCallback(async (pack: QuestionPack) => {
if (pack.is_premium && !hasPremiumAccess) {
setTeaserPack(pack);
setShowTeaser(true);
return;
}
const result = await togglePack(pack.id);
if (!result.success && result.reason === "no_couple") {
Alert.alert(
"Pair with Partner",
"You need to pair with your partner before you can customise question packs.",
[
{ text: "Later", style: "cancel" },
{ text: "Pair Now", onPress: () => router.push("/pairing") }
]
);
}
}, [hasPremiumAccess, togglePack]);
// Handle pack press - navigate or show teaser
const handlePackPress = useCallback((pack: QuestionPack) => {
const isPremiumLocked = pack.is_premium && !hasPremiumAccess;
if (isPremiumLocked) {
setTeaserPack(pack);
setShowTeaser(true);
} else {
router.push(`/pack/${pack.id}`);
}
}, [hasPremiumAccess]);
// Handle teaser unlock button - show paywall
const handleTeaserUnlock = useCallback(() => {
setShowTeaser(false);
setShowPaywall(true);
}, []);
// Handle teaser close
const handleTeaserClose = useCallback(() => {
setShowTeaser(false);
setTeaserPack(null);
}, []);
const enabledPacks = packs.filter(p => enabledPackIds.includes(p.id));
const enabledPackCount = enabledPacks.length;
const totalQuestions = enabledPacks.reduce((sum, p) => sum + (p.questions?.[0]?.count || 0), 0);
return (
<GradientBackground>
<View style={styles.container}>
{/* Header */}
<Animated.View
entering={FadeInDown.duration(400)}
style={styles.header}
>
<Text style={styles.title}>Question Packs</Text>
<Text style={styles.subtitle}>
{totalQuestions} {totalQuestions === 1 ? 'question' : 'questions'} from {enabledPackCount} {enabledPackCount === 1 ? 'pack' : 'packs'} enabled
</Text>
</Animated.View>
{/* Category Tabs */}
<Animated.View entering={FadeInDown.delay(100).duration(400)}>
<CategoryTabs
categories={categories}
selectedFilter={selectedFilter}
onSelectFilter={setSelectedFilter}
enabledCount={enabledPackIds.length}
/>
</Animated.View>
{/* Masonry Grid */}
<ScrollView
showsVerticalScrollIndicator={false}
refreshControl={
<RefreshControl
refreshing={isLoading}
onRefresh={fetchPacks}
tintColor={colors.primary}
colors={[colors.primary]}
/>
}
contentContainerStyle={styles.scrollContent}
>
{filteredPacks.length === 0 ? (
<Animated.View
entering={FadeInDown.delay(200).duration(400)}
style={styles.emptyContainer}
>
<View style={styles.emptyIconContainer}>
<Ionicons name="layers-outline" size={48} color={colors.textTertiary} />
</View>
<Text style={styles.emptyTitle}>No packs available</Text>
<Text style={styles.emptyText}>
{selectedFilter === 'enabled'
? "No packs enabled yet. Enable some packs to see them here!"
: selectedFilter !== 'all'
? "No packs in this category. Try selecting a different one."
: "Check back later for new question packs!"}
</Text>
</Animated.View>
) : (
<View style={styles.masonryContainer}>
{/* Left Column */}
<View style={styles.column}>
{leftColumn.map(({ pack, index }, columnIndex) => (
<Animated.View
key={pack.id}
entering={FadeInDown.delay(columnIndex * 80).duration(400)}
layout={Layout.springify()}
>
<BoutiquePackCard
pack={pack}
index={index}
isEnabled={enabledPackIds.includes(pack.id)}
isPremiumLocked={pack.is_premium && !hasPremiumAccess}
onPress={() => handlePackPress(pack)}
onToggle={() => handleTogglePack(pack)}
/>
</Animated.View>
))}
</View>
{/* Right Column */}
<View style={styles.column}>
{rightColumn.map(({ pack, index }, columnIndex) => (
<Animated.View
key={pack.id}
entering={FadeInDown.delay(columnIndex * 80 + 40).duration(400)}
layout={Layout.springify()}
>
<BoutiquePackCard
pack={pack}
index={index}
isEnabled={enabledPackIds.includes(pack.id)}
isPremiumLocked={pack.is_premium && !hasPremiumAccess}
onPress={() => handlePackPress(pack)}
onToggle={() => handleTogglePack(pack)}
/>
</Animated.View>
))}
</View>
</View>
)}
</ScrollView>
{/* Modals */}
{teaserPack && (
<PackTeaser
visible={showTeaser}
packId={teaserPack.id}
packName={teaserPack.name}
packIcon={teaserPack.icon}
onClose={handleTeaserClose}
onUnlock={handleTeaserUnlock}
/>
)}
<Paywall
visible={showPaywall}
onClose={() => setShowPaywall(false)}
onSuccess={() => {
// Refresh packs after successful purchase
fetchPacks();
}}
/>
</View>
</GradientBackground>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
header: {
paddingTop: 60,
paddingHorizontal: spacing.lg,
paddingBottom: spacing.sm,
},
title: {
...typography.title1,
color: colors.text,
},
subtitle: {
...typography.subhead,
color: colors.textSecondary,
marginTop: spacing.xs,
},
scrollContent: {
paddingBottom: Platform.OS === 'ios' ? 120 : 100,
},
masonryContainer: {
flexDirection: 'row',
paddingHorizontal: spacing.md,
gap: spacing.md,
},
column: {
flex: 1,
gap: spacing.md,
},
emptyContainer: {
alignItems: "center",
paddingVertical: spacing.xxl,
paddingHorizontal: spacing.lg,
},
emptyIconContainer: {
width: 80,
height: 80,
borderRadius: 40,
backgroundColor: colors.glass.background,
justifyContent: "center",
alignItems: "center",
marginBottom: spacing.md,
},
emptyTitle: {
...typography.title3,
color: colors.text,
marginBottom: spacing.xs,
},
emptyText: {
...typography.body,
color: colors.textSecondary,
textAlign: "center",
},
});
+15
View File
@@ -0,0 +1,15 @@
import { Stack } from "expo-router";
import { colors } from "../../../src/theme";
export default function PacksLayout() {
return (
<Stack
screenOptions={{
headerShown: false,
contentStyle: { backgroundColor: colors.background },
animation: "fade",
animationDuration: 150,
}}
/>
);
}
+297
View File
@@ -0,0 +1,297 @@
import { View, Text, StyleSheet, Pressable, Platform } from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { router } from "expo-router";
import { useEffect } from "react";
import Animated, { FadeInDown } from "react-native-reanimated";
import { LinearGradient } from "expo-linear-gradient";
import { GradientBackground } from "../../../src/components/ui";
import { usePacksStore } from "../../../src/store";
import { colors, spacing, radius, typography, shadows } from "../../../src/theme";
interface PackTypeCard {
id: string;
title: string;
description: string;
icon: keyof typeof Ionicons.glyphMap;
color: string;
colorRgba: string;
gradient: [string, string];
comingSoon: boolean;
route?: string;
}
const PACK_TYPES: PackTypeCard[] = [
{
id: "questions",
title: "Question Packs",
description: "Explore intimate questions together and discover what you both want",
icon: "layers",
color: colors.primary,
colorRgba: "rgba(233, 69, 96, ",
gradient: ["rgba(233, 69, 96, 0.25)", "rgba(155, 89, 182, 0.25)"],
comingSoon: false,
route: "/(app)/packs/questions",
},
{
id: "dares",
title: "Dare Packs",
description: "Spice things up with exciting dares for adventurous couples",
icon: "flash",
color: colors.premium.gold,
colorRgba: "rgba(212, 175, 55, ",
gradient: ["rgba(212, 175, 55, 0.25)", "rgba(184, 134, 11, 0.25)"],
comingSoon: true,
},
{
id: "quiz",
title: "Quiz Packs",
description: "Test how well you know each other with fun couple quizzes",
icon: "help-circle",
color: colors.premium.rose,
colorRgba: "rgba(232, 164, 174, ",
gradient: ["rgba(232, 164, 174, 0.25)", "rgba(212, 145, 155, 0.25)"],
comingSoon: true,
},
];
export default function PackTypeSelection() {
const { fetchPacks } = usePacksStore();
// Prefetch packs data so it's ready when navigating to questions
useEffect(() => {
fetchPacks();
}, []);
const handlePackTypePress = (packType: PackTypeCard) => {
if (!packType.comingSoon && packType.route) {
router.push(packType.route as any);
}
};
return (
<GradientBackground>
<View style={styles.container}>
{/* Header */}
<Animated.View
entering={FadeInDown.duration(600).springify()}
style={styles.header}
>
<Text style={styles.label}>EXPLORE</Text>
<Text style={styles.title}>Content Packs</Text>
{/* Decorative Separator */}
<View style={styles.separator}>
<View style={styles.separatorLine} />
<View style={styles.separatorDiamond} />
<View style={styles.separatorLine} />
</View>
<Text style={styles.subtitle}>
Choose a pack type to explore
</Text>
</Animated.View>
{/* Pack Type Cards */}
<View style={styles.cardsContainer}>
{PACK_TYPES.map((packType, index) => (
<Animated.View
key={packType.id}
entering={FadeInDown.delay(100 + index * 100).duration(500).springify()}
>
<Pressable
onPress={() => handlePackTypePress(packType)}
style={({ pressed }) => [
styles.card,
pressed && !packType.comingSoon && styles.cardPressed,
packType.comingSoon && styles.cardDisabled,
]}
disabled={packType.comingSoon}
>
<LinearGradient
colors={packType.gradient as [string, string]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.cardGradient}
>
{/* Icon Circle */}
<View
style={[
styles.iconCircle,
{
backgroundColor: `${packType.colorRgba}0.15)`,
borderColor: `${packType.colorRgba}0.3)`,
},
]}
>
<Ionicons
name={packType.icon}
size={32}
color={packType.comingSoon ? colors.textTertiary : packType.color}
/>
</View>
{/* Content */}
<View style={styles.cardContent}>
<View style={styles.cardTitleRow}>
<Text
style={[
styles.cardTitle,
packType.comingSoon && styles.cardTitleDisabled,
]}
>
{packType.title}
</Text>
{packType.comingSoon && (
<View style={styles.comingSoonBadge}>
<Text style={styles.comingSoonText}>Coming Soon</Text>
</View>
)}
</View>
<Text
style={[
styles.cardDescription,
packType.comingSoon && styles.cardDescriptionDisabled,
]}
>
{packType.description}
</Text>
</View>
{/* Arrow */}
{!packType.comingSoon && (
<Ionicons
name="chevron-forward"
size={24}
color={colors.textSecondary}
/>
)}
</LinearGradient>
</Pressable>
</Animated.View>
))}
</View>
</View>
</GradientBackground>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
header: {
paddingTop: 60,
paddingHorizontal: spacing.lg,
paddingBottom: spacing.lg,
alignItems: "center",
},
label: {
...typography.caption1,
fontWeight: "600",
letterSpacing: 3,
color: colors.primary,
textAlign: "center",
marginBottom: spacing.xs,
},
title: {
...typography.title1,
color: colors.text,
textAlign: "center",
},
separator: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
marginVertical: spacing.md,
width: 140,
},
separatorLine: {
flex: 1,
height: 1,
backgroundColor: "rgba(233, 69, 96, 0.3)",
},
separatorDiamond: {
width: 6,
height: 6,
backgroundColor: colors.primary,
transform: [{ rotate: "45deg" }],
marginHorizontal: spacing.md,
opacity: 0.6,
},
subtitle: {
...typography.body,
color: colors.textSecondary,
textAlign: "center",
},
cardsContainer: {
paddingHorizontal: spacing.lg,
gap: spacing.md,
},
card: {
borderRadius: radius.lg,
overflow: "hidden",
borderWidth: 1,
borderColor: colors.glass.border,
...shadows.md,
},
cardPressed: {
opacity: 0.9,
transform: [{ scale: 0.98 }],
},
cardDisabled: {
opacity: 0.6,
},
cardGradient: {
flexDirection: "row",
alignItems: "center",
padding: spacing.lg,
gap: spacing.md,
},
iconCircle: {
width: 64,
height: 64,
borderRadius: 32,
justifyContent: "center",
alignItems: "center",
borderWidth: 1,
},
cardContent: {
flex: 1,
},
cardTitleRow: {
flexDirection: "row",
alignItems: "center",
gap: spacing.sm,
marginBottom: spacing.xs,
},
cardTitle: {
...typography.title3,
color: colors.text,
},
cardTitleDisabled: {
color: colors.textSecondary,
},
cardDescription: {
...typography.subhead,
color: colors.textSecondary,
lineHeight: 20,
},
cardDescriptionDisabled: {
color: colors.textTertiary,
},
comingSoonBadge: {
backgroundColor: "rgba(255, 255, 255, 0.1)",
paddingHorizontal: spacing.sm,
paddingVertical: 2,
borderRadius: radius.full,
borderWidth: 1,
borderColor: "rgba(255, 255, 255, 0.15)",
},
comingSoonText: {
...typography.caption2,
color: colors.textTertiary,
fontWeight: "500",
textTransform: "uppercase",
letterSpacing: 0.5,
},
});
+711
View File
@@ -0,0 +1,711 @@
import { View, Text, StyleSheet, RefreshControl, Platform, Alert, Pressable } from "react-native";
import { usePacksStore, useAuthStore, useSubscriptionStore } from "../../../src/store";
import { Ionicons } from "@expo/vector-icons";
import { router } from "expo-router";
import { useMemo, useState, useCallback } from "react";
import Animated, {
FadeIn,
FadeOut,
useAnimatedStyle,
useSharedValue,
withTiming,
useAnimatedScrollHandler,
interpolate,
Extrapolation,
} from "react-native-reanimated";
import { BlurView } from "expo-blur";
import { GradientBackground } from "../../../src/components/ui";
import { Paywall } from "../../../src/components/Paywall";
import { PackTeaser } from "../../../src/components/PackTeaser";
import { colors, spacing, radius, typography } from "../../../src/theme";
import type { QuestionPack, Category } from "../../../src/types";
const HEADER_MAX_HEIGHT = 180;
const HEADER_MIN_HEIGHT = 100;
const HEADER_SCROLL_DISTANCE = HEADER_MAX_HEIGHT - HEADER_MIN_HEIGHT;
interface PackRowProps {
pack: QuestionPack;
isEnabled: boolean;
isPremiumLocked: boolean;
isExpanded: boolean;
onToggle: () => void;
onPress: () => void;
onInfoPress: () => void;
}
function PackRow({ pack, isEnabled, isPremiumLocked, isExpanded, onToggle, onPress, onInfoPress }: PackRowProps) {
const questionCount = pack.questions?.[0]?.count || 0;
return (
<Pressable onPress={onPress} style={styles.packRow}>
<View style={styles.packRowMain}>
<View style={[styles.packIcon, isEnabled && styles.packIconEnabled]}>
<Text style={styles.packIconText}>{pack.icon || "📦"}</Text>
</View>
<View style={styles.packContent}>
<View style={styles.packTitleRow}>
<Text style={styles.packName} numberOfLines={1}>{pack.name}</Text>
{isPremiumLocked && (
<Ionicons name="lock-closed" size={14} color={colors.premium.gold} />
)}
</View>
<Text style={styles.packMeta}>
{questionCount} {questionCount === 1 ? "question" : "questions"}
{isEnabled && <Text style={styles.packMetaEnabled}> · Enabled</Text>}
</Text>
</View>
<Ionicons
name={isExpanded ? "chevron-up" : "chevron-down"}
size={20}
color={colors.textTertiary}
/>
</View>
{isExpanded && (
<Animated.View
entering={FadeIn.duration(200)}
exiting={FadeOut.duration(150)}
style={styles.packExpanded}
>
{pack.description && (
<Text style={styles.packDescription}>{pack.description}</Text>
)}
<View style={styles.packActions}>
<Pressable style={styles.packInfoButton} onPress={onInfoPress}>
<Ionicons name="eye-outline" size={18} color={colors.textSecondary} />
<Text style={styles.packInfoButtonText}>Preview</Text>
</Pressable>
<Pressable
style={[
styles.packToggleButton,
isEnabled && styles.packToggleButtonEnabled,
isPremiumLocked && styles.packToggleButtonLocked,
]}
onPress={onToggle}
>
{isPremiumLocked ? (
<>
<Ionicons name="lock-closed" size={16} color={colors.premium.gold} />
<Text style={[styles.packToggleButtonText, styles.packToggleButtonTextLocked]}>
Unlock
</Text>
</>
) : isEnabled ? (
<>
<Ionicons name="checkmark" size={16} color={colors.primary} />
<Text style={[styles.packToggleButtonText, styles.packToggleButtonTextEnabled]}>
Enabled
</Text>
</>
) : (
<>
<Ionicons name="add" size={16} color={colors.text} />
<Text style={styles.packToggleButtonText}>Enable</Text>
</>
)}
</Pressable>
</View>
</Animated.View>
)}
</Pressable>
);
}
interface CategorySectionProps {
category: Category;
packs: QuestionPack[];
enabledPackIds: string[];
hasPremiumAccess: boolean;
expandedPackId: string | null;
onPackPress: (packId: string) => void;
onPackToggle: (pack: QuestionPack) => void;
onPackInfo: (pack: QuestionPack) => void;
}
function CategorySection({
category,
packs,
enabledPackIds,
hasPremiumAccess,
expandedPackId,
onPackPress,
onPackToggle,
onPackInfo,
}: CategorySectionProps) {
const [isCollapsed, setIsCollapsed] = useState(false);
const enabledCount = packs.filter(p => enabledPackIds.includes(p.id)).length;
const rotateAnim = useSharedValue(0);
const chevronStyle = useAnimatedStyle(() => ({
transform: [{ rotate: `${rotateAnim.value}deg` }],
}));
const handleToggleCollapse = () => {
rotateAnim.value = withTiming(isCollapsed ? 0 : -90, { duration: 200 });
setIsCollapsed(!isCollapsed);
};
return (
<View style={styles.categorySection}>
<Pressable style={styles.categoryHeader} onPress={handleToggleCollapse}>
<View style={styles.categoryHeaderLeft}>
{category.icon && (
<Text style={styles.categoryIcon}>{category.icon}</Text>
)}
<Text style={styles.categoryName}>{category.name}</Text>
{enabledCount > 0 && (
<View style={styles.categoryBadge}>
<Text style={styles.categoryBadgeText}>{enabledCount}</Text>
</View>
)}
</View>
<Animated.View style={chevronStyle}>
<Ionicons name="chevron-down" size={20} color={colors.textSecondary} />
</Animated.View>
</Pressable>
{!isCollapsed && (
<Animated.View
entering={FadeIn.duration(200)}
exiting={FadeOut.duration(150)}
style={styles.packsList}
>
{packs.map((pack) => (
<PackRow
key={pack.id}
pack={pack}
isEnabled={enabledPackIds.includes(pack.id)}
isPremiumLocked={pack.is_premium && !hasPremiumAccess}
isExpanded={expandedPackId === pack.id}
onPress={() => onPackPress(pack.id)}
onToggle={() => onPackToggle(pack)}
onInfoPress={() => onPackInfo(pack)}
/>
))}
</Animated.View>
)}
</View>
);
}
export default function QuestionPacksScreen() {
const { packs, categories, enabledPackIds, togglePack, isLoading, fetchPacks } = usePacksStore();
const { user, partner } = useAuthStore();
const { subscription } = useSubscriptionStore();
const [expandedPackId, setExpandedPackId] = useState<string | null>(null);
const [showPaywall, setShowPaywall] = useState(false);
const [showTeaser, setShowTeaser] = useState(false);
const [teaserPack, setTeaserPack] = useState<QuestionPack | null>(null);
const scrollY = useSharedValue(0);
const scrollHandler = useAnimatedScrollHandler({
onScroll: (event) => {
scrollY.value = event.contentOffset.y;
},
});
const hasPremiumAccess = useMemo(() => {
return user?.is_premium || partner?.is_premium || subscription.isProUser;
}, [user?.is_premium, partner?.is_premium, subscription.isProUser]);
const packsByCategory = useMemo(() => {
const grouped = new Map<string, QuestionPack[]>();
categories.forEach(cat => grouped.set(cat.id, []));
grouped.set("uncategorized", []);
packs.forEach(pack => {
const catId = pack.category_id || "uncategorized";
const existing = grouped.get(catId) || [];
existing.push(pack);
grouped.set(catId, existing);
});
return grouped;
}, [packs, categories]);
const handlePackPress = useCallback((packId: string) => {
setExpandedPackId(current => current === packId ? null : packId);
}, []);
const handlePackToggle = useCallback(async (pack: QuestionPack) => {
if (pack.is_premium && !hasPremiumAccess) {
setTeaserPack(pack);
setShowTeaser(true);
return;
}
const result = await togglePack(pack.id);
if (!result.success && result.reason === "no_couple") {
Alert.alert(
"Pair with Partner",
"You need to pair with your partner before you can customise question packs.",
[
{ text: "Later", style: "cancel" },
{ text: "Pair Now", onPress: () => router.push("/pairing") }
]
);
}
}, [hasPremiumAccess, togglePack]);
const handlePackInfo = useCallback((pack: QuestionPack) => {
const isPremiumLocked = pack.is_premium && !hasPremiumAccess;
if (isPremiumLocked) {
setTeaserPack(pack);
setShowTeaser(true);
} else {
router.push(`/pack/${pack.id}`);
}
}, [hasPremiumAccess]);
const handleTeaserUnlock = useCallback(() => {
setShowTeaser(false);
setShowPaywall(true);
}, []);
const handleTeaserClose = useCallback(() => {
setShowTeaser(false);
setTeaserPack(null);
}, []);
const enabledPackCount = enabledPackIds.length;
const totalQuestions = packs
.filter(p => enabledPackIds.includes(p.id))
.reduce((sum, p) => sum + (p.questions?.[0]?.count || 0), 0);
// Animated styles for collapsing header
const largeHeaderStyle = useAnimatedStyle(() => {
const opacity = interpolate(
scrollY.value,
[0, HEADER_SCROLL_DISTANCE * 0.5],
[1, 0],
Extrapolation.CLAMP
);
const translateY = interpolate(
scrollY.value,
[0, HEADER_SCROLL_DISTANCE],
[0, -20],
Extrapolation.CLAMP
);
return { opacity, transform: [{ translateY }] };
});
const compactHeaderStyle = useAnimatedStyle(() => {
const opacity = interpolate(
scrollY.value,
[HEADER_SCROLL_DISTANCE * 0.5, HEADER_SCROLL_DISTANCE],
[0, 1],
Extrapolation.CLAMP
);
return { opacity };
});
const navBarBackgroundStyle = useAnimatedStyle(() => {
const opacity = interpolate(
scrollY.value,
[0, HEADER_SCROLL_DISTANCE * 0.8],
[0, 1],
Extrapolation.CLAMP
);
return { opacity };
});
return (
<GradientBackground>
<View style={styles.container}>
{/* Fixed Nav Bar */}
<View style={styles.navBar}>
{/* Nav bar background with blur */}
<Animated.View style={[styles.navBarBackground, navBarBackgroundStyle]}>
{Platform.OS === "ios" ? (
<BlurView intensity={80} tint="dark" style={StyleSheet.absoluteFill} />
) : (
<View style={[StyleSheet.absoluteFill, { backgroundColor: "rgba(13, 13, 26, 0.95)" }]} />
)}
</Animated.View>
{/* Back button */}
<Pressable
style={styles.backButton}
onPress={() => router.back()}
hitSlop={8}
>
<Ionicons name="chevron-back" size={24} color={colors.text} />
</Pressable>
{/* Compact title (fades in on scroll) */}
<Animated.Text style={[styles.navBarTitle, compactHeaderStyle]}>
Question Packs
</Animated.Text>
{/* Spacer for alignment */}
<View style={styles.navBarSpacer} />
</View>
{/* Scrollable Content */}
<Animated.ScrollView
showsVerticalScrollIndicator={false}
onScroll={scrollHandler}
scrollEventThrottle={16}
refreshControl={
<RefreshControl
refreshing={isLoading}
onRefresh={fetchPacks}
tintColor={colors.primary}
colors={[colors.primary]}
progressViewOffset={HEADER_MAX_HEIGHT}
/>
}
contentContainerStyle={styles.scrollContent}
>
{/* Large Header (fades out on scroll) */}
<Animated.View style={[styles.largeHeader, largeHeaderStyle]}>
<Text style={styles.label}>COLLECTION</Text>
<Text style={styles.title}>Question Packs</Text>
<View style={styles.separator}>
<View style={styles.separatorLine} />
<View style={styles.separatorDiamond} />
<View style={styles.separatorLine} />
</View>
<View style={styles.statsBadge}>
<Ionicons name="checkmark-circle" size={14} color={colors.primary} />
<Text style={styles.statsBadgeText}>
{totalQuestions} {totalQuestions === 1 ? "question" : "questions"} from {enabledPackCount} {enabledPackCount === 1 ? "pack" : "packs"}
</Text>
</View>
</Animated.View>
{/* Categories */}
<View style={styles.categoriesContainer}>
{categories.map(category => {
const categoryPacks = packsByCategory.get(category.id) || [];
if (categoryPacks.length === 0) return null;
return (
<CategorySection
key={category.id}
category={category}
packs={categoryPacks}
enabledPackIds={enabledPackIds}
hasPremiumAccess={hasPremiumAccess}
expandedPackId={expandedPackId}
onPackPress={handlePackPress}
onPackToggle={handlePackToggle}
onPackInfo={handlePackInfo}
/>
);
})}
{(packsByCategory.get("uncategorized")?.length || 0) > 0 && (
<CategorySection
category={{ id: "uncategorized", name: "Other", description: null, icon: "📁", sort_order: 999, created_at: "" }}
packs={packsByCategory.get("uncategorized") || []}
enabledPackIds={enabledPackIds}
hasPremiumAccess={hasPremiumAccess}
expandedPackId={expandedPackId}
onPackPress={handlePackPress}
onPackToggle={handlePackToggle}
onPackInfo={handlePackInfo}
/>
)}
</View>
</Animated.ScrollView>
{/* Modals */}
{teaserPack && (
<PackTeaser
visible={showTeaser}
packId={teaserPack.id}
packName={teaserPack.name}
packIcon={teaserPack.icon}
onClose={handleTeaserClose}
onUnlock={handleTeaserUnlock}
/>
)}
<Paywall
visible={showPaywall}
onClose={() => setShowPaywall(false)}
onSuccess={() => fetchPacks()}
/>
</View>
</GradientBackground>
);
}
const NAV_BAR_HEIGHT = 44;
const STATUS_BAR_HEIGHT = 60; // Match other pages
const styles = StyleSheet.create({
container: {
flex: 1,
},
// Fixed Nav Bar
navBar: {
position: "absolute",
top: 0,
left: 0,
right: 0,
height: STATUS_BAR_HEIGHT + NAV_BAR_HEIGHT,
flexDirection: "row",
alignItems: "center",
paddingTop: STATUS_BAR_HEIGHT - 10,
paddingHorizontal: spacing.md,
zIndex: 100,
},
navBarBackground: {
...StyleSheet.absoluteFillObject,
borderBottomWidth: 1,
borderBottomColor: "rgba(255, 255, 255, 0.1)",
overflow: "hidden",
},
backButton: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: "rgba(255, 255, 255, 0.1)",
justifyContent: "center",
alignItems: "center",
},
navBarTitle: {
flex: 1,
...typography.headline,
color: colors.text,
textAlign: "center",
marginRight: 40, // Balance with back button
},
navBarSpacer: {
width: 0,
},
// Large Header
largeHeader: {
alignItems: "center",
paddingBottom: spacing.lg,
},
label: {
...typography.caption1,
fontWeight: "600",
letterSpacing: 3,
color: colors.primary,
textAlign: "center",
marginBottom: spacing.xs,
},
title: {
...typography.title1,
color: colors.text,
textAlign: "center",
},
separator: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
marginVertical: spacing.md,
width: 140,
},
separatorLine: {
flex: 1,
height: 1,
backgroundColor: "rgba(233, 69, 96, 0.3)",
},
separatorDiamond: {
width: 6,
height: 6,
backgroundColor: colors.primary,
transform: [{ rotate: "45deg" }],
marginHorizontal: spacing.md,
opacity: 0.6,
},
statsBadge: {
flexDirection: "row",
alignItems: "center",
backgroundColor: "rgba(233, 69, 96, 0.1)",
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: radius.full,
borderWidth: 1,
borderColor: "rgba(233, 69, 96, 0.2)",
gap: spacing.sm,
},
statsBadgeText: {
...typography.caption1,
color: colors.textSecondary,
fontWeight: "500",
},
// Scroll Content
scrollContent: {
paddingTop: 60, // Match other pages
paddingBottom: Platform.OS === "ios" ? 120 : 100,
},
categoriesContainer: {
paddingHorizontal: spacing.lg,
},
// Category Section
categorySection: {
marginBottom: spacing.lg,
},
categoryHeader: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingVertical: spacing.sm,
marginBottom: spacing.xs,
},
categoryHeaderLeft: {
flexDirection: "row",
alignItems: "center",
gap: spacing.sm,
},
categoryIcon: {
fontSize: 18,
},
categoryName: {
...typography.headline,
color: colors.text,
textTransform: "uppercase",
letterSpacing: 1,
fontSize: 13,
},
categoryBadge: {
backgroundColor: colors.primary,
paddingHorizontal: 8,
paddingVertical: 2,
borderRadius: 10,
minWidth: 20,
alignItems: "center",
},
categoryBadgeText: {
...typography.caption2,
color: colors.text,
fontWeight: "600",
},
packsList: {
backgroundColor: "rgba(255, 255, 255, 0.03)",
borderRadius: radius.lg,
borderWidth: 1,
borderColor: colors.glass.border,
overflow: "hidden",
},
// Pack Row
packRow: {
borderBottomWidth: 1,
borderBottomColor: colors.glass.border,
},
packRowMain: {
flexDirection: "row",
alignItems: "center",
padding: spacing.md,
gap: spacing.md,
},
packIcon: {
width: 44,
height: 44,
borderRadius: 12,
backgroundColor: "rgba(255, 255, 255, 0.05)",
justifyContent: "center",
alignItems: "center",
borderWidth: 1,
borderColor: "rgba(255, 255, 255, 0.08)",
},
packIconEnabled: {
backgroundColor: "rgba(233, 69, 96, 0.1)",
borderColor: "rgba(233, 69, 96, 0.2)",
},
packIconText: {
fontSize: 20,
},
packContent: {
flex: 1,
},
packTitleRow: {
flexDirection: "row",
alignItems: "center",
gap: spacing.xs,
},
packName: {
...typography.body,
color: colors.text,
fontWeight: "500",
},
packMeta: {
...typography.caption1,
color: colors.textTertiary,
marginTop: 2,
},
packMetaEnabled: {
color: colors.primary,
},
// Expanded Pack
packExpanded: {
paddingHorizontal: spacing.md,
paddingBottom: spacing.md,
paddingTop: 0,
},
packDescription: {
...typography.subhead,
color: colors.textSecondary,
lineHeight: 20,
marginBottom: spacing.md,
},
packActions: {
flexDirection: "row",
gap: spacing.sm,
},
packInfoButton: {
flexDirection: "row",
alignItems: "center",
gap: spacing.xs,
paddingVertical: spacing.sm,
paddingHorizontal: spacing.md,
borderRadius: radius.sm,
backgroundColor: "rgba(255, 255, 255, 0.05)",
borderWidth: 1,
borderColor: colors.glass.border,
},
packInfoButtonText: {
...typography.subhead,
color: colors.textSecondary,
},
packToggleButton: {
flex: 1,
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: spacing.xs,
paddingVertical: spacing.sm,
paddingHorizontal: spacing.md,
borderRadius: radius.sm,
backgroundColor: "rgba(255, 255, 255, 0.08)",
borderWidth: 1,
borderColor: "rgba(255, 255, 255, 0.12)",
},
packToggleButtonEnabled: {
backgroundColor: "rgba(233, 69, 96, 0.1)",
borderColor: "rgba(233, 69, 96, 0.3)",
},
packToggleButtonLocked: {
backgroundColor: "rgba(212, 175, 55, 0.1)",
borderColor: "rgba(212, 175, 55, 0.3)",
},
packToggleButtonText: {
...typography.subhead,
color: colors.text,
fontWeight: "500",
},
packToggleButtonTextEnabled: {
color: colors.primary,
},
packToggleButtonTextLocked: {
color: colors.premium.gold,
},
});
+1 -1
View File
@@ -408,7 +408,7 @@ const styles = StyleSheet.create({
alignItems: "center",
},
header: {
paddingTop: Platform.OS === "ios" ? 60 : 40,
paddingTop: 60,
paddingHorizontal: spacing.lg,
paddingBottom: spacing.lg,
flexDirection: "row",
+57 -20
View File
@@ -22,9 +22,11 @@ import { GradientBackground, GlassCard } from "../../src/components/ui";
import { FeedbackModal } from "../../src/components/FeedbackModal";
import { Paywall } from "../../src/components/Paywall";
import { Events } from "../../src/lib/analytics";
import { colors, gradients, spacing, radius, typography, shadows } from "../../src/theme";
import { colors, gradients, spacing, radius, typography, shadows, featureColors } from "../../src/theme";
const MAX_CONTENT_WIDTH = 500;
const ACCENT_COLOR = featureColors.profile.accent;
const ACCENT_GRADIENT = featureColors.profile.gradient as [string, string];
export default function ProfileScreen() {
const { user, partner, couple, signOut, fetchCouple, fetchUser } = useAuthStore();
@@ -447,7 +449,13 @@ export default function ProfileScreen() {
entering={FadeInDown.delay(100).duration(500)}
style={styles.header}
>
<Text style={styles.headerLabel}>SETTINGS</Text>
<Text style={styles.title}>Profile</Text>
<View style={styles.separator}>
<View style={styles.separatorLine} />
<View style={styles.separatorDiamond} />
<View style={styles.separatorLine} />
</View>
</Animated.View>
{/* Profile Card */}
@@ -464,7 +472,7 @@ export default function ProfileScreen() {
style={styles.avatarTouchable}
>
<LinearGradient
colors={gradients.primary as [string, string]}
colors={ACCENT_GRADIENT}
style={styles.avatarGradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
@@ -560,7 +568,7 @@ export default function ProfileScreen() {
<View style={styles.rowContainer}>
<View style={styles.rowLeft}>
<LinearGradient
colors={gradients.primary as [string, string]}
colors={ACCENT_GRADIENT}
style={styles.partnerIconGradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
@@ -590,7 +598,7 @@ export default function ProfileScreen() {
>
<View style={styles.rowLeft}>
<LinearGradient
colors={gradients.primary as [string, string]}
colors={ACCENT_GRADIENT}
style={styles.partnerIconGradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
@@ -640,7 +648,7 @@ export default function ProfileScreen() {
<View style={styles.rowContainer}>
<View style={styles.rowLeft}>
<LinearGradient
colors={gradients.primary as [string, string]}
colors={ACCENT_GRADIENT}
style={styles.partnerIconGradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
@@ -683,7 +691,7 @@ export default function ProfileScreen() {
</View>
</View>
<LinearGradient
colors={gradients.primary as [string, string]}
colors={ACCENT_GRADIENT}
style={styles.upgradeButton}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
@@ -718,7 +726,7 @@ export default function ProfileScreen() {
<View style={styles.rowContainer}>
<View style={styles.rowLeft}>
<LinearGradient
colors={gradients.primary as [string, string]}
colors={ACCENT_GRADIENT}
style={styles.partnerIconGradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
@@ -734,8 +742,8 @@ export default function ProfileScreen() {
value={showExplicit}
onValueChange={handleExplicitToggle}
disabled={isUpdatingExplicit}
trackColor={{ false: colors.glass.border, true: colors.primaryLight }}
thumbColor={showExplicit ? colors.primary : colors.textTertiary}
trackColor={{ false: colors.glass.border, true: colors.secondaryLight }}
thumbColor={showExplicit ? ACCENT_COLOR : colors.textTertiary}
ios_backgroundColor={colors.glass.border}
/>
</View>
@@ -745,7 +753,7 @@ export default function ProfileScreen() {
<View style={styles.rowContainer}>
<View style={styles.rowLeft}>
<LinearGradient
colors={gradients.primary as [string, string]}
colors={ACCENT_GRADIENT}
style={styles.partnerIconGradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
@@ -765,8 +773,8 @@ export default function ProfileScreen() {
value={biometricEnabled}
onValueChange={handleBiometricToggle}
disabled={isUpdatingBiometric}
trackColor={{ false: colors.glass.border, true: colors.primaryLight }}
thumbColor={biometricEnabled ? colors.primary : colors.textTertiary}
trackColor={{ false: colors.glass.border, true: colors.secondaryLight }}
thumbColor={biometricEnabled ? ACCENT_COLOR : colors.textTertiary}
ios_backgroundColor={colors.glass.border}
/>
</View>
@@ -812,7 +820,7 @@ export default function ProfileScreen() {
>
<View style={styles.rowLeft}>
<LinearGradient
colors={gradients.primary as [string, string]}
colors={ACCENT_GRADIENT}
style={styles.partnerIconGradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
@@ -997,7 +1005,7 @@ export default function ProfileScreen() {
style={styles.versionContainer}
>
<View style={styles.versionBadge}>
<Ionicons name="heart" size={12} color={colors.primary} />
<Ionicons name="heart" size={12} color={ACCENT_COLOR} />
<Text style={styles.version}>Sauci v1.0.0</Text>
</View>
</Animated.View>
@@ -1125,11 +1133,40 @@ const styles = StyleSheet.create({
header: {
paddingTop: 60,
paddingHorizontal: spacing.lg,
paddingBottom: spacing.lg,
paddingBottom: spacing.md,
alignItems: 'center',
},
headerLabel: {
...typography.caption2,
fontWeight: '600',
letterSpacing: 3,
color: colors.secondary,
marginBottom: spacing.xs,
},
title: {
...typography.title1,
...typography.largeTitle,
color: colors.text,
textAlign: 'center',
},
separator: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
marginVertical: spacing.md,
width: 100,
},
separatorLine: {
flex: 1,
height: 1,
backgroundColor: 'rgba(155, 89, 182, 0.3)',
},
separatorDiamond: {
width: 6,
height: 6,
backgroundColor: colors.secondary,
transform: [{ rotate: '45deg' }],
marginHorizontal: spacing.sm,
opacity: 0.6,
},
profileSection: {
paddingHorizontal: spacing.lg,
@@ -1186,7 +1223,7 @@ const styles = StyleSheet.create({
width: 32,
height: 32,
borderRadius: 16,
backgroundColor: colors.primary,
backgroundColor: colors.secondary,
justifyContent: "center",
alignItems: "center",
borderWidth: 3,
@@ -1227,7 +1264,7 @@ const styles = StyleSheet.create({
backgroundColor: colors.glass.background,
borderRadius: radius.md,
borderWidth: 1,
borderColor: colors.primary,
borderColor: colors.secondary,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
minWidth: 200,
@@ -1250,7 +1287,7 @@ const styles = StyleSheet.create({
width: 36,
height: 36,
borderRadius: 18,
backgroundColor: colors.primary,
backgroundColor: colors.secondary,
justifyContent: "center",
alignItems: "center",
},
@@ -1364,7 +1401,7 @@ const styles = StyleSheet.create({
},
manageButtonText: {
...typography.subhead,
color: colors.primary,
color: colors.secondary,
fontWeight: "600",
},
upgradeButton: {
+35 -51
View File
@@ -33,11 +33,6 @@ export default function QuizScreen() {
entering={FadeInDown.duration(600).springify()}
style={styles.content}
>
{/* Icon */}
<View style={styles.iconContainer}>
<Ionicons name="heart" size={36} color={ACCENT_COLOR} />
</View>
{/* Title section */}
<Text style={styles.label}>INTIMACY</Text>
<Text style={styles.title}>Quiz</Text>
@@ -81,40 +76,39 @@ export default function QuizScreen() {
{/* Bottom teaser */}
<Text style={styles.teaser}>Something special is being crafted for you</Text>
{/* Interest button */}
{isAuthenticated && !isLoading && (
<Animated.View
entering={FadeIn.delay(500).duration(400)}
style={styles.interestButtonContainer}
>
<AnimatedPressable
onPress={toggleInterest}
onPressIn={handlePressIn}
onPressOut={handlePressOut}
disabled={isToggling}
style={[
styles.interestButton,
isInterested && styles.interestButtonActive,
animatedButtonStyle,
]}
>
{isToggling ? (
<ActivityIndicator size="small" color={ACCENT_COLOR} />
) : (
<>
<Ionicons
name={isInterested ? "checkmark-circle" : "notifications-outline"}
size={16}
color={ACCENT_COLOR}
/>
<Text style={styles.interestButtonText}>
{isInterested ? "You're on the list!" : "I'm interested"}
</Text>
</>
)}
</AnimatedPressable>
</Animated.View>
)}
{/* Interest button - always reserve space to prevent layout shift */}
<View style={styles.interestButtonContainer}>
{isAuthenticated && !isLoading && (
<Animated.View entering={FadeIn.delay(500).duration(400)}>
<AnimatedPressable
onPress={toggleInterest}
onPressIn={handlePressIn}
onPressOut={handlePressOut}
disabled={isToggling}
style={[
styles.interestButton,
isInterested && styles.interestButtonActive,
animatedButtonStyle,
]}
>
{isToggling ? (
<ActivityIndicator size="small" color={ACCENT_COLOR} />
) : (
<>
<Ionicons
name={isInterested ? "checkmark-circle" : "notifications-outline"}
size={16}
color={ACCENT_COLOR}
/>
<Text style={styles.interestButtonText}>
{isInterested ? "You're on the list!" : "I'm interested"}
</Text>
</>
)}
</AnimatedPressable>
</Animated.View>
)}
</View>
</Animated.View>
</View>
</GradientBackground>
@@ -127,7 +121,7 @@ const styles = StyleSheet.create({
justifyContent: 'center',
alignItems: 'center',
paddingHorizontal: spacing.lg,
paddingTop: Platform.OS === 'ios' ? 60 : 40,
paddingTop: 60,
paddingBottom: Platform.OS === 'ios' ? 120 : 100,
},
content: {
@@ -135,17 +129,6 @@ const styles = StyleSheet.create({
alignItems: 'center',
paddingHorizontal: spacing.md,
},
iconContainer: {
width: 72,
height: 72,
borderRadius: 36,
backgroundColor: 'rgba(232, 164, 174, 0.1)',
borderWidth: 1,
borderColor: 'rgba(232, 164, 174, 0.2)',
justifyContent: 'center',
alignItems: 'center',
marginBottom: spacing.xl,
},
label: {
...typography.caption1,
fontWeight: '600',
@@ -223,6 +206,7 @@ const styles = StyleSheet.create({
},
interestButtonContainer: {
marginTop: spacing.xl,
minHeight: 44,
},
interestButton: {
flexDirection: 'row',
+71 -35
View File
@@ -19,7 +19,8 @@ import { usePacksStore, useAuthStore } from "../../src/store";
import { skipQuestion, getSkippedQuestionIds } from "../../src/lib/skippedQuestions";
import { hasSeenSwipeTutorial, markSwipeTutorialSeen } from "../../src/lib/swipeTutorialSeen";
import { invokeWithAuthRetry } from "../../src/lib/authErrorHandler";
import SwipeCard from "../../src/components/SwipeCard";
// import SwipeCard from "../../src/components/SwipeCard";
import SwipeCard from "../../src/components/SwipeCardPremium"; // PoC: Premium styling
import SwipeTutorial from "../../src/components/SwipeTutorial";
import { GradientBackground, GlassCard, GlassButton } from "../../src/components/ui";
import { Events } from "../../src/lib/analytics";
@@ -115,6 +116,7 @@ export default function SwipeScreen() {
const checkAnswerGap = async (): Promise<boolean> => {
if (!couple || !partner) {
setIsBlocked(false);
setGapInfo(null);
return false;
}
@@ -129,10 +131,15 @@ export default function SwipeScreen() {
if (data && data.length > 0) {
const result = data[0];
setIsBlocked(result.is_blocked);
setGapInfo({
unanswered: result.unanswered_by_partner,
threshold: result.threshold
});
// Only set gap info if threshold is enabled (> 0)
if (result.threshold > 0) {
setGapInfo({
unanswered: result.unanswered_by_partner,
threshold: result.threshold
});
} else {
setGapInfo(null);
}
return result.is_blocked;
}
} catch (error) {
@@ -141,6 +148,18 @@ export default function SwipeScreen() {
return false;
};
// Calculate effective total questions (accounting for gap limit)
const effectiveTotal = (() => {
if (!gapInfo || gapInfo.threshold === 0) {
// No gap limit active, show all questions
return questions.length;
}
// remaining = how many more questions until blocked
const remaining = gapInfo.threshold - gapInfo.unanswered;
// effective total = current position + remaining (capped by actual questions)
return Math.min(questions.length, currentIndex + Math.max(0, remaining));
})();
// Track when all questions are exhausted
useEffect(() => {
if (questions.length > 0 && currentIndex >= questions.length && !hasTrackedExhausted.current) {
@@ -193,12 +212,14 @@ export default function SwipeScreen() {
// Filter out recently skipped questions
const filtered = (data || []).filter((q: any) => !skippedIds.has(q.id));
// If no questions returned and user has partner, check if it's due to answer gap
if (filtered.length === 0 && partner && !packId) {
// Always check answer gap when user has partner and not viewing specific pack
// This gives us the gap info for accurate counter display
if (partner && !packId) {
await checkAnswerGap();
} else {
// Reset blocked state if we have questions
// Reset blocked state if no partner or viewing specific pack
setIsBlocked(false);
setGapInfo(null);
}
const sorted = filtered.sort((a: any, b: any) => {
@@ -554,10 +575,10 @@ export default function SwipeScreen() {
return (
<GradientBackground>
{/* Ambient Orbs */}
{/* Ambient Orbs - Premium gold/rose */}
<Animated.View style={[styles.ambientOrb, styles.orbTopRight, orbStyle1]} pointerEvents="none">
<LinearGradient
colors={[colors.primaryGlow, 'transparent']}
colors={[colors.premium.goldGlow, 'transparent']}
style={styles.orbGradient}
start={{ x: 0.5, y: 0.5 }}
end={{ x: 1, y: 1 }}
@@ -565,7 +586,7 @@ export default function SwipeScreen() {
</Animated.View>
<Animated.View style={[styles.ambientOrb, styles.orbBottomLeft, orbStyle2]} pointerEvents="none">
<LinearGradient
colors={[colors.secondaryGlow, 'transparent']}
colors={['rgba(232, 164, 174, 0.2)', 'transparent']}
style={styles.orbGradient}
start={{ x: 0.5, y: 0.5 }}
end={{ x: 0, y: 0 }}
@@ -579,26 +600,23 @@ export default function SwipeScreen() {
style={styles.header}
>
<View style={styles.progressContainer}>
{/* Premium label */}
<Text style={styles.progressLabel}>EXPLORE</Text>
<Text style={styles.progressText}>
{currentIndex + 1} of {questions.length}
{currentIndex + 1} of {effectiveTotal || questions.length}
</Text>
<View style={[styles.progressBar, styles.progressBarEnhanced]}>
<View style={[styles.progressBar, styles.progressBarPremium]}>
<Animated.View
style={[
styles.progressFill,
{ width: `${((currentIndex + 1) / questions.length) * 100}%` }
styles.progressFillPremium,
{ width: `${((currentIndex + 1) / (effectiveTotal || questions.length || 1)) * 100}%` }
]}
>
<LinearGradient
colors={gradients.primary as [string, string]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={styles.progressGradient}
/>
{/* Shimmer sweep */}
<Animated.View style={[styles.progressShimmer, shimmerStyle]}>
<LinearGradient
colors={['transparent', 'rgba(255,255,255,0.3)', 'transparent']}
colors={['transparent', 'rgba(212, 175, 55, 0.5)', 'transparent']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={StyleSheet.absoluteFill}
@@ -629,12 +647,12 @@ export default function SwipeScreen() {
/>
</View>
{/* Hint */}
{/* Hint - Premium styling */}
<Animated.View
entering={FadeIn.delay(500).duration(400)}
style={styles.hintContainer}
>
<Text style={styles.hintText}>Swipe or tap to answer</Text>
<Text style={styles.hintTextPremium}>Swipe or tap to answer</Text>
</Animated.View>
{/* Bottom spacing for tab bar */}
@@ -686,6 +704,13 @@ const styles = StyleSheet.create({
progressContainer: {
alignItems: "center",
},
progressLabel: {
...typography.caption2,
fontWeight: '600',
letterSpacing: 3,
color: colors.premium.gold,
marginBottom: spacing.xs,
},
progressText: {
...typography.subhead,
color: colors.textSecondary,
@@ -698,18 +723,23 @@ const styles = StyleSheet.create({
borderRadius: 3,
overflow: "hidden",
},
progressBarEnhanced: {
height: 8,
borderRadius: 4,
progressBarPremium: {
width: 140,
height: 4,
borderRadius: 2,
backgroundColor: 'rgba(212, 175, 55, 0.1)',
borderWidth: 1,
borderColor: colors.glass.borderLight,
...shadows.glow(colors.primaryGlow),
borderColor: 'rgba(212, 175, 55, 0.15)',
},
progressFill: {
height: "100%",
borderRadius: 4,
overflow: "hidden",
},
progressFillPremium: {
backgroundColor: 'rgba(212, 175, 55, 0.5)',
borderRadius: 2,
},
progressGradient: {
flex: 1,
borderRadius: 4,
@@ -725,24 +755,24 @@ const styles = StyleSheet.create({
justifyContent: "center",
alignItems: "center",
},
// Card stack
// Card stack - Premium styling
stackCard: {
position: "absolute",
width: "85%",
height: 480,
backgroundColor: colors.glass.backgroundLight,
borderRadius: radius.xxl,
height: 500,
backgroundColor: '#0d0d1a',
borderRadius: radius.xl,
borderWidth: 1,
borderColor: colors.glass.border,
borderColor: 'rgba(212, 175, 55, 0.15)',
},
stackCardSecond: {
transform: [{ scale: 0.95 }, { translateY: 12 }],
opacity: 0.5,
opacity: 0.4,
...shadows.md,
},
stackCardThird: {
transform: [{ scale: 0.90 }, { translateY: 24 }],
opacity: 0.25,
opacity: 0.2,
...shadows.sm,
},
hintContainer: {
@@ -753,6 +783,12 @@ const styles = StyleSheet.create({
...typography.caption1,
color: colors.textTertiary,
},
hintTextPremium: {
...typography.caption1,
fontStyle: 'italic',
color: colors.textTertiary,
letterSpacing: 0.5,
},
bottomSpacer: {
height: Platform.OS === 'ios' ? 100 : 80,
},