mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-11-01 18:19:43 +00:00
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:
1 parent
feb7657562
commit
cac7a2a31d
31 files changed
+6638
-1827
No files matched your search
@@ -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,
|
||||
|
||||
+1421
-232
File diff suppressed because it is too large.
Load diff
@@ -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
@@ -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
@@ -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',
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user