Add question types, nudge feature, and share modal

- Add new question card components (photo, audio, text, who-likely)
- Add share preview modal for matches
- Add nudge notification system with rate limiting
- Add session-based notification improvements
- Add match confetti celebration animation
- Update onboarding flow with paywall tracking
- Remove deprecated swipe card and tutorial components
- Update GlassCard/GlassInput to use solid backgrounds
- Fix routing issues after packs screen removal
This commit is contained in:
jjbrunton committed 2026-01-25 19:17:39 +00:00
1 parent 8612b16b93
commit 1daf386765
114 files changed
+14877 -7911

No files matched your search

+66 -47
View File
@@ -1,18 +1,18 @@
import React from 'react';
import { StyleSheet, View, Text, TouchableOpacity, Platform } from 'react-native';
import { LinearGradient } from 'expo-linear-gradient';
import { BlurView } from 'expo-blur';
import { Ionicons } from '@expo/vector-icons';
import Animated, {
useAnimatedStyle,
withSpring,
useSharedValue,
FadeIn,
} from 'react-native-reanimated';
import { ShimmerEffect } from './ui/ShimmerEffect';
import { GlassToggle } from './ui/GlassToggle';
import { colors, gradients, spacing, radius, typography, blur, shadows, animations } from '../theme';
import type { QuestionPack } from '../types';
import React from 'react';
import { StyleSheet, View, Text, TouchableOpacity, Platform } from 'react-native';
import { LinearGradient } from 'expo-linear-gradient';
import { BlurView } from 'expo-blur';
import { Ionicons } from '@expo/vector-icons';
import Animated, {
useAnimatedStyle,
withSpring,
useSharedValue,
FadeIn,
} from 'react-native-reanimated';
import { ShimmerEffect } from './ui/ShimmerEffect';
import { GlassToggle } from './ui/GlassToggle';
import { colors, gradients, spacing, radius, typography, blur, shadows, animations } from '../theme';
import type { QuestionPack } from '../types';
// Boutique gradient palette for pack cards
const BOUTIQUE_GRADIENTS: Array<readonly [string, string]> = [
@@ -71,33 +71,39 @@ export function BoutiquePackCard({
style={StyleSheet.absoluteFill}
/>
{/* Glass overlay */}
{useBlur ? (
<BlurView
intensity={blur.light}
tint="dark"
style={[StyleSheet.absoluteFill, styles.glassOverlay]}
/>
) : (
<View style={[StyleSheet.absoluteFill, styles.glassOverlayFallback]} />
)}
{/* Flat overlay - removed glass */}
<View style={[StyleSheet.absoluteFill, { backgroundColor: 'rgba(0,0,0,0.4)' }]} />
{/* Silk texture effect */}
{/* Silk texture effect - removed for flatter look
<LinearGradient
colors={gradients.silkLight as [string, string]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={[StyleSheet.absoluteFill, { opacity: 0.5 }]}
/>
*/}
{/* Top highlight */}
{/* Top highlight - removed
<LinearGradient
colors={[colors.glass.highlight, 'transparent']}
style={styles.topHighlight}
/>
*/}
{/* Content */}
<View style={styles.content}>
{/* Explicit Badge (top-left) */}
{pack.is_explicit && (
<View style={styles.explicitBadge}>
<Ionicons
name="flame"
size={10}
color={colors.text}
/>
<Text style={styles.explicitBadgeText}>18+</Text>
</View>
)}
{/* PRO Badge (top-right) */}
{pack.is_premium && (
<View style={[
@@ -148,17 +154,17 @@ export function BoutiquePackCard({
<View style={styles.footer}>
<View style={styles.questionBadge}>
<Text style={styles.questionCount}>{questionCount}</Text>
<Text style={styles.questionLabel}>questions</Text>
</View>
<GlassToggle
value={isEnabled}
onValueChange={onToggle}
disabled={isPremiumLocked}
style={isPremiumLocked ? { opacity: 0.4 } : undefined}
/>
</View>
</View>
<Text style={styles.questionLabel}>questions</Text>
</View>
<GlassToggle
value={isEnabled}
onValueChange={onToggle}
disabled={isPremiumLocked}
style={isPremiumLocked ? { opacity: 0.4 } : undefined}
/>
</View>
</View>
{/* Lock overlay for premium locked packs */}
{isPremiumLocked && (
@@ -209,7 +215,6 @@ const styles = StyleSheet.create({
cardPremium: {
borderWidth: 1,
borderColor: colors.premium.gold,
...shadows.glow(colors.premium.goldGlow),
},
cardEnabled: {
borderWidth: 1,
@@ -224,23 +229,37 @@ const styles = StyleSheet.create({
overflow: 'hidden',
},
glassOverlay: {
backgroundColor: 'rgba(22, 33, 62, 0.3)',
display: 'none',
},
glassOverlayFallback: {
backgroundColor: 'rgba(22, 33, 62, 0.5)',
display: 'none',
},
topHighlight: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: 40,
display: 'none',
},
content: {
flex: 1,
padding: spacing.md,
justifyContent: 'space-between',
},
explicitBadge: {
position: 'absolute',
top: spacing.sm,
left: spacing.sm,
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.error,
paddingHorizontal: spacing.sm,
paddingVertical: 3,
borderRadius: radius.xs,
gap: 3,
zIndex: 10,
},
explicitBadgeText: {
...typography.caption2,
color: colors.text,
fontWeight: '700',
},
proBadge: {
position: 'absolute',
top: spacing.sm,
@@ -255,7 +274,7 @@ const styles = StyleSheet.create({
zIndex: 10,
},
proBadgeLocked: {
backgroundColor: colors.glass.backgroundLight,
backgroundColor: colors.background, // Flat bg
},
proBadgeText: {
...typography.caption2,
@@ -0,0 +1,360 @@
/**
* MatchConfetti - Premium celebration animation for match moments
*
* Uses Reanimated for smooth animations. Displays an elegant burst of
* hearts and sparkles that explode outward, plus a prominent "It's a Match!" popup.
*/
import React, { useEffect, useMemo } from 'react';
import { View, StyleSheet, Dimensions, Text } from 'react-native';
import Animated, {
useSharedValue,
useAnimatedStyle,
withTiming,
withDelay,
withSpring,
withSequence,
Easing,
interpolate,
} from 'react-native-reanimated';
import { LinearGradient } from 'expo-linear-gradient';
import { Ionicons } from '@expo/vector-icons';
import * as Haptics from 'expo-haptics';
import { Platform } from 'react-native';
import { colors, typography, spacing, radius } from '../theme';
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get('window');
interface Props {
visible: boolean;
onAnimationComplete?: () => void;
}
type ParticleShape = 'heart' | 'sparkle' | 'circle' | 'star';
interface Particle {
id: number;
startX: number;
startY: number;
endX: number;
endY: number;
rotation: number;
color: string;
size: number;
delay: number;
shape: ParticleShape;
}
const CONFETTI_COLORS = [
colors.primary, // Brand pink #e1306c
'#FF6B9D', // Hot pink
'#FFB3C6', // Light pink
colors.premium.rose, // Soft rose
'#9b59b6', // Secondary purple
colors.premium.gold, // Gold accent
'#FF8A5B', // Coral
'#FFFFFF', // White sparkle
];
const PARTICLE_COUNT = 40;
// Particle shapes distribution
const SHAPES: ParticleShape[] = ['heart', 'heart', 'sparkle', 'sparkle', 'circle', 'star'];
// Generate particle with physics-based trajectory
const generateParticle = (index: number): Particle => {
// Start from center of screen
const startX = SCREEN_WIDTH / 2;
const startY = SCREEN_HEIGHT / 2 - 80;
// Random angle for explosion (360 degrees) with more upward bias
const baseAngle = (index / PARTICLE_COUNT) * Math.PI * 2;
const angle = baseAngle + (Math.random() - 0.5) * 0.8;
const velocity = 180 + Math.random() * 280;
// Calculate end position - more spread, less gravity drop for hearts
const endX = startX + Math.cos(angle) * velocity;
const endY = startY + Math.sin(angle) * velocity + 250;
const shape = SHAPES[Math.floor(Math.random() * SHAPES.length)];
return {
id: index,
startX,
startY,
endX,
endY,
rotation: Math.random() * 540 - 270,
color: CONFETTI_COLORS[Math.floor(Math.random() * CONFETTI_COLORS.length)],
size: shape === 'heart' ? 14 + Math.random() * 10 : 8 + Math.random() * 8,
delay: Math.random() * 150,
shape,
};
};
// Heart shape component
const HeartShape: React.FC<{ size: number; color: string }> = ({ size, color }) => (
<Ionicons name="heart" size={size} color={color} />
);
// Sparkle/star shape component
const SparkleShape: React.FC<{ size: number; color: string }> = ({ size, color }) => (
<Ionicons name="sparkles" size={size} color={color} />
);
// Star shape component
const StarShape: React.FC<{ size: number; color: string }> = ({ size, color }) => (
<Ionicons name="star" size={size} color={color} />
);
const ConfettiParticle: React.FC<{ particle: Particle; visible: boolean }> = ({
particle,
visible,
}) => {
const progress = useSharedValue(0);
const opacity = useSharedValue(0);
useEffect(() => {
if (visible) {
progress.value = 0;
opacity.value = 1;
progress.value = withDelay(
particle.delay,
withTiming(1, {
duration: 1000,
easing: Easing.out(Easing.cubic),
})
);
opacity.value = withDelay(
particle.delay + 600,
withTiming(0, {
duration: 400,
easing: Easing.out(Easing.ease),
})
);
} else {
progress.value = 0;
opacity.value = 0;
}
}, [visible]);
const animatedStyle = useAnimatedStyle(() => {
const x = particle.startX + (particle.endX - particle.startX) * progress.value;
const y = particle.startY + (particle.endY - particle.startY) * progress.value;
const rotation = particle.rotation * progress.value;
// Scale pulses slightly then shrinks
const scale = interpolate(progress.value, [0, 0.2, 1], [0.5, 1.2, 0.6]);
return {
transform: [
{ translateX: x },
{ translateY: y },
{ rotate: `${rotation}deg` },
{ scale },
],
opacity: opacity.value,
};
});
const renderShape = () => {
switch (particle.shape) {
case 'heart':
return <HeartShape size={particle.size} color={particle.color} />;
case 'sparkle':
return <SparkleShape size={particle.size} color={particle.color} />;
case 'star':
return <StarShape size={particle.size} color={particle.color} />;
case 'circle':
default:
return (
<View
style={{
width: particle.size,
height: particle.size,
backgroundColor: particle.color,
borderRadius: particle.size / 2,
}}
/>
);
}
};
return (
<Animated.View style={[styles.particle, animatedStyle]}>
{renderShape()}
</Animated.View>
);
};
// Animated popup message component - prominent and celebratory
const MatchPopup: React.FC<{ visible: boolean }> = ({ visible }) => {
const scale = useSharedValue(0);
const opacity = useSharedValue(0);
const iconScale = useSharedValue(0);
useEffect(() => {
if (visible) {
// Pop in with bouncy spring, then fade out after delay
scale.value = withSequence(
withSpring(1, { damping: 10, stiffness: 180 }),
withDelay(3200, withTiming(0.9, { duration: 400 }))
);
// Fade in quickly, stay visible, then fade out
opacity.value = withSequence(
withTiming(1, { duration: 150 }),
withDelay(3200, withTiming(0, { duration: 400 }))
);
// Heart icon bounces after text appears
iconScale.value = withDelay(
150,
withSequence(
withSpring(1.3, { damping: 8, stiffness: 200 }),
withSpring(1, { damping: 12, stiffness: 200 })
)
);
} else {
scale.value = 0;
opacity.value = 0;
iconScale.value = 0;
}
}, [visible]);
const animatedStyle = useAnimatedStyle(() => ({
transform: [{ scale: scale.value }],
opacity: opacity.value,
}));
const iconAnimatedStyle = useAnimatedStyle(() => ({
transform: [{ scale: iconScale.value }],
}));
return (
<Animated.View style={[styles.popupContainer, animatedStyle]}>
<View style={styles.popup}>
<LinearGradient
colors={[colors.primary, '#9b59b6']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.popupGradient}
>
<Text style={styles.popupTitle}>You both answered!</Text>
<Animated.View style={iconAnimatedStyle}>
<Ionicons name="heart" size={32} color="#fff" style={styles.popupIcon} />
</Animated.View>
</LinearGradient>
</View>
</Animated.View>
);
};
export const MatchConfetti: React.FC<Props> = ({ visible, onAnimationComplete }) => {
const particles = useMemo(
() => Array.from({ length: PARTICLE_COUNT }, (_, i) => generateParticle(i)),
[]
);
// Trigger haptic and callback when animation starts
useEffect(() => {
if (visible) {
// Celebration haptic pattern - satisfying burst
if (Platform.OS !== 'web') {
// Initial success notification
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
// Follow up with impacts for celebration feel
setTimeout(() => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy);
}, 80);
setTimeout(() => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
}, 180);
setTimeout(() => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy);
}, 300);
}
// Notify parent when animation completes (after popup fades)
if (onAnimationComplete) {
const timer = setTimeout(() => {
onAnimationComplete();
}, 4000);
return () => clearTimeout(timer);
}
}
}, [visible, onAnimationComplete]);
if (!visible) return null;
return (
<View style={styles.container} pointerEvents="none">
{/* Confetti particles */}
{particles.map((particle) => (
<ConfettiParticle
key={particle.id}
particle={particle}
visible={visible}
/>
))}
{/* Match popup message */}
<MatchPopup visible={visible} />
</View>
);
};
const styles = StyleSheet.create({
container: {
...StyleSheet.absoluteFillObject,
zIndex: 1000,
},
particle: {
position: 'absolute',
left: 0,
top: 0,
},
popupContainer: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
justifyContent: 'center',
alignItems: 'center',
zIndex: 10,
},
popup: {
borderRadius: radius.xl,
shadowColor: colors.primary,
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.6,
shadowRadius: 24,
elevation: 15,
overflow: 'hidden',
},
popupGradient: {
paddingHorizontal: spacing.xl + spacing.md,
paddingVertical: spacing.lg + spacing.sm,
alignItems: 'center',
},
popupTitle: {
...typography.title1,
color: '#fff',
fontWeight: '800',
textAlign: 'center',
textShadowColor: 'rgba(0, 0, 0, 0.3)',
textShadowOffset: { width: 0, height: 2 },
textShadowRadius: 4,
},
popupSubtext: {
...typography.callout,
color: 'rgba(255, 255, 255, 0.9)',
textAlign: 'center',
marginTop: spacing.xs,
},
popupIcon: {
marginTop: spacing.sm,
},
});
@@ -252,18 +252,12 @@ export const MatchNotificationModal: React.FC<Props> = ({
<View style={styles.glowOuter} />
<View style={styles.modalContainer}>
{/* Glass background */}
<BlurView intensity={40} tint="dark" style={styles.blurContainer}>
<LinearGradient
colors={['rgba(22, 33, 62, 0.95)', 'rgba(13, 13, 26, 0.98)']}
style={styles.gradientOverlay}
/>
</BlurView>
{/* Flat background */}
<View style={styles.flatBackground} />
{/* Content */}
<View style={styles.content}>
{/* Title section */}
<Text style={styles.title}>It's a Match!</Text>
{/* Title section */}
<Text style={styles.title}>It's a Match!</Text>
{/* Match type badge */}
<View style={[styles.matchTypeBadge, { borderColor: `${matchTypeInfo.color}30` }]}>
@@ -342,9 +336,6 @@ export const MatchNotificationModal: React.FC<Props> = ({
</Pressable>
</View>
</View>
{/* Border */}
<View style={styles.border} pointerEvents="none" />
</View>
</Animated.View>
</Animated.View>
@@ -374,24 +365,22 @@ const styles = StyleSheet.create({
borderRadius: radius.xl + 3,
backgroundColor: 'transparent',
borderWidth: 1,
borderColor: 'rgba(233, 69, 96, 0.15)',
shadowColor: colors.primary,
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.2,
shadowRadius: 20,
elevation: 8,
borderColor: colors.primary, // Simpler border
opacity: 0.2, // Subtle opacity
},
modalContainer: {
width: '100%',
borderRadius: radius.xl,
overflow: 'hidden',
backgroundColor: colors.backgroundLight, // Solid background
borderWidth: 1,
borderColor: colors.border,
},
blurContainer: {
...StyleSheet.absoluteFillObject,
},
gradientOverlay: {
flatBackground: {
...StyleSheet.absoluteFillObject,
backgroundColor: colors.backgroundLight,
},
// Removed blur/gradient styles
content: {
padding: spacing.xl,
paddingTop: spacing.lg,
@@ -404,7 +393,7 @@ const styles = StyleSheet.create({
marginBottom: spacing.sm,
},
matchTypeBadge: {
backgroundColor: 'rgba(255, 255, 255, 0.05)',
backgroundColor: colors.background, // Darker flat background
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
borderRadius: radius.full,
@@ -523,21 +512,17 @@ const styles = StyleSheet.create({
dismissButton: {
flex: 1,
borderRadius: radius.lg,
backgroundColor: colors.glass.background,
backgroundColor: colors.background, // Flat dark background
borderWidth: 1,
borderColor: colors.glass.border,
borderColor: colors.border,
paddingVertical: spacing.md,
alignItems: 'center',
justifyContent: 'center',
},
// Removed border style since it's now on modalContainer
// border: { ... }
dismissButtonText: {
...typography.headline,
color: colors.textSecondary,
},
border: {
...StyleSheet.absoluteFillObject,
borderRadius: radius.xl,
borderWidth: 1,
borderColor: 'rgba(233, 69, 96, 0.2)',
},
});
@@ -1,72 +1,96 @@
import React from 'react';
import { StyleSheet, View, Text, TouchableOpacity } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { Image } from 'expo-image';
import { router } from 'expo-router';
import Animated, { FadeInDown } from 'react-native-reanimated';
import { colors, spacing, typography, radius } from '../../theme';
import type { Profile, Couple } from '@/types';
interface CompactHeaderProps {
user: Profile | null;
partner: Profile | null;
couple: Couple | null;
}
export function CompactHeader({ user, partner, couple }: CompactHeaderProps) {
return (
<Animated.View
entering={FadeInDown.delay(100).duration(500).springify()}
style={styles.container}
>
{/* Greeting */}
<View style={styles.greetingRow}>
<View>
<Text style={styles.welcomeLabel}>Discover</Text>
<Text style={styles.greeting}>
Hey, {user?.name || 'Beautiful'}
</Text>
</View>
{/* Partner badge */}
{partner ? (
<View style={styles.partnerBadge}>
{partner.avatar_url ? (
<Image
source={{ uri: partner.avatar_url }}
style={styles.partnerAvatar}
cachePolicy="disk"
contentFit="cover"
transition={200}
/>
) : (
<Ionicons name="heart" size={12} color={colors.primary} />
)}
<Text style={styles.partnerText} numberOfLines={1}>
{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</Text>
</TouchableOpacity>
) : (
<TouchableOpacity
style={styles.pairBadge}
onPress={() => router.push('/(app)/pairing')}
>
<Ionicons name="add-circle-outline" size={14} color={colors.textTertiary} />
<Text style={styles.pairText}>Connect</Text>
</TouchableOpacity>
)}
</View>
</Animated.View>
);
}
import React from 'react';
import { StyleSheet, View, Text, TouchableOpacity } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { Image } from 'expo-image';
import { router } from 'expo-router';
import Animated, { FadeInDown } from 'react-native-reanimated';
import { colors, spacing, typography, radius } from '../../theme';
import type { Profile, Couple } from '@/types';
interface CompactHeaderProps {
user: Profile | null;
partner: Profile | null;
couple: Couple | null;
label?: string;
accessory?: React.ReactNode;
showGreeting?: boolean;
showPartnerBadge?: boolean;
}
export function CompactHeader({
user,
partner,
couple,
label = 'Discover',
accessory,
showGreeting = true,
showPartnerBadge = true,
}: CompactHeaderProps) {
return (
<Animated.View
entering={FadeInDown.delay(100).duration(500).springify()}
style={styles.container}
>
{showGreeting ? (
<View style={styles.greetingRow}>
<View>
<Text style={styles.welcomeLabel}>{label}</Text>
<Text style={styles.greeting}>
Hey, {user?.name || 'Beautiful'}
</Text>
</View>
<View style={styles.rightSection}>
{/* Partner badge */}
{showPartnerBadge ? (
partner ? (
<View style={styles.partnerBadge}>
{partner.avatar_url ? (
<Image
source={{ uri: partner.avatar_url }}
style={styles.partnerAvatar}
cachePolicy="disk"
contentFit="cover"
transition={200}
/>
) : (
<Ionicons name="heart" size={12} color={colors.primary} />
)}
<Text style={styles.partnerText} numberOfLines={1}>
{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</Text>
</TouchableOpacity>
) : (
<TouchableOpacity
style={styles.pairBadge}
onPress={() => router.push('/(app)/pairing')}
>
<Ionicons name="add-circle-outline" size={14} color={colors.textTertiary} />
<Text style={styles.pairText}>Connect</Text>
</TouchableOpacity>
)
) : null}
{accessory ? <View style={styles.accessory}>{accessory}</View> : null}
</View>
</View>
) : (
<View style={styles.titleRow}>
{accessory ? <View style={styles.titleSpacer} /> : null}
<Text style={styles.titleOnly}>{label}</Text>
{accessory ? <View style={styles.accessory}>{accessory}</View> : null}
</View>
)}
</Animated.View>
);
}
const styles = StyleSheet.create({
container: {
@@ -74,11 +98,34 @@ const styles = StyleSheet.create({
paddingHorizontal: spacing.lg,
paddingBottom: spacing.lg,
},
greetingRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
greetingRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
rightSection: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
},
accessory: {
justifyContent: 'center',
alignItems: 'center',
},
titleRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
},
titleOnly: {
...typography.title2,
color: colors.text,
textAlign: 'center',
flex: 1,
},
titleSpacer: {
width: 32,
},
welcomeLabel: {
...typography.caption2,
fontWeight: '600',
@@ -32,7 +32,7 @@ export function ContentRow({
const handleSeeAll = () => {
// Navigate to packs screen with category filter
router.push({
pathname: '/(app)/packs',
pathname: '/',
params: categoryId ? { category: categoryId } : undefined,
});
};
@@ -29,14 +29,13 @@ export function ContentTile({ pack, isLocked, isNew = false, progress, onPress }
};
const backgroundColor = getCategoryColor(pack.category);
const icon = pack.icon || pack.category?.icon || 'layers';
const questionCount = pack.questions?.[0]?.count ?? 0;
// Calculate progress percentage
const progressPercent = progress && progress.totalQuestions > 0
? progress.answeredQuestions / progress.totalQuestions
: 0;
// Show progress ring for packs with questions (even at 0%)
const showProgress = progress && progress.totalQuestions > 0;
// Always show progress ring when progress data exists (even at 0% or 0 questions)
const showProgress = !!progress;
return (
<TouchableOpacity
@@ -55,6 +54,12 @@ export function ContentTile({ pack, isLocked, isNew = false, progress, onPress }
/>
</View>
<View style={styles.badges}>
{pack.is_explicit && (
<View style={styles.explicitBadge}>
<Ionicons name="flame" size={10} color={colors.text} />
<Text style={styles.explicitBadgeText}>18+</Text>
</View>
)}
{isNew && (
<View style={styles.newBadge}>
<Text style={styles.newBadgeText}>NEW</Text>
@@ -87,13 +92,6 @@ export function ContentTile({ pack, isLocked, isNew = false, progress, onPress }
</Text>
</View>
{/* Bottom info */}
<View style={styles.bottomRow}>
<Text style={styles.countText}>
{questionCount} {questionCount === 1 ? 'question' : 'questions'}
</Text>
</View>
{/* Decorative overlay for depth */}
<View style={styles.overlay} />
</View>
@@ -136,6 +134,20 @@ const styles = StyleSheet.create({
flexDirection: 'row',
gap: spacing.xs,
},
explicitBadge: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.error,
paddingHorizontal: spacing.sm,
paddingVertical: 2,
borderRadius: radius.xs,
gap: 2,
},
explicitBadgeText: {
...typography.caption2,
fontWeight: '700',
color: colors.text,
},
newBadge: {
backgroundColor: 'rgba(255, 255, 255, 0.95)',
paddingHorizontal: spacing.sm,
@@ -173,13 +185,6 @@ const styles = StyleSheet.create({
color: colors.text,
fontWeight: '700',
},
bottomRow: {
marginTop: 'auto',
},
countText: {
...typography.caption1,
color: 'rgba(255, 255, 255, 0.7)',
},
});
export default ContentTile;
@@ -1,338 +1,338 @@
import React, { useState } from 'react';
import {
View,
Text,
StyleSheet,
Modal,
TouchableOpacity,
TextInput,
Alert,
ActivityIndicator,
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient';
import { supabase } from '../../lib/supabase';
import { getDeviceInfo } from '../../lib/deviceInfo';
import { useAuthStore } from '../../store';
import { colors, gradients, spacing, radius, typography } from '../../theme';
interface QuestionFeedbackModalProps {
visible: boolean;
onClose: () => void;
questionId: string;
questionText: string;
}
const FEEDBACK_REASONS = [
{ id: 'inappropriate', label: 'Inappropriate', icon: 'warning' as const },
{ id: 'confusing', label: 'Confusing', icon: 'help-circle' as const },
{ id: 'duplicate', label: 'Duplicate', icon: 'copy' as const },
{ id: 'other', label: 'Other', icon: 'ellipsis-horizontal' as const },
];
export function QuestionFeedbackModal({
visible,
onClose,
questionId,
questionText,
}: QuestionFeedbackModalProps) {
const { user } = useAuthStore();
const [selectedReason, setSelectedReason] = useState<string | null>(null);
const [additionalInfo, setAdditionalInfo] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const resetForm = () => {
setSelectedReason(null);
setAdditionalInfo('');
};
const handleClose = () => {
if (!isSubmitting) {
resetForm();
onClose();
}
};
const handleSubmit = async () => {
if (!selectedReason) {
Alert.alert('Please select a reason', 'Choose why you want to report this question.');
return;
}
if (!user) {
Alert.alert('Error', 'You must be logged in to submit feedback.');
return;
}
setIsSubmitting(true);
try {
const deviceInfo = getDeviceInfo();
const reasonLabel = FEEDBACK_REASONS.find(r => r.id === selectedReason)?.label || selectedReason;
const { error } = await supabase.from('feedback').insert({
user_id: user.id,
type: 'question',
title: `Question Feedback: ${reasonLabel}`,
description: additionalInfo.trim() || `Reported as: ${reasonLabel}`,
question_id: questionId,
device_info: deviceInfo,
});
if (error) throw error;
Alert.alert('Thank you!', 'Your feedback has been submitted.', [
{ text: 'OK', onPress: handleClose },
]);
resetForm();
} catch (error) {
Alert.alert('Error', 'Failed to submit feedback. Please try again.');
console.error(error);
} finally {
setIsSubmitting(false);
}
};
return (
<Modal visible={visible} transparent animationType="fade" onRequestClose={handleClose}>
<View style={styles.overlay}>
<View style={styles.content}>
{/* Header */}
<View style={styles.header}>
<LinearGradient
colors={gradients.primary as [string, string]}
style={styles.headerIcon}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
<Ionicons name="flag" size={24} color={colors.text} />
</LinearGradient>
<Text style={styles.headerTitle}>Report Question</Text>
</View>
{/* Question Preview */}
<View style={styles.questionPreview}>
<Text style={styles.questionText} numberOfLines={2}>
"{questionText}"
</Text>
</View>
{/* Reason Selection */}
<Text style={styles.label}>What's the issue?</Text>
<View style={styles.reasonsGrid}>
{FEEDBACK_REASONS.map((reason) => (
<TouchableOpacity
key={reason.id}
style={[
styles.reasonButton,
selectedReason === reason.id && styles.reasonButtonActive,
]}
onPress={() => setSelectedReason(reason.id)}
activeOpacity={0.7}
disabled={isSubmitting}
>
<Ionicons
name={reason.icon}
size={20}
color={
selectedReason === reason.id
? colors.text
: colors.textSecondary
}
/>
<Text
style={[
styles.reasonText,
selectedReason === reason.id && styles.reasonTextActive,
]}
>
{reason.label}
</Text>
</TouchableOpacity>
))}
</View>
{/* Additional Info */}
<Text style={styles.label}>Additional details (optional)</Text>
<TextInput
style={styles.input}
value={additionalInfo}
onChangeText={setAdditionalInfo}
placeholder="Tell us more..."
placeholderTextColor={colors.textTertiary}
multiline
numberOfLines={3}
textAlignVertical="top"
editable={!isSubmitting}
/>
{/* Buttons */}
<View style={styles.buttons}>
<TouchableOpacity
style={styles.cancelButton}
onPress={handleClose}
disabled={isSubmitting}
activeOpacity={0.7}
>
<Text style={styles.cancelButtonText}>Cancel</Text>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.submitButton,
!selectedReason && styles.submitButtonDisabled,
]}
onPress={handleSubmit}
disabled={isSubmitting || !selectedReason}
activeOpacity={0.7}
>
{isSubmitting ? (
<ActivityIndicator size="small" color={colors.text} />
) : (
<>
<Ionicons name="send" size={16} color={colors.text} />
<Text style={styles.submitButtonText}>Submit</Text>
</>
)}
</TouchableOpacity>
</View>
</View>
</View>
</Modal>
);
}
const styles = StyleSheet.create({
overlay: {
flex: 1,
backgroundColor: 'rgba(0, 0, 0, 0.8)',
justifyContent: 'center',
alignItems: 'center',
padding: spacing.lg,
},
content: {
backgroundColor: colors.backgroundLight,
borderRadius: radius.xl,
padding: spacing.lg,
width: '100%',
maxWidth: 360,
borderWidth: 1,
borderColor: colors.glass.border,
},
header: {
alignItems: 'center',
marginBottom: spacing.md,
},
headerIcon: {
width: 48,
height: 48,
borderRadius: 24,
justifyContent: 'center',
alignItems: 'center',
marginBottom: spacing.sm,
},
headerTitle: {
...typography.title3,
color: colors.text,
textAlign: 'center',
},
questionPreview: {
backgroundColor: colors.glass.background,
borderRadius: radius.md,
padding: spacing.md,
marginBottom: spacing.md,
borderWidth: 1,
borderColor: colors.glass.border,
},
questionText: {
...typography.subhead,
color: colors.textSecondary,
fontStyle: 'italic',
textAlign: 'center',
},
label: {
...typography.subhead,
color: colors.textSecondary,
marginBottom: spacing.sm,
marginLeft: spacing.xs,
},
reasonsGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: spacing.sm,
marginBottom: spacing.md,
},
reasonButton: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
backgroundColor: colors.glass.background,
paddingVertical: spacing.sm,
paddingHorizontal: spacing.md,
borderRadius: radius.md,
borderWidth: 1,
borderColor: colors.glass.border,
},
reasonButtonActive: {
backgroundColor: colors.primary,
borderColor: colors.primary,
},
reasonText: {
...typography.caption1,
color: colors.textSecondary,
fontWeight: '600',
},
reasonTextActive: {
color: colors.text,
},
input: {
backgroundColor: colors.glass.background,
borderRadius: radius.md,
borderWidth: 1,
borderColor: colors.glass.border,
padding: spacing.md,
...typography.body,
color: colors.text,
minHeight: 80,
marginBottom: spacing.md,
},
buttons: {
flexDirection: 'row',
gap: spacing.md,
},
cancelButton: {
flex: 1,
backgroundColor: colors.glass.backgroundLight,
paddingVertical: spacing.md,
borderRadius: radius.md,
alignItems: 'center',
borderWidth: 1,
borderColor: colors.glass.borderLight,
},
cancelButtonText: {
...typography.body,
color: colors.textSecondary,
fontWeight: '600',
},
submitButton: {
flex: 1,
backgroundColor: colors.primary,
paddingVertical: spacing.md,
borderRadius: radius.md,
alignItems: 'center',
flexDirection: 'row',
justifyContent: 'center',
gap: spacing.xs,
},
submitButtonDisabled: {
opacity: 0.5,
},
submitButtonText: {
...typography.body,
color: colors.text,
fontWeight: '600',
},
});
export default QuestionFeedbackModal;
import React, { useState } from 'react';
import {
View,
Text,
StyleSheet,
Modal,
TouchableOpacity,
TextInput,
Alert,
ActivityIndicator,
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient';
import { supabase } from '../../lib/supabase';
import { getDeviceInfo } from '../../lib/deviceInfo';
import { useAuthStore } from '../../store';
import { colors, gradients, spacing, radius, typography } from '../../theme';
interface QuestionFeedbackModalProps {
visible: boolean;
onClose: () => void;
questionId: string;
questionText: string;
}
const FEEDBACK_REASONS = [
{ id: 'inappropriate', label: 'Inappropriate', icon: 'warning' as const },
{ id: 'confusing', label: 'Confusing', icon: 'help-circle' as const },
{ id: 'duplicate', label: 'Duplicate', icon: 'copy' as const },
{ id: 'other', label: 'Other', icon: 'ellipsis-horizontal' as const },
];
export function QuestionFeedbackModal({
visible,
onClose,
questionId,
questionText,
}: QuestionFeedbackModalProps) {
const { user } = useAuthStore();
const [selectedReason, setSelectedReason] = useState<string | null>(null);
const [additionalInfo, setAdditionalInfo] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const resetForm = () => {
setSelectedReason(null);
setAdditionalInfo('');
};
const handleClose = () => {
if (!isSubmitting) {
resetForm();
onClose();
}
};
const handleSubmit = async () => {
if (!selectedReason) {
Alert.alert('Please select a reason', 'Choose why you want to report this question.');
return;
}
if (!user) {
Alert.alert('Error', 'You must be logged in to submit feedback.');
return;
}
setIsSubmitting(true);
try {
const deviceInfo = getDeviceInfo();
const reasonLabel = FEEDBACK_REASONS.find(r => r.id === selectedReason)?.label || selectedReason;
const { error } = await supabase.from('feedback').insert({
user_id: user.id,
type: 'question',
title: `Question Feedback: ${reasonLabel}`,
description: additionalInfo.trim() || `Reported as: ${reasonLabel}`,
question_id: questionId,
device_info: deviceInfo,
});
if (error) throw error;
Alert.alert('Thank you!', 'Your feedback has been submitted.', [
{ text: 'OK', onPress: handleClose },
]);
resetForm();
} catch (error) {
Alert.alert('Error', 'Failed to submit feedback. Please try again.');
console.error(error);
} finally {
setIsSubmitting(false);
}
};
return (
<Modal visible={visible} transparent animationType="fade" onRequestClose={handleClose}>
<View style={styles.overlay}>
<View style={styles.content}>
{/* Header */}
<View style={styles.header}>
<LinearGradient
colors={gradients.primary as [string, string]}
style={styles.headerIcon}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
<Ionicons name="flag" size={24} color={colors.text} />
</LinearGradient>
<Text style={styles.headerTitle}>Report Question</Text>
</View>
{/* Question Preview */}
<View style={styles.questionPreview}>
<Text style={styles.questionText} numberOfLines={2}>
"{questionText}"
</Text>
</View>
{/* Reason Selection */}
<Text style={styles.label}>What's the issue?</Text>
<View style={styles.reasonsGrid}>
{FEEDBACK_REASONS.map((reason) => (
<TouchableOpacity
key={reason.id}
style={[
styles.reasonButton,
selectedReason === reason.id && styles.reasonButtonActive,
]}
onPress={() => setSelectedReason(reason.id)}
activeOpacity={0.7}
disabled={isSubmitting}
>
<Ionicons
name={reason.icon}
size={20}
color={
selectedReason === reason.id
? colors.text
: colors.textSecondary
}
/>
<Text
style={[
styles.reasonText,
selectedReason === reason.id && styles.reasonTextActive,
]}
>
{reason.label}
</Text>
</TouchableOpacity>
))}
</View>
{/* Additional Info */}
<Text style={styles.label}>Additional details (optional)</Text>
<TextInput
style={styles.input}
value={additionalInfo}
onChangeText={setAdditionalInfo}
placeholder="Tell us more..."
placeholderTextColor={colors.textTertiary}
multiline
numberOfLines={3}
textAlignVertical="top"
editable={!isSubmitting}
/>
{/* Buttons */}
<View style={styles.buttons}>
<TouchableOpacity
style={styles.cancelButton}
onPress={handleClose}
disabled={isSubmitting}
activeOpacity={0.7}
>
<Text style={styles.cancelButtonText}>Cancel</Text>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.submitButton,
!selectedReason && styles.submitButtonDisabled,
]}
onPress={handleSubmit}
disabled={isSubmitting || !selectedReason}
activeOpacity={0.7}
>
{isSubmitting ? (
<ActivityIndicator size="small" color={colors.text} />
) : (
<>
<Ionicons name="send" size={16} color={colors.text} />
<Text style={styles.submitButtonText}>Submit</Text>
</>
)}
</TouchableOpacity>
</View>
</View>
</View>
</Modal>
);
}
const styles = StyleSheet.create({
overlay: {
flex: 1,
backgroundColor: 'rgba(0, 0, 0, 0.8)',
justifyContent: 'center',
alignItems: 'center',
padding: spacing.lg,
},
content: {
backgroundColor: colors.backgroundLight,
borderRadius: radius.xl,
padding: spacing.lg,
width: '100%',
maxWidth: 360,
borderWidth: 1,
borderColor: colors.border,
},
header: {
alignItems: 'center',
marginBottom: spacing.md,
},
headerIcon: {
width: 48,
height: 48,
borderRadius: 24,
justifyContent: 'center',
alignItems: 'center',
marginBottom: spacing.sm,
},
headerTitle: {
...typography.title3,
color: colors.text,
textAlign: 'center',
},
questionPreview: {
backgroundColor: colors.background, // Flat inner container
borderRadius: radius.md,
padding: spacing.md,
marginBottom: spacing.md,
borderWidth: 1,
borderColor: colors.border,
},
questionText: {
...typography.subhead,
color: colors.textSecondary,
fontStyle: 'italic',
textAlign: 'center',
},
label: {
...typography.subhead,
color: colors.textSecondary,
marginBottom: spacing.sm,
marginLeft: spacing.xs,
},
reasonsGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: spacing.sm,
marginBottom: spacing.md,
},
reasonButton: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
backgroundColor: colors.background, // Flat button
paddingVertical: spacing.sm,
paddingHorizontal: spacing.md,
borderRadius: radius.md,
borderWidth: 1,
borderColor: colors.border,
},
reasonButtonActive: {
backgroundColor: colors.primary,
borderColor: colors.primary,
},
reasonText: {
...typography.caption1,
color: colors.textSecondary,
fontWeight: '600',
},
reasonTextActive: {
color: colors.text,
},
input: {
backgroundColor: colors.background,
borderRadius: radius.md,
borderWidth: 1,
borderColor: colors.border,
padding: spacing.md,
...typography.body,
color: colors.text,
minHeight: 80,
marginBottom: spacing.md,
},
buttons: {
flexDirection: 'row',
gap: spacing.md,
},
cancelButton: {
flex: 1,
backgroundColor: colors.background,
paddingVertical: spacing.md,
borderRadius: radius.md,
alignItems: 'center',
borderWidth: 1,
borderColor: colors.border,
},
cancelButtonText: {
...typography.body,
color: colors.textSecondary,
fontWeight: '600',
},
submitButton: {
flex: 1,
backgroundColor: colors.primary,
paddingVertical: spacing.md,
borderRadius: radius.md,
alignItems: 'center',
flexDirection: 'row',
justifyContent: 'center',
gap: spacing.xs,
},
submitButtonDisabled: {
opacity: 0.5,
},
submitButtonText: {
...typography.body,
color: colors.text,
fontWeight: '600',
},
});
export default QuestionFeedbackModal;
+226 -256
View File
@@ -10,40 +10,45 @@ import {
Platform,
Pressable,
Linking,
Image,
} from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { LinearGradient } from "expo-linear-gradient";
import { router } from "expo-router";
import { useAuthStore, useSubscriptionStore } from "../../store";
import { colors, gradients, spacing, radius, typography } from "../../theme";
import { colors, gradients, spacing, radius, typography, shadows } from "../../theme";
import type { PurchasesPackage } from "../../lib/revenuecat";
import { Events } from "../../lib/analytics";
const logoImage = require("../../../assets/logo.png");
interface PaywallProps {
visible: boolean;
onClose: () => void;
onSuccess?: () => void;
/** Optional RevenueCat offering identifier. If not provided, uses the default offering. */
offeringId?: string;
}
const FEATURES = [
{
icon: "diamond" as const,
title: "Unlock All Packs",
description: "Access every premium collection",
icon: "layers" as const,
title: "All Premium Packs",
description: "Unlock every intimate collection",
},
{
icon: "sparkles" as const,
title: "Exclusive Content",
description: "New packs added regularly",
title: "New Content Weekly",
description: "Fresh questions added regularly",
},
{
icon: "heart" as const,
title: "Share with Partner",
description: "Both of you get Pro access",
title: "Shared Access",
description: "Your partner gets Pro too",
},
];
export function Paywall({ visible, onClose, onSuccess }: PaywallProps) {
export function Paywall({ visible, onClose, onSuccess, offeringId }: PaywallProps) {
const { isAnonymous } = useAuthStore();
const handleClose = (reason: "dismiss" | "success" | "restore" | "system") => {
@@ -67,15 +72,13 @@ export function Paywall({ visible, onClose, onSuccess }: PaywallProps) {
const [guestConfirmed, setGuestConfirmed] = useState(false);
useEffect(() => {
if (visible && !offerings) {
fetchOfferings();
}
if (visible) {
fetchOfferings(offeringId);
Events.paywallShown("paywall");
setGuestConfirmed(false);
setShowGuestConfirm(false);
}
}, [visible, offerings, fetchOfferings]);
}, [visible, offeringId, fetchOfferings]);
useEffect(() => {
// Auto-select annual package as default (better value)
@@ -169,18 +172,13 @@ export function Paywall({ visible, onClose, onSuccess }: PaywallProps) {
>
<View style={styles.overlay}>
<View style={styles.content}>
{/* Premium gradient background */}
{/* Gold accent line at top */}
<LinearGradient
colors={['rgba(22, 33, 62, 0.98)', 'rgba(13, 13, 26, 1)']}
style={StyleSheet.absoluteFill}
colors={[colors.premium.gold, colors.premium.goldDark]}
style={styles.topAccent}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
/>
{/* Top silk highlight */}
<LinearGradient
colors={['rgba(212, 175, 55, 0.08)', 'transparent']}
style={styles.silkHighlight}
/>
{/* Premium border accent */}
<View style={styles.premiumBorderAccent} />
<ScrollView
showsVerticalScrollIndicator={false}
@@ -192,47 +190,34 @@ export function Paywall({ visible, onClose, onSuccess }: PaywallProps) {
onPress={() => handleClose("dismiss")}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<Ionicons name="close" size={24} color={colors.textSecondary} />
<Ionicons name="close" size={22} color={colors.textSecondary} />
</TouchableOpacity>
{/* Header */}
<View style={styles.header}>
{/* Premium label */}
<Text style={styles.label}>EXCLUSIVE ACCESS</Text>
{/* Icon with glow */}
<View style={styles.iconContainer}>
<LinearGradient
colors={[colors.premium.gold, colors.premium.goldDark]}
style={StyleSheet.absoluteFill}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
{/* Sauci logo */}
<View style={styles.logoContainer}>
<Image
source={logoImage}
style={styles.logo}
resizeMode="contain"
/>
<Ionicons name="diamond" size={32} color={colors.background} />
</View>
<Text style={styles.title}>Sauci Pro</Text>
{/* Decorative separator */}
<View style={styles.separator}>
<View style={styles.separatorLine} />
<View style={styles.separatorDiamond} />
<View style={styles.separatorLine} />
</View>
<Text style={styles.subtitle}>
Unlock all premium content for you and your partner
Unlock the full intimate experience
</Text>
</View>
{/* Features */}
<View style={styles.features}>
{FEATURES.map((feature, index) => (
{FEATURES.map((feature) => (
<View key={feature.title} style={styles.featureRow}>
<View style={styles.featureIcon}>
<Ionicons
name={feature.icon}
size={18}
size={20}
color={colors.premium.gold}
/>
</View>
@@ -242,11 +227,6 @@ export function Paywall({ visible, onClose, onSuccess }: PaywallProps) {
{feature.description}
</Text>
</View>
<Ionicons
name="checkmark-circle"
size={20}
color={colors.premium.gold}
/>
</View>
))}
</View>
@@ -261,75 +241,96 @@ export function Paywall({ visible, onClose, onSuccess }: PaywallProps) {
</Text>
</View>
) : offerings?.availablePackages ? (
offerings.availablePackages.map((pkg) => {
const isSelected =
selectedPackage?.identifier === pkg.identifier;
const isAnnual = pkg.packageType === "ANNUAL";
return (
<Pressable
key={pkg.identifier}
onPress={() => setSelectedPackage(pkg)}
style={[
styles.packageCard,
isSelected && styles.packageCardSelected,
isAnnual && styles.packageCardAnnual,
]}
>
{isAnnual && (
<LinearGradient
colors={['rgba(212, 175, 55, 0.1)', 'rgba(184, 134, 11, 0.05)']}
style={StyleSheet.absoluteFill}
/>
)}
{isAnnual && savings && savings > 0 && (
<View style={styles.savingsBadge}>
<LinearGradient
colors={[colors.premium.gold, colors.premium.goldDark]}
style={StyleSheet.absoluteFill}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
/>
<Ionicons name="star" size={10} color={colors.background} />
<Text style={styles.savingsText}>
SAVE {savings}%
</Text>
</View>
)}
<View style={styles.packageContent}>
<View
<>
{/* Annual package - hero card */}
{offerings.availablePackages
.filter((p) => p.packageType === "ANNUAL")
.map((pkg) => {
const isSelected = selectedPackage?.identifier === pkg.identifier;
return (
<Pressable
key={pkg.identifier}
onPress={() => setSelectedPackage(pkg)}
style={[
styles.radioOuter,
isSelected && styles.radioOuterSelected,
styles.packageCard,
styles.packageCardAnnual,
isSelected && styles.packageCardSelected,
]}
>
{isSelected && <View style={styles.radioInner} />}
</View>
<View style={styles.packageInfo}>
<View style={styles.packageTitleRow}>
<Text style={[
styles.packageTitle,
isAnnual && styles.packageTitleAnnual
{/* Subtle gold tint background */}
<View style={styles.annualBackground} />
{/* Best value badge */}
{savings && savings > 0 && (
<View style={styles.savingsBadge}>
<LinearGradient
colors={[colors.premium.gold, colors.premium.goldDark]}
style={StyleSheet.absoluteFill}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
/>
<Text style={styles.savingsText}>
SAVE {savings}%
</Text>
</View>
)}
<View style={styles.packageContent}>
<View style={[
styles.radioOuter,
isSelected && styles.radioOuterSelected,
]}>
{isAnnual ? "Annual" : "Monthly"}
</Text>
{isAnnual && (
<View style={styles.bestValueBadge}>
<Text style={styles.bestValueText}>BEST VALUE</Text>
{isSelected && <View style={styles.radioInner} />}
</View>
<View style={styles.packageInfo}>
<View style={styles.packageHeader}>
<Text style={styles.packageTitleAnnual}>Annual</Text>
<View style={styles.recommendedBadge}>
<Ionicons name="star" size={10} color={colors.premium.gold} />
<Text style={styles.recommendedText}>BEST VALUE</Text>
</View>
</View>
)}
<Text style={styles.packagePriceAnnual}>
{formatPrice(pkg)}
</Text>
</View>
</View>
<Text style={[
styles.packagePrice,
isAnnual && styles.packagePriceAnnual
]}>
{formatPrice(pkg)}
</Text>
</View>
</View>
</Pressable>
);
})
</Pressable>
);
})}
{/* Monthly package */}
{offerings.availablePackages
.filter((p) => p.packageType === "MONTHLY")
.map((pkg) => {
const isSelected = selectedPackage?.identifier === pkg.identifier;
return (
<Pressable
key={pkg.identifier}
onPress={() => setSelectedPackage(pkg)}
style={[
styles.packageCard,
isSelected && styles.packageCardSelected,
]}
>
<View style={styles.packageContent}>
<View style={[
styles.radioOuter,
isSelected && styles.radioOuterSelected,
]}>
{isSelected && <View style={styles.radioInner} />}
</View>
<View style={styles.packageInfo}>
<Text style={styles.packageTitle}>Monthly</Text>
<Text style={styles.packagePrice}>
{formatPrice(pkg)}
</Text>
</View>
</View>
</Pressable>
);
})}
</>
) : (
<View style={styles.errorContainer}>
<Text style={styles.errorText}>
@@ -342,7 +343,7 @@ export function Paywall({ visible, onClose, onSuccess }: PaywallProps) {
)}
<TouchableOpacity
style={styles.retryButton}
onPress={fetchOfferings}
onPress={() => fetchOfferings(offeringId)}
>
<Text style={styles.retryButtonText}>Retry</Text>
</TouchableOpacity>
@@ -379,7 +380,6 @@ export function Paywall({ visible, onClose, onSuccess }: PaywallProps) {
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
>
<Ionicons name="diamond" size={18} color={colors.background} style={{ marginRight: spacing.sm }} />
<Text style={styles.purchaseButtonText}>Unlock Pro</Text>
</LinearGradient>
)}
@@ -405,18 +405,19 @@ export function Paywall({ visible, onClose, onSuccess }: PaywallProps) {
<TouchableOpacity
onPress={() => Linking.openURL("https://sauci.app/terms")}
>
<Text style={styles.legalLinkText}>Terms of Use</Text>
<Text style={styles.legalLinkText}>Terms</Text>
</TouchableOpacity>
<Text style={styles.legalSeparator}>•</Text>
<TouchableOpacity
onPress={() => Linking.openURL("https://sauci.app/privacy")}
>
<Text style={styles.legalLinkText}>Privacy Policy</Text>
<Text style={styles.legalLinkText}>Privacy</Text>
</TouchableOpacity>
</View>
</View>
</ScrollView>
{/* Guest confirmation modal */}
{showGuestConfirm && (
<View style={styles.guestConfirmOverlay}>
<Pressable
@@ -425,8 +426,8 @@ export function Paywall({ visible, onClose, onSuccess }: PaywallProps) {
/>
<View style={styles.guestConfirmCard}>
<View style={styles.guestConfirmHeader}>
<View style={styles.guestConfirmIcon}>
<Ionicons name="shield-checkmark" size={22} color={colors.premium.gold} />
<View style={styles.guestConfirmIconWrapper}>
<Ionicons name="shield-checkmark" size={24} color={colors.premium.gold} />
</View>
<TouchableOpacity
onPress={() => setShowGuestConfirm(false)}
@@ -440,7 +441,7 @@ export function Paywall({ visible, onClose, onSuccess }: PaywallProps) {
<Text style={styles.guestConfirmTitle}>Save your account first?</Text>
<Text style={styles.guestConfirmBody}>
Unsaved accounts can't be recovered if you delete the app or switch devices.
Saving your account protects your access to Pro.
Saving protects your Pro access.
</Text>
<View style={styles.guestConfirmActions}>
@@ -452,7 +453,14 @@ export function Paywall({ visible, onClose, onSuccess }: PaywallProps) {
router.push("/(app)/settings/save-account" as any);
}}
>
<Text style={styles.guestConfirmPrimaryText}>Save account</Text>
<LinearGradient
colors={[colors.premium.gold, colors.premium.goldDark]}
style={styles.guestConfirmPrimaryGradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
>
<Text style={styles.guestConfirmPrimaryText}>Save account</Text>
</LinearGradient>
</Pressable>
<Pressable
@@ -463,7 +471,7 @@ export function Paywall({ visible, onClose, onSuccess }: PaywallProps) {
await doPurchase();
}}
>
<Text style={styles.guestConfirmSecondaryText}>Continue purchase</Text>
<Text style={styles.guestConfirmSecondaryText}>Continue anyway</Text>
</Pressable>
</View>
</View>
@@ -485,132 +493,84 @@ const styles = StyleSheet.create({
backgroundColor: colors.background,
borderTopLeftRadius: radius.xxl,
borderTopRightRadius: radius.xxl,
maxHeight: "90%",
borderWidth: 1,
borderBottomWidth: 0,
borderColor: 'rgba(212, 175, 55, 0.2)',
maxHeight: "92%",
overflow: 'hidden',
},
silkHighlight: {
topAccent: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: 150,
height: 3,
borderTopLeftRadius: radius.xxl,
borderTopRightRadius: radius.xxl,
},
premiumBorderAccent: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: 2,
backgroundColor: colors.premium.gold,
opacity: 0.4,
},
scrollContent: {
padding: spacing.lg,
paddingTop: spacing.xl,
paddingBottom: Platform.OS === "ios" ? 40 : spacing.lg,
paddingTop: spacing.xxl,
paddingBottom: Platform.OS === "ios" ? 44 : spacing.xl,
},
closeButton: {
position: "absolute",
top: spacing.md,
right: spacing.md,
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: 'rgba(255, 255, 255, 0.08)',
top: spacing.sm,
right: spacing.sm,
width: 36,
height: 36,
borderRadius: 18,
backgroundColor: colors.backgroundLight,
justifyContent: "center",
alignItems: "center",
zIndex: 10,
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.1)',
},
// Header
header: {
alignItems: "center",
marginBottom: spacing.xl,
},
label: {
...typography.caption1,
fontWeight: '600',
letterSpacing: 3,
color: colors.premium.gold,
textAlign: 'center',
marginBottom: spacing.md,
},
iconContainer: {
width: 72,
height: 72,
borderRadius: 36,
logoContainer: {
width: 88,
height: 88,
justifyContent: "center",
alignItems: "center",
overflow: 'hidden',
marginBottom: spacing.md,
shadowColor: colors.premium.gold,
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.6,
shadowRadius: 20,
elevation: 12,
marginBottom: spacing.lg,
},
logo: {
width: 88,
height: 88,
},
title: {
...typography.title1,
...typography.largeTitle,
color: colors.text,
marginBottom: spacing.xs,
textShadowColor: 'rgba(212, 175, 55, 0.3)',
textShadowOffset: { width: 0, height: 0 },
textShadowRadius: 10,
},
separator: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
marginVertical: spacing.md,
width: 140,
},
separatorLine: {
flex: 1,
height: 1,
backgroundColor: 'rgba(212, 175, 55, 0.3)',
},
separatorDiamond: {
width: 6,
height: 6,
backgroundColor: colors.premium.gold,
transform: [{ rotate: '45deg' }],
marginHorizontal: spacing.md,
opacity: 0.8,
},
subtitle: {
...typography.body,
color: colors.textSecondary,
textAlign: "center",
lineHeight: 24,
},
// Features
features: {
marginBottom: spacing.xl,
backgroundColor: 'rgba(212, 175, 55, 0.05)',
borderRadius: radius.lg,
padding: spacing.md,
borderWidth: 1,
borderColor: 'rgba(212, 175, 55, 0.1)',
gap: spacing.sm,
},
featureRow: {
flexDirection: "row",
alignItems: "center",
paddingVertical: spacing.sm,
paddingHorizontal: spacing.md,
backgroundColor: colors.backgroundLight,
borderRadius: radius.md,
},
featureIcon: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: 'rgba(212, 175, 55, 0.1)',
width: 44,
height: 44,
borderRadius: 22,
backgroundColor: colors.premium.goldLight,
justifyContent: "center",
alignItems: "center",
marginRight: spacing.md,
borderWidth: 1,
borderColor: 'rgba(212, 175, 55, 0.2)',
},
featureText: {
flex: 1,
@@ -622,7 +582,10 @@ const styles = StyleSheet.create({
featureDescription: {
...typography.subhead,
color: colors.textSecondary,
marginTop: 2,
},
// Packages
packages: {
marginBottom: spacing.lg,
gap: spacing.md,
@@ -638,27 +601,28 @@ const styles = StyleSheet.create({
},
packageCard: {
position: "relative",
backgroundColor: 'rgba(22, 33, 62, 0.4)',
backgroundColor: colors.backgroundLight,
borderRadius: radius.lg,
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.08)',
borderWidth: 2,
borderColor: colors.border,
padding: spacing.md,
overflow: "hidden",
},
packageCardSelected: {
borderColor: colors.premium.gold,
borderWidth: 2,
},
packageCardAnnual: {
borderColor: 'rgba(212, 175, 55, 0.3)',
borderColor: colors.premium.goldLight,
},
annualBackground: {
...StyleSheet.absoluteFillObject,
backgroundColor: colors.premium.goldLight,
opacity: 0.3,
},
savingsBadge: {
position: "absolute",
top: -1,
top: 0,
right: spacing.md,
flexDirection: 'row',
alignItems: 'center',
gap: 4,
paddingHorizontal: spacing.sm,
paddingVertical: 4,
borderBottomLeftRadius: radius.sm,
@@ -680,7 +644,7 @@ const styles = StyleSheet.create({
height: 24,
borderRadius: 12,
borderWidth: 2,
borderColor: 'rgba(255, 255, 255, 0.3)',
borderColor: colors.border,
justifyContent: "center",
alignItems: "center",
marginRight: spacing.md,
@@ -697,7 +661,7 @@ const styles = StyleSheet.create({
packageInfo: {
flex: 1,
},
packageTitleRow: {
packageHeader: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
@@ -707,19 +671,23 @@ const styles = StyleSheet.create({
color: colors.text,
},
packageTitleAnnual: {
...typography.headline,
color: colors.premium.champagne,
},
bestValueBadge: {
backgroundColor: 'rgba(212, 175, 55, 0.2)',
paddingHorizontal: spacing.xs,
recommendedBadge: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
backgroundColor: colors.premium.goldLight,
paddingHorizontal: spacing.sm,
paddingVertical: 2,
borderRadius: radius.xs,
},
bestValueText: {
recommendedText: {
...typography.caption2,
color: colors.premium.gold,
fontWeight: '700',
letterSpacing: 0.5,
letterSpacing: 0.3,
},
packagePrice: {
...typography.subhead,
@@ -727,9 +695,12 @@ const styles = StyleSheet.create({
marginTop: 2,
},
packagePriceAnnual: {
...typography.subhead,
color: colors.premium.champagne,
opacity: 0.8,
marginTop: 2,
},
// Error states
errorContainer: {
alignItems: "center",
padding: spacing.lg,
@@ -741,12 +712,10 @@ const styles = StyleSheet.create({
marginBottom: spacing.md,
},
retryButton: {
backgroundColor: 'rgba(212, 175, 55, 0.1)',
backgroundColor: colors.premium.goldLight,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
borderRadius: radius.md,
borderWidth: 1,
borderColor: 'rgba(212, 175, 55, 0.2)',
},
retryButtonText: {
...typography.subhead,
@@ -764,17 +733,17 @@ const styles = StyleSheet.create({
color: colors.error,
textAlign: "center",
},
// Actions
actions: {
marginBottom: spacing.lg,
},
purchaseButton: {
borderRadius: radius.lg,
overflow: "hidden",
...shadows.lg,
shadowColor: colors.premium.gold,
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 8,
elevation: 8,
shadowOpacity: 0.4,
},
purchaseButtonDisabled: {
opacity: 0.5,
@@ -786,7 +755,6 @@ const styles = StyleSheet.create({
backgroundColor: colors.premium.gold,
},
purchaseButtonGradient: {
flexDirection: 'row',
paddingVertical: spacing.md,
alignItems: "center",
justifyContent: 'center',
@@ -795,6 +763,7 @@ const styles = StyleSheet.create({
...typography.headline,
color: colors.background,
fontWeight: '700',
fontSize: 18,
},
restoreButton: {
alignItems: "center",
@@ -803,11 +772,12 @@ const styles = StyleSheet.create({
},
restoreText: {
...typography.subhead,
color: colors.premium.gold,
opacity: 0.8,
color: colors.textSecondary,
},
// Legal
legal: {
paddingHorizontal: spacing.md,
paddingHorizontal: spacing.sm,
},
legalText: {
...typography.caption1,
@@ -820,11 +790,11 @@ const styles = StyleSheet.create({
justifyContent: "center",
alignItems: "center",
marginTop: spacing.sm,
gap: spacing.xs,
gap: spacing.sm,
},
legalLinkText: {
...typography.caption1,
color: colors.premium.gold,
color: colors.textSecondary,
textDecorationLine: "underline",
},
legalSeparator: {
@@ -832,21 +802,22 @@ const styles = StyleSheet.create({
color: colors.textTertiary,
},
// Guest confirm modal
guestConfirmOverlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: "rgba(0, 0, 0, 0.7)",
backgroundColor: "rgba(0, 0, 0, 0.8)",
justifyContent: "center",
alignItems: "center",
padding: spacing.lg,
},
guestConfirmCard: {
width: "100%",
maxWidth: 420,
backgroundColor: "rgba(22, 33, 62, 0.98)",
maxWidth: 400,
backgroundColor: colors.backgroundLight,
borderRadius: radius.xl,
padding: spacing.lg,
borderWidth: 1,
borderColor: "rgba(212, 175, 55, 0.2)",
borderColor: colors.border,
},
guestConfirmHeader: {
flexDirection: "row",
@@ -854,23 +825,21 @@ const styles = StyleSheet.create({
justifyContent: "space-between",
marginBottom: spacing.md,
},
guestConfirmIcon: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: "rgba(212, 175, 55, 0.12)",
guestConfirmIconWrapper: {
width: 48,
height: 48,
borderRadius: 24,
backgroundColor: colors.premium.goldLight,
justifyContent: "center",
alignItems: "center",
},
guestConfirmClose: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: "rgba(255, 255, 255, 0.08)",
width: 36,
height: 36,
borderRadius: 18,
backgroundColor: colors.background,
justifyContent: "center",
alignItems: "center",
borderWidth: 1,
borderColor: "rgba(255, 255, 255, 0.1)",
},
guestConfirmTitle: {
...typography.title3,
@@ -878,19 +847,20 @@ const styles = StyleSheet.create({
marginBottom: spacing.sm,
},
guestConfirmBody: {
...typography.subhead,
...typography.body,
color: colors.textSecondary,
lineHeight: 20,
lineHeight: 22,
marginBottom: spacing.lg,
textAlign: "left",
},
guestConfirmActions: {
gap: spacing.sm,
},
guestConfirmPrimary: {
backgroundColor: colors.premium.gold,
paddingVertical: spacing.md,
borderRadius: radius.lg,
overflow: 'hidden',
},
guestConfirmPrimaryGradient: {
paddingVertical: spacing.md,
alignItems: "center",
},
guestConfirmPrimaryText: {
@@ -899,16 +869,16 @@ const styles = StyleSheet.create({
fontWeight: "700",
},
guestConfirmSecondary: {
backgroundColor: "rgba(255, 255, 255, 0.06)",
backgroundColor: colors.background,
paddingVertical: spacing.md,
borderRadius: radius.lg,
alignItems: "center",
borderWidth: 1,
borderColor: "rgba(255, 255, 255, 0.12)",
borderColor: colors.border,
},
guestConfirmSecondaryText: {
...typography.subhead,
color: colors.text,
color: colors.textSecondary,
fontWeight: "600",
},
});
@@ -0,0 +1,452 @@
import { useState, useEffect } from "react";
import { View, Text, StyleSheet, Platform, TouchableOpacity } from "react-native";
import { LinearGradient } from "expo-linear-gradient";
import Animated, {
useSharedValue,
useAnimatedStyle,
withSpring,
withRepeat,
withSequence,
withTiming,
interpolate,
Easing,
FadeInDown,
} from "react-native-reanimated";
import { Ionicons } from "@expo/vector-icons";
import { colors, gradients, radius, shadows, typography, spacing, animations } from "../../theme";
import { QuestionFeedbackModal } from "../feedback";
import { SharePreviewModal } from "../share";
const ACTION_BUTTON_SIZE = 72;
const ACTION_BUTTON_OVERHANG_PERCENT = 0.5; // 50% of button hangs below the card
const actionButtonOffset = ACTION_BUTTON_SIZE * ACTION_BUTTON_OVERHANG_PERCENT;
const actionButtonBackground = colors.muted;
// Haptics helper - not supported on web
const triggerHaptic = async (style: 'light' | 'medium' | 'heavy' = 'medium') => {
if (Platform.OS === 'web') return;
const Haptics = await import('expo-haptics');
const feedbackStyle = style === 'light'
? Haptics.ImpactFeedbackStyle.Light
: style === 'heavy'
? Haptics.ImpactFeedbackStyle.Heavy
: Haptics.ImpactFeedbackStyle.Medium;
await Haptics.impactAsync(feedbackStyle);
};
export interface QuestionCardProps {
question: {
id: string;
text: string;
intensity: number;
partner_text?: string | null;
is_two_part?: boolean;
partner_answered?: boolean;
question_type?: string;
};
packInfo?: { name: string; icon: string; color?: string } | null;
onAnswer: (answer: 'yes' | 'no' | 'skip') => void;
onReport?: () => void;
}
export default function QuestionCard({ question, packInfo, onAnswer, onReport }: QuestionCardProps) {
const [showFeedbackModal, setShowFeedbackModal] = useState(false);
const [showShareModal, setShowShareModal] = useState(false);
const idleBreathing = useSharedValue(0);
const handleSharePress = async () => {
await triggerHaptic('light');
setShowShareModal(true);
};
// Subtle idle breathing animation
useEffect(() => {
idleBreathing.value = withRepeat(
withSequence(
withTiming(1, { duration: 2000, easing: Easing.inOut(Easing.sin) }),
withTiming(0, { duration: 2000, easing: Easing.inOut(Easing.sin) })
),
-1,
true
);
}, []);
const animatedStyle = useAnimatedStyle(() => {
const idleOffset = interpolate(idleBreathing.value, [0, 1], [0, -4]);
return {
transform: [{ translateY: idleOffset }],
};
});
const handleButtonPress = async (answer: 'yes' | 'no' | 'skip') => {
await triggerHaptic(answer === 'skip' ? 'light' : 'medium');
onAnswer(answer);
};
const handleFeedbackPress = () => {
if (onReport) {
onReport();
} else {
setShowFeedbackModal(true);
}
};
return (
<>
<Animated.View
entering={FadeInDown.duration(400).springify()}
style={styles.cardWrapper}
>
<Animated.View style={[styles.cardOuter, animatedStyle, shadows.lg, { shadowColor: colors.primary }]}>
<View
style={[
styles.gradientContainer,
{ backgroundColor: packInfo?.color || colors.background }
]}
>
<CardContent
question={question}
packInfo={packInfo}
handleButtonPress={handleButtonPress}
onFeedbackPress={handleFeedbackPress}
onSharePress={handleSharePress}
/>
</View>
</Animated.View>
</Animated.View>
<QuestionFeedbackModal
visible={showFeedbackModal}
onClose={() => setShowFeedbackModal(false)}
questionId={question.id}
questionText={question.text}
/>
<SharePreviewModal
visible={showShareModal}
onClose={() => setShowShareModal(false)}
question={question}
packName={packInfo?.name}
cardColor={packInfo?.color}
/>
</>
);
}
function CardContent({
question,
packInfo,
handleButtonPress,
onFeedbackPress,
onSharePress,
}: {
question: QuestionCardProps['question'];
packInfo?: { name: string; icon: string } | null;
handleButtonPress: (answer: 'yes' | 'no' | 'skip') => void;
onFeedbackPress: () => void;
onSharePress: () => void;
}) {
return (
<View style={styles.cardInner}>
{/* Header Row - Pack badge and action buttons */}
<View style={styles.headerRow}>
{/* Pack Badge */}
{packInfo && (
<View style={styles.packBadge}>
<Ionicons name={packInfo.icon as any} size={14} color={colors.text} />
<Text style={styles.packBadgeText} numberOfLines={1}>
{packInfo.name}
</Text>
</View>
)}
{!packInfo && <View style={styles.headerSpacer} />}
{/* Action Buttons */}
<View style={styles.headerActions}>
<TouchableOpacity
style={styles.headerButton}
onPress={onSharePress}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
activeOpacity={0.7}
>
<Ionicons name="share-outline" size={20} color="rgba(255,255,255,0.8)" />
</TouchableOpacity>
<TouchableOpacity
style={styles.headerButton}
onPress={onFeedbackPress}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
activeOpacity={0.7}
>
<Ionicons name="flag-outline" size={20} color="rgba(255,255,255,0.6)" />
</TouchableOpacity>
</View>
</View>
{/* Content - Question text centered */}
<View style={styles.content}>
<Text style={[styles.text, question.is_two_part && styles.twoPartText]}>
{question.text}
</Text>
{/* Partner's version of the question - only show when user is answering first */}
{question.partner_text && !question.partner_answered && (
<View style={styles.partnerTextContainer}>
<View style={styles.partnerDivider}>
<View style={styles.partnerDividerLine} />
<View style={styles.partnerLabelContainer}>
<Ionicons name="swap-horizontal" size={12} color="rgba(255,255,255,0.8)" />
<Text style={styles.partnerLabel}>Partner sees</Text>
</View>
<View style={styles.partnerDividerLine} />
</View>
<Text style={styles.partnerText}>{question.partner_text}</Text>
</View>
)}
</View>
{/* Footer - Action Buttons pinned to bottom */}
<View style={styles.footer}>
<View style={styles.buttonContainer}>
<ActionButton
onPress={() => handleButtonPress("no")}
icon="close"
iconColor={colors.error}
label="NO"
/>
{/* Skip button in between */}
<TouchableOpacity
style={styles.skipButtonCenter}
onPress={() => handleButtonPress("skip")}
activeOpacity={0.7}
>
<Text style={styles.skipText}>Skip</Text>
</TouchableOpacity>
<ActionButton
onPress={() => handleButtonPress("yes")}
icon="checkmark"
iconColor={colors.success}
label="YES"
/>
</View>
</View>
</View>
);
}
function ActionButton({
onPress,
icon,
iconColor,
label,
small = false,
}: {
onPress: () => void;
icon: string;
iconColor: string;
label: string;
small?: boolean;
}) {
const buttonScale = useSharedValue(1);
const handlePressIn = () => {
buttonScale.value = withSpring(0.92, animations.spring);
};
const handlePressOut = () => {
buttonScale.value = withSpring(1, animations.spring);
};
const buttonAnimatedStyle = useAnimatedStyle(() => ({
transform: [{ scale: buttonScale.value }],
}));
return (
<TouchableOpacity
onPress={onPress}
onPressIn={handlePressIn}
onPressOut={handlePressOut}
activeOpacity={1}
style={styles.actionButtonWrapper}
>
<Animated.View style={[
styles.actionButton,
small && styles.actionButtonSmall,
buttonAnimatedStyle,
{ backgroundColor: actionButtonBackground }
]}>
<Ionicons
name={icon as any}
size={small ? 20 : 32}
color={iconColor}
style={styles.buttonIcon}
/>
</Animated.View>
</TouchableOpacity>
);
}
const styles = StyleSheet.create({
cardWrapper: {
width: "90%",
maxWidth: 400,
flex: 1,
maxHeight: 580 - actionButtonOffset, // Account for button overhang
},
cardOuter: {
flex: 1,
borderRadius: radius.xxl,
overflow: "visible",
},
gradientContainer: {
flex: 1,
borderRadius: radius.xxl,
overflow: "visible",
},
cardInner: {
flex: 1,
paddingTop: spacing.md,
paddingBottom: spacing.sm,
paddingHorizontal: spacing.lg,
},
// Header row styles
headerRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: spacing.md,
},
headerSpacer: {
flex: 1,
},
packBadge: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: 'rgba(255,255,255,0.15)',
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: radius.full,
gap: spacing.xs,
maxWidth: '60%',
},
packBadgeText: {
...typography.caption1,
fontWeight: '600',
color: colors.text,
},
headerActions: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
},
headerButton: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: 'rgba(255,255,255,0.12)',
justifyContent: 'center',
alignItems: 'center',
},
// Content styles
content: {
flex: 1,
justifyContent: "center",
alignItems: "center",
paddingHorizontal: spacing.sm,
},
text: {
...typography.title1,
color: colors.text,
textAlign: "center",
lineHeight: 36,
},
twoPartText: {
...typography.title2,
lineHeight: 32,
},
partnerTextContainer: {
marginTop: spacing.lg,
width: '100%',
paddingHorizontal: spacing.sm,
},
partnerDivider: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: spacing.sm,
},
partnerDividerLine: {
flex: 1,
height: 1,
backgroundColor: 'rgba(255,255,255,0.2)',
},
partnerLabelContainer: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.md,
paddingVertical: spacing.xs,
backgroundColor: 'rgba(255,255,255,0.15)',
borderRadius: radius.full,
gap: spacing.xs,
},
partnerLabel: {
...typography.caption1,
color: 'rgba(255,255,255,0.8)',
fontWeight: '500',
letterSpacing: 0.3,
},
partnerText: {
...typography.subhead,
color: 'rgba(255,255,255,0.7)',
textAlign: 'center',
fontStyle: 'italic',
},
// Footer styles
footer: {
paddingTop: spacing.md,
transform: [{ translateY: actionButtonOffset }],
},
buttonContainer: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "flex-end",
width: '100%',
paddingHorizontal: spacing.md,
},
actionButtonWrapper: {
alignItems: 'center',
gap: spacing.xs,
},
actionButton: {
width: 72,
height: 72,
borderRadius: 36,
justifyContent: "center",
alignItems: "center",
overflow: "hidden",
elevation: 4,
shadowColor: "#000",
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 4,
},
actionButtonSmall: {
width: 52,
height: 52,
borderRadius: 26,
},
buttonIcon: {
zIndex: 1,
},
skipButtonCenter: {
paddingVertical: spacing.sm,
paddingHorizontal: spacing.md,
marginBottom: 30, // Align above center of 72px buttons
},
skipText: {
...typography.caption1,
color: 'rgba(255,255,255,0.5)',
fontWeight: '600',
},
});
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,630 @@
import { useState, useEffect } from "react";
import { View, Text, StyleSheet, Platform, TouchableOpacity, Image } from "react-native";
import { LinearGradient } from "expo-linear-gradient";
import Animated, {
useSharedValue,
useAnimatedStyle,
withSpring,
withRepeat,
withSequence,
withTiming,
interpolate,
Easing,
FadeInDown,
FadeIn,
} from "react-native-reanimated";
import { Ionicons } from "@expo/vector-icons";
import { colors, gradients, radius, shadows, typography, spacing, animations } from "../../theme";
import { useMediaPicker } from "../../hooks/useMediaPicker";
import { SharePreviewModal } from "../share";
const ACTION_BUTTON_SIZE = 72;
const ACTION_BUTTON_OVERHANG_PERCENT = 0.5;
const actionButtonOffset = ACTION_BUTTON_SIZE * ACTION_BUTTON_OVERHANG_PERCENT;
const actionButtonBackground = colors.muted;
// Haptics helper - not supported on web
const triggerHaptic = async (style: 'light' | 'medium' | 'heavy' = 'medium') => {
if (Platform.OS === 'web') return;
const Haptics = await import('expo-haptics');
const feedbackStyle = style === 'light'
? Haptics.ImpactFeedbackStyle.Light
: style === 'heavy'
? Haptics.ImpactFeedbackStyle.Heavy
: Haptics.ImpactFeedbackStyle.Medium;
await Haptics.impactAsync(feedbackStyle);
};
export interface QuestionCardPhotoProps {
question: {
id: string;
text: string;
intensity: number;
partner_text?: string | null;
partner_answered?: boolean;
};
packInfo?: { name: string; icon: string; color?: string } | null;
onAnswer: (answer: 'yes' | 'skip', responseData?: { type: 'photo'; media_path: string }) => void;
onReport?: () => void;
}
export default function QuestionCardPhoto({ question, packInfo, onAnswer, onReport }: QuestionCardPhotoProps) {
const [selectedPhotoUri, setSelectedPhotoUri] = useState<string | null>(null);
const [showShareModal, setShowShareModal] = useState(false);
const { takePhoto, pickMedia } = useMediaPicker({ imageQuality: 0.8 });
const idleBreathing = useSharedValue(0);
const handleSharePress = async () => {
await triggerHaptic('light');
setShowShareModal(true);
};
// Subtle idle breathing animation
useEffect(() => {
idleBreathing.value = withRepeat(
withSequence(
withTiming(1, { duration: 2000, easing: Easing.inOut(Easing.sin) }),
withTiming(0, { duration: 2000, easing: Easing.inOut(Easing.sin) })
),
-1,
true
);
}, []);
const animatedStyle = useAnimatedStyle(() => {
const idleOffset = interpolate(idleBreathing.value, [0, 1], [0, -4]);
return {
transform: [{ translateY: idleOffset }],
};
});
const handleTakePhoto = async () => {
await triggerHaptic('light');
const result = await takePhoto();
if (result) {
setSelectedPhotoUri(result.uri);
}
};
const handlePickFromLibrary = async () => {
await triggerHaptic('light');
const result = await pickMedia();
if (result && result.mediaType === 'image') {
setSelectedPhotoUri(result.uri);
}
};
const handleConfirm = async () => {
if (!selectedPhotoUri) return;
await triggerHaptic('medium');
onAnswer('yes', { type: 'photo', media_path: selectedPhotoUri });
};
const handleRetake = async () => {
await triggerHaptic('light');
setSelectedPhotoUri(null);
};
const handleCancel = async () => {
await triggerHaptic('light');
setSelectedPhotoUri(null);
};
const handleSkip = async () => {
await triggerHaptic('light');
onAnswer('skip');
};
return (
<>
<Animated.View
entering={FadeInDown.duration(400).springify()}
style={styles.cardWrapper}
>
<Animated.View style={[styles.cardOuter, animatedStyle, shadows.lg, { shadowColor: colors.primary }]}>
<View
style={[
styles.gradientContainer,
{ backgroundColor: packInfo?.color || colors.background }
]}
>
{selectedPhotoUri ? (
<PhotoPreviewContent
question={question}
packInfo={packInfo}
photoUri={selectedPhotoUri}
onConfirm={handleConfirm}
onRetake={handleRetake}
onCancel={handleCancel}
onReport={onReport}
/>
) : (
<PhotoSelectContent
question={question}
packInfo={packInfo}
onTakePhoto={handleTakePhoto}
onPickFromLibrary={handlePickFromLibrary}
onSkip={handleSkip}
onReport={onReport}
onSharePress={handleSharePress}
/>
)}
</View>
</Animated.View>
</Animated.View>
<SharePreviewModal
visible={showShareModal}
onClose={() => setShowShareModal(false)}
question={question}
packName={packInfo?.name}
cardColor={packInfo?.color}
/>
</>
);
}
// State A - No photo selected
function PhotoSelectContent({
question,
packInfo,
onTakePhoto,
onPickFromLibrary,
onSkip,
onReport,
onSharePress,
}: {
question: QuestionCardPhotoProps['question'];
packInfo?: { name: string; icon: string } | null;
onTakePhoto: () => void;
onPickFromLibrary: () => void;
onSkip: () => void;
onReport?: () => void;
onSharePress: () => void;
}) {
return (
<View style={styles.cardInner}>
{/* Header Row */}
<View style={styles.headerRow}>
{packInfo && (
<View style={styles.packBadge}>
<Ionicons name={packInfo.icon as any} size={14} color={colors.text} />
<Text style={styles.packBadgeText} numberOfLines={1}>
{packInfo.name}
</Text>
</View>
)}
{!packInfo && <View style={styles.headerSpacer} />}
<View style={styles.headerActions}>
<TouchableOpacity
style={styles.headerButton}
onPress={onSharePress}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
activeOpacity={0.7}
>
<Ionicons name="share-outline" size={20} color="rgba(255,255,255,0.8)" />
</TouchableOpacity>
{onReport && (
<TouchableOpacity
style={styles.headerButton}
onPress={onReport}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
activeOpacity={0.7}
>
<Ionicons name="flag-outline" size={20} color="rgba(255,255,255,0.6)" />
</TouchableOpacity>
)}
</View>
</View>
{/* Content */}
<View style={styles.content}>
<Text style={styles.text}>
{question.text}
</Text>
</View>
{/* Footer */}
<View style={styles.footer}>
<View style={styles.photoButtonContainer}>
<PhotoActionButton
onPress={onPickFromLibrary}
icon="images"
label="Library"
colors={[colors.secondary, colors.secondaryDark]}
/>
<TouchableOpacity
style={styles.skipButtonCenter}
onPress={onSkip}
activeOpacity={0.7}
>
<Text style={styles.skipText}>Skip</Text>
</TouchableOpacity>
<PhotoActionButton
onPress={onTakePhoto}
icon="camera"
label="Camera"
colors={gradients.primary as [string, string]}
/>
</View>
</View>
</View>
);
}
// State B - Photo preview
function PhotoPreviewContent({
question,
packInfo,
photoUri,
onConfirm,
onRetake,
onCancel,
onReport,
}: {
question: QuestionCardPhotoProps['question'];
packInfo?: { name: string; icon: string } | null;
photoUri: string;
onConfirm: () => void;
onRetake: () => void;
onCancel: () => void;
onReport?: () => void;
}) {
return (
<View style={styles.cardInner}>
{/* Header Row */}
<View style={styles.headerRow}>
{packInfo && (
<View style={styles.packBadge}>
<Ionicons name={packInfo.icon as any} size={14} color={colors.text} />
<Text style={styles.packBadgeText} numberOfLines={1}>
{packInfo.name}
</Text>
</View>
)}
{!packInfo && <View style={styles.headerSpacer} />}
<View style={styles.headerActions}>
{onReport && (
<TouchableOpacity
style={styles.headerButton}
onPress={onReport}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
activeOpacity={0.7}
>
<Ionicons name="flag-outline" size={20} color="rgba(255,255,255,0.6)" />
</TouchableOpacity>
)}
</View>
</View>
{/* Content */}
<View style={styles.previewContent}>
<Text style={styles.previewText} numberOfLines={2}>
{question.text}
</Text>
{/* Photo Preview */}
<Animated.View
entering={FadeIn.duration(300)}
style={styles.photoPreviewContainer}
>
<Image
source={{ uri: photoUri }}
style={styles.photoPreview}
resizeMode="cover"
/>
<View style={styles.photoPreviewBorder} pointerEvents="none" />
</Animated.View>
</View>
{/* Footer */}
<View style={styles.previewFooter}>
<View style={styles.previewButtonRow}>
<TouchableOpacity
style={styles.secondaryButton}
onPress={onCancel}
activeOpacity={0.7}
>
<Ionicons name="close" size={20} color="rgba(255,255,255,0.7)" />
<Text style={styles.secondaryButtonText}>Cancel</Text>
</TouchableOpacity>
<ConfirmButton onPress={onConfirm} />
<TouchableOpacity
style={styles.secondaryButton}
onPress={onRetake}
activeOpacity={0.7}
>
<Ionicons name="refresh" size={20} color="rgba(255,255,255,0.7)" />
<Text style={styles.secondaryButtonText}>Retake</Text>
</TouchableOpacity>
</View>
</View>
</View>
);
}
function PhotoActionButton({
onPress,
colors: _buttonColors,
icon,
label,
}: {
onPress: () => void;
colors: [string, string];
icon: string;
label: string;
}) {
const buttonScale = useSharedValue(1);
const handlePressIn = () => {
buttonScale.value = withSpring(0.95, animations.spring);
};
const handlePressOut = () => {
buttonScale.value = withSpring(1, animations.spring);
};
const buttonAnimatedStyle = useAnimatedStyle(() => ({
transform: [{ scale: buttonScale.value }],
}));
return (
<TouchableOpacity
onPress={onPress}
onPressIn={handlePressIn}
onPressOut={handlePressOut}
activeOpacity={1}
style={styles.photoActionWrapper}
>
<Animated.View style={[
styles.photoActionButton,
buttonAnimatedStyle,
{ backgroundColor: actionButtonBackground }
]}>
<Ionicons
name={icon as any}
size={28}
color="white"
style={styles.buttonIcon}
/>
</Animated.View>
</TouchableOpacity>
);
}
function ConfirmButton({ onPress }: { onPress: () => void }) {
const buttonScale = useSharedValue(1);
const handlePressIn = () => {
buttonScale.value = withSpring(0.92, animations.spring);
};
const handlePressOut = () => {
buttonScale.value = withSpring(1, animations.spring);
};
const buttonAnimatedStyle = useAnimatedStyle(() => ({
transform: [{ scale: buttonScale.value }],
}));
return (
<TouchableOpacity
onPress={onPress}
onPressIn={handlePressIn}
onPressOut={handlePressOut}
activeOpacity={1}
>
<Animated.View style={[
styles.confirmButton,
buttonAnimatedStyle,
{ backgroundColor: colors.success }
]}>
<LinearGradient
colors={gradients.success as [string, string]}
style={StyleSheet.absoluteFill}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
/>
<Ionicons
name="checkmark"
size={28}
color={colors.text}
style={styles.buttonIcon}
/>
</Animated.View>
</TouchableOpacity>
);
}
const styles = StyleSheet.create({
cardWrapper: {
width: "90%",
maxWidth: 400,
flex: 1,
maxHeight: 580 - actionButtonOffset, // Account for button overhang
},
cardOuter: {
flex: 1,
borderRadius: radius.xxl,
overflow: "visible",
},
gradientContainer: {
flex: 1,
borderRadius: radius.xxl,
overflow: "visible",
},
cardInner: {
flex: 1,
paddingTop: spacing.md,
paddingBottom: spacing.sm,
paddingHorizontal: spacing.lg,
},
// Header row styles
headerRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: spacing.md,
},
headerSpacer: {
flex: 1,
},
packBadge: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: 'rgba(255,255,255,0.15)',
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: radius.full,
gap: spacing.xs,
maxWidth: '60%',
},
packBadgeText: {
...typography.caption1,
fontWeight: '600',
color: colors.text,
},
headerActions: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
},
headerButton: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: 'rgba(255,255,255,0.12)',
justifyContent: 'center',
alignItems: 'center',
},
// Content styles
content: {
flex: 1,
justifyContent: "center",
alignItems: "center",
paddingHorizontal: spacing.sm,
},
text: {
...typography.title1,
color: colors.text,
textAlign: "center",
lineHeight: 36,
},
// Footer styles
footer: {
paddingTop: spacing.md,
transform: [{ translateY: actionButtonOffset }],
},
previewFooter: {
paddingTop: spacing.md,
paddingBottom: spacing.sm,
},
photoButtonContainer: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "flex-end",
width: '100%',
paddingHorizontal: spacing.md,
},
photoActionWrapper: {
alignItems: 'center',
gap: spacing.xs,
},
photoActionButton: {
width: 72,
height: 72,
borderRadius: 36,
justifyContent: "center",
alignItems: "center",
overflow: "hidden",
elevation: 4,
shadowColor: "#000",
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 4,
},
buttonIcon: {
zIndex: 1,
},
photoActionButtonLabel: {
...typography.caption1,
color: colors.text,
fontWeight: '600',
},
skipButtonCenter: {
paddingVertical: spacing.sm,
paddingHorizontal: spacing.md,
marginBottom: 30, // Align above center of 72px buttons
},
skipText: {
...typography.caption1,
color: 'rgba(255,255,255,0.5)',
fontWeight: '600',
},
// Preview state styles
previewContent: {
flex: 1,
paddingTop: spacing.sm,
},
previewText: {
...typography.headline,
color: colors.text,
textAlign: "center",
marginBottom: spacing.md,
},
photoPreviewContainer: {
flex: 1,
marginBottom: spacing.sm,
borderRadius: radius.lg,
overflow: 'hidden',
backgroundColor: 'rgba(0,0,0,0.2)',
},
photoPreview: {
width: '100%',
height: '100%',
borderRadius: radius.lg,
},
photoPreviewBorder: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
borderRadius: radius.lg,
borderWidth: 2,
borderColor: 'rgba(255,255,255,0.2)',
},
previewButtonRow: {
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
gap: spacing.md,
},
secondaryButton: {
alignItems: 'center',
justifyContent: 'center',
paddingVertical: spacing.sm,
paddingHorizontal: spacing.md,
gap: spacing.xs,
},
secondaryButtonText: {
...typography.caption1,
color: 'rgba(255,255,255,0.7)',
},
confirmButton: {
width: 64,
height: 64,
borderRadius: 32,
justifyContent: "center",
alignItems: "center",
overflow: "hidden",
},
});
@@ -0,0 +1,496 @@
import { useState, useEffect } from "react";
import {
View,
Text,
StyleSheet,
Platform,
TouchableOpacity,
TextInput,
Keyboard,
TouchableWithoutFeedback,
} from "react-native";
import { LinearGradient } from "expo-linear-gradient";
import Animated, {
useSharedValue,
useAnimatedStyle,
withSpring,
withRepeat,
withSequence,
withTiming,
interpolate,
Easing,
FadeInDown,
} from "react-native-reanimated";
import { Ionicons } from "@expo/vector-icons";
import { colors, gradients, radius, shadows, typography, spacing, animations } from "../../theme";
import { QuestionFeedbackModal } from "../feedback";
import { SharePreviewModal } from "../share";
const ACTION_BUTTON_SIZE = 72;
const ACTION_BUTTON_OVERHANG_PERCENT = 0.5;
const actionButtonOffset = ACTION_BUTTON_SIZE * ACTION_BUTTON_OVERHANG_PERCENT;
const actionButtonBackground = colors.muted;
// Haptics helper - not supported on web
const triggerHaptic = async (style: 'light' | 'medium' | 'heavy' = 'medium') => {
if (Platform.OS === 'web') return;
const Haptics = await import('expo-haptics');
const feedbackStyle = style === 'light'
? Haptics.ImpactFeedbackStyle.Light
: style === 'heavy'
? Haptics.ImpactFeedbackStyle.Heavy
: Haptics.ImpactFeedbackStyle.Medium;
await Haptics.impactAsync(feedbackStyle);
};
const MAX_TEXT_LENGTH = 500;
export interface QuestionCardTextProps {
question: {
id: string;
text: string;
intensity: number;
partner_text?: string | null;
partner_answered?: boolean;
};
packInfo?: { name: string; icon: string; color?: string } | null;
onAnswer: (answer: 'yes' | 'no' | 'skip', responseData?: { type: 'text_answer'; text: string }) => void;
onReport?: () => void;
}
export default function QuestionCardText({ question, packInfo, onAnswer, onReport }: QuestionCardTextProps) {
const [showFeedbackModal, setShowFeedbackModal] = useState(false);
const [showShareModal, setShowShareModal] = useState(false);
const [textValue, setTextValue] = useState('');
const [isFocused, setIsFocused] = useState(false);
const idleBreathing = useSharedValue(0);
const handleSharePress = async () => {
await triggerHaptic('light');
setShowShareModal(true);
};
// Subtle idle breathing animation
useEffect(() => {
idleBreathing.value = withRepeat(
withSequence(
withTiming(1, { duration: 2000, easing: Easing.inOut(Easing.sin) }),
withTiming(0, { duration: 2000, easing: Easing.inOut(Easing.sin) })
),
-1,
true
);
}, []);
const animatedStyle = useAnimatedStyle(() => {
const idleOffset = interpolate(idleBreathing.value, [0, 1], [0, -4]);
return {
transform: [{ translateY: idleOffset }],
};
});
const handleButtonPress = async (answer: 'yes' | 'no' | 'skip') => {
Keyboard.dismiss();
await triggerHaptic(answer === 'skip' ? 'light' : 'medium');
if (answer === 'yes' && textValue.trim().length > 0) {
onAnswer(answer, { type: 'text_answer', text: textValue.trim() });
} else {
onAnswer(answer);
}
};
const handleFeedbackPress = () => {
if (onReport) {
onReport();
} else {
setShowFeedbackModal(true);
}
};
const isYesDisabled = textValue.trim().length === 0;
return (
<>
<Animated.View
entering={FadeInDown.duration(400).springify()}
style={styles.cardWrapper}
>
<Animated.View style={[styles.cardOuter, animatedStyle, shadows.lg, { shadowColor: colors.primary }]}>
<TouchableWithoutFeedback onPress={Keyboard.dismiss} accessible={false}>
<View
style={[
styles.gradientContainer,
{ backgroundColor: packInfo?.color || colors.background }
]}
>
<CardContent
question={question}
packInfo={packInfo}
textValue={textValue}
setTextValue={setTextValue}
isFocused={isFocused}
setIsFocused={setIsFocused}
isYesDisabled={isYesDisabled}
handleButtonPress={handleButtonPress}
onFeedbackPress={handleFeedbackPress}
onSharePress={handleSharePress}
/>
</View>
</TouchableWithoutFeedback>
</Animated.View>
</Animated.View>
<QuestionFeedbackModal
visible={showFeedbackModal}
onClose={() => setShowFeedbackModal(false)}
questionId={question.id}
questionText={question.text}
/>
<SharePreviewModal
visible={showShareModal}
onClose={() => setShowShareModal(false)}
question={question}
packName={packInfo?.name}
cardColor={packInfo?.color}
/>
</>
);
}
function CardContent({
question,
packInfo,
textValue,
setTextValue,
isFocused,
setIsFocused,
isYesDisabled,
handleButtonPress,
onFeedbackPress,
onSharePress,
}: {
question: QuestionCardTextProps['question'];
packInfo?: { name: string; icon: string } | null;
textValue: string;
setTextValue: (value: string) => void;
isFocused: boolean;
setIsFocused: (focused: boolean) => void;
isYesDisabled: boolean;
handleButtonPress: (answer: 'yes' | 'no' | 'skip') => void;
onFeedbackPress: () => void;
onSharePress: () => void;
}) {
return (
<View style={styles.cardInner}>
{/* Header Row - Pack badge and action buttons */}
<View style={styles.headerRow}>
{/* Pack Badge */}
{packInfo && (
<View style={styles.packBadge}>
<Ionicons name={packInfo.icon as any} size={14} color={colors.text} />
<Text style={styles.packBadgeText} numberOfLines={1}>
{packInfo.name}
</Text>
</View>
)}
{!packInfo && <View style={styles.headerSpacer} />}
{/* Action Buttons */}
<View style={styles.headerActions}>
<TouchableOpacity
style={styles.headerButton}
onPress={onSharePress}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
activeOpacity={0.7}
>
<Ionicons name="share-outline" size={20} color="rgba(255,255,255,0.8)" />
</TouchableOpacity>
<TouchableOpacity
style={styles.headerButton}
onPress={onFeedbackPress}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
activeOpacity={0.7}
>
<Ionicons name="flag-outline" size={20} color="rgba(255,255,255,0.6)" />
</TouchableOpacity>
</View>
</View>
{/* Content */}
<View style={styles.content}>
<Text style={styles.text}>
{question.text}
</Text>
{/* Text Input Area - Glass style */}
<View style={[
styles.inputContainer,
isFocused && styles.inputContainerFocused
]}>
<TextInput
style={styles.textInput}
value={textValue}
onChangeText={(text) => setTextValue(text.slice(0, MAX_TEXT_LENGTH))}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
placeholder="Type your answer..."
placeholderTextColor="rgba(255,255,255,0.4)"
multiline
textAlignVertical="top"
maxLength={MAX_TEXT_LENGTH}
/>
<View style={styles.charCountContainer}>
<Text style={[
styles.charCount,
textValue.length >= MAX_TEXT_LENGTH && styles.charCountMax
]}>
{textValue.length}/{MAX_TEXT_LENGTH}
</Text>
</View>
</View>
</View>
{/* Footer - Action Buttons */}
<View style={styles.footer}>
<View style={styles.buttonContainer}>
<ActionButton
onPress={() => handleButtonPress("no")}
icon="close"
iconColor={colors.error}
label="NO"
/>
{/* Skip button in between */}
<TouchableOpacity
style={styles.skipButtonCenter}
onPress={() => handleButtonPress("skip")}
activeOpacity={0.7}
>
<Text style={styles.skipText}>Skip</Text>
</TouchableOpacity>
<ActionButton
onPress={() => handleButtonPress("yes")}
icon="checkmark"
iconColor={colors.success}
label="YES"
disabled={isYesDisabled}
/>
</View>
</View>
</View>
);
}
function ActionButton({
onPress,
icon,
iconColor,
label,
disabled = false,
}: {
onPress: () => void;
icon: string;
iconColor: string;
label: string;
disabled?: boolean;
}) {
const buttonScale = useSharedValue(1);
const handlePressIn = () => {
if (disabled) return;
buttonScale.value = withSpring(0.92, animations.spring);
};
const handlePressOut = () => {
if (disabled) return;
buttonScale.value = withSpring(1, animations.spring);
};
const handlePress = () => {
if (disabled) return;
onPress();
};
const buttonAnimatedStyle = useAnimatedStyle(() => ({
transform: [{ scale: buttonScale.value }],
}));
return (
<TouchableOpacity
onPress={handlePress}
onPressIn={handlePressIn}
onPressOut={handlePressOut}
activeOpacity={disabled ? 1 : 1}
disabled={disabled}
style={styles.actionButtonWrapper}
>
<Animated.View style={[
styles.actionButton,
buttonAnimatedStyle,
{ backgroundColor: actionButtonBackground }
]}>
<Ionicons
name={icon as any}
size={32}
color={iconColor}
style={[styles.buttonIcon, disabled && { opacity: 0.4 }]}
/>
</Animated.View>
</TouchableOpacity>
);
}
const styles = StyleSheet.create({
cardWrapper: {
width: "90%",
maxWidth: 400,
flex: 1,
maxHeight: 580 - actionButtonOffset, // Account for button overhang
},
cardOuter: {
flex: 1,
borderRadius: radius.xxl,
overflow: "visible",
},
gradientContainer: {
flex: 1,
borderRadius: radius.xxl,
overflow: "visible",
},
cardInner: {
flex: 1,
paddingTop: spacing.md,
paddingBottom: spacing.sm,
paddingHorizontal: spacing.lg,
},
// Header row styles
headerRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: spacing.md,
},
headerSpacer: {
flex: 1,
},
packBadge: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: 'rgba(255,255,255,0.15)',
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: radius.full,
gap: spacing.xs,
maxWidth: '60%',
},
packBadgeText: {
...typography.caption1,
fontWeight: '600',
color: colors.text,
},
headerActions: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
},
headerButton: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: 'rgba(255,255,255,0.12)',
justifyContent: 'center',
alignItems: 'center',
},
// Content styles
content: {
flex: 1,
justifyContent: "flex-start",
alignItems: "center",
paddingTop: spacing.sm,
},
text: {
...typography.title2,
color: colors.text,
textAlign: "center",
lineHeight: 30,
marginBottom: spacing.lg,
},
// Glass-style input
inputContainer: {
width: '100%',
flex: 1,
minHeight: 100,
maxHeight: 180,
backgroundColor: 'rgba(255,255,255,0.12)',
borderRadius: radius.lg,
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.15)',
padding: spacing.md,
},
inputContainerFocused: {
borderColor: 'rgba(255,255,255,0.4)',
backgroundColor: 'rgba(255,255,255,0.18)',
},
textInput: {
flex: 1,
...typography.body,
color: colors.text,
textAlignVertical: 'top',
},
charCountContainer: {
alignItems: 'flex-end',
marginTop: spacing.xs,
},
charCount: {
...typography.caption2,
color: 'rgba(255,255,255,0.5)',
},
charCountMax: {
color: colors.error,
},
// Footer styles
footer: {
paddingTop: spacing.md,
transform: [{ translateY: actionButtonOffset }],
},
buttonContainer: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "flex-end",
width: '100%',
paddingHorizontal: spacing.md,
},
actionButtonWrapper: {
alignItems: 'center',
gap: spacing.xs,
},
actionButton: {
width: 72,
height: 72,
borderRadius: 36,
justifyContent: "center",
alignItems: "center",
overflow: "hidden",
elevation: 4,
shadowColor: "#000",
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 4,
},
buttonIcon: {
zIndex: 1,
},
skipButtonCenter: {
paddingVertical: spacing.sm,
paddingHorizontal: spacing.md,
marginBottom: 30, // Align above center of 72px buttons
},
skipText: {
...typography.caption1,
color: 'rgba(255,255,255,0.5)',
fontWeight: '600',
},
});
@@ -0,0 +1,478 @@
import { useState, useEffect } from "react";
import { View, Text, StyleSheet, Platform, TouchableOpacity } from "react-native";
import { Image } from "expo-image";
import { LinearGradient } from "expo-linear-gradient";
import Animated, {
useSharedValue,
useAnimatedStyle,
withSpring,
withRepeat,
withSequence,
withTiming,
interpolate,
Easing,
FadeInDown,
} from "react-native-reanimated";
import { Ionicons } from "@expo/vector-icons";
import { colors, gradients, radius, shadows, typography, spacing, animations } from "../../theme";
import { QuestionFeedbackModal } from "../feedback";
import { SharePreviewModal } from "../share";
const AVATAR_SIZE = 72;
const AVATAR_OVERHANG_PERCENT = 0.5; // 50% of avatar hangs below the card
const actionButtonOffset = AVATAR_SIZE * AVATAR_OVERHANG_PERCENT;
// Haptics helper - not supported on web
const triggerHaptic = async (style: 'light' | 'medium' | 'heavy' = 'medium') => {
if (Platform.OS === 'web') return;
const Haptics = await import('expo-haptics');
const feedbackStyle = style === 'light'
? Haptics.ImpactFeedbackStyle.Light
: style === 'heavy'
? Haptics.ImpactFeedbackStyle.Heavy
: Haptics.ImpactFeedbackStyle.Medium;
await Haptics.impactAsync(feedbackStyle);
};
export interface QuestionCardWhoLikelyProps {
question: {
id: string;
text: string;
intensity: number;
};
packInfo?: { name: string; icon: string; color?: string } | null;
user: {
id: string;
name?: string;
avatar_url?: string | null;
};
partner: {
id: string;
name?: string;
avatar_url?: string | null;
};
onAnswer: (responseData: { type: 'who_likely'; chosen_user_id: string }) => void;
onSkip?: () => void;
onReport?: () => void;
}
// Get initials from name
function getInitials(name?: string): string {
if (!name) return "?";
const parts = name.trim().split(/\s+/);
if (parts.length === 1) {
return parts[0].charAt(0).toUpperCase();
}
return (parts[0].charAt(0) + parts[parts.length - 1].charAt(0)).toUpperCase();
}
export default function QuestionCardWhoLikely({ question, packInfo, user, partner, onAnswer, onSkip, onReport }: QuestionCardWhoLikelyProps) {
const [showFeedbackModal, setShowFeedbackModal] = useState(false);
const [showShareModal, setShowShareModal] = useState(false);
const [selectedId, setSelectedId] = useState<string | null>(null);
const idleBreathing = useSharedValue(0);
const handleSharePress = async () => {
await triggerHaptic('light');
setShowShareModal(true);
};
// Subtle idle breathing animation
useEffect(() => {
idleBreathing.value = withRepeat(
withSequence(
withTiming(1, { duration: 2000, easing: Easing.inOut(Easing.sin) }),
withTiming(0, { duration: 2000, easing: Easing.inOut(Easing.sin) })
),
-1,
true
);
}, []);
const animatedStyle = useAnimatedStyle(() => {
const idleOffset = interpolate(idleBreathing.value, [0, 1], [0, -4]);
return {
transform: [{ translateY: idleOffset }],
};
});
const handleAvatarPress = async (userId: string) => {
await triggerHaptic('medium');
setSelectedId(userId);
// Brief delay to show selection before triggering answer
setTimeout(() => {
onAnswer({ type: 'who_likely', chosen_user_id: userId });
}, 200);
};
const handleSkipPress = async () => {
await triggerHaptic('light');
onSkip?.();
};
const handleFeedbackPress = () => {
if (onReport) {
onReport();
} else {
setShowFeedbackModal(true);
}
};
return (
<>
<Animated.View
entering={FadeInDown.duration(400).springify()}
style={styles.cardWrapper}
>
<Animated.View style={[styles.cardOuter, animatedStyle, shadows.lg, { shadowColor: colors.primary }]}>
<View
style={[styles.gradientContainer, { backgroundColor: packInfo?.color || colors.background }]}
>
<CardContent
question={question}
packInfo={packInfo}
user={user}
partner={partner}
selectedId={selectedId}
onAvatarPress={handleAvatarPress}
onSkipPress={handleSkipPress}
onFeedbackPress={handleFeedbackPress}
onSharePress={handleSharePress}
/>
</View>
</Animated.View>
</Animated.View>
<QuestionFeedbackModal
visible={showFeedbackModal}
onClose={() => setShowFeedbackModal(false)}
questionId={question.id}
questionText={question.text}
/>
<SharePreviewModal
visible={showShareModal}
onClose={() => setShowShareModal(false)}
question={question}
packName={packInfo?.name}
cardColor={packInfo?.color}
/>
</>
);
}
function CardContent({
question,
packInfo,
user,
partner,
selectedId,
onAvatarPress,
onSkipPress,
onFeedbackPress,
onSharePress,
}: {
question: QuestionCardWhoLikelyProps['question'];
packInfo?: { name: string; icon: string; color?: string } | null;
user: QuestionCardWhoLikelyProps['user'];
partner: QuestionCardWhoLikelyProps['partner'];
selectedId: string | null;
onAvatarPress: (userId: string) => void;
onSkipPress: () => void;
onFeedbackPress: () => void;
onSharePress: () => void;
}) {
return (
<View style={styles.cardInner}>
{/* Header Row */}
<View style={styles.headerRow}>
{packInfo && (
<View style={styles.packBadge}>
<Ionicons name={packInfo.icon as any} size={14} color={colors.text} />
<Text style={styles.packBadgeText} numberOfLines={1}>
{packInfo.name}
</Text>
</View>
)}
{!packInfo && <View style={styles.headerSpacer} />}
<View style={styles.headerActions}>
<TouchableOpacity
style={styles.headerButton}
onPress={onSharePress}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
activeOpacity={0.7}
>
<Ionicons name="share-outline" size={20} color="rgba(255,255,255,0.8)" />
</TouchableOpacity>
<TouchableOpacity
style={styles.headerButton}
onPress={onFeedbackPress}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
activeOpacity={0.7}
>
<Ionicons name="flag-outline" size={20} color="rgba(255,255,255,0.6)" />
</TouchableOpacity>
</View>
</View>
{/* Content */}
<View style={styles.content}>
<Text style={styles.text}>
{question.text}
</Text>
</View>
{/* Footer - Avatar Buttons */}
<View style={styles.footer}>
<View style={styles.avatarContainer}>
<AvatarButton
userId={user.id}
name={user.name}
avatarUrl={user.avatar_url}
isSelected={selectedId === user.id}
onPress={onAvatarPress}
/>
<TouchableOpacity
style={styles.skipButtonCenter}
onPress={onSkipPress}
activeOpacity={0.7}
>
<Text style={styles.skipText}>Skip</Text>
</TouchableOpacity>
<AvatarButton
userId={partner.id}
name={partner.name}
avatarUrl={partner.avatar_url}
isSelected={selectedId === partner.id}
onPress={onAvatarPress}
/>
</View>
</View>
</View>
);
}
function AvatarButton({
userId,
name,
avatarUrl,
isSelected,
onPress,
}: {
userId: string;
name?: string;
avatarUrl?: string | null;
isSelected: boolean;
onPress: (userId: string) => void;
}) {
const buttonScale = useSharedValue(1);
const buttonGlow = useSharedValue(0);
// Animate selection
useEffect(() => {
if (isSelected) {
buttonScale.value = withSequence(
withSpring(1.1, animations.springBouncy),
withSpring(1.05, animations.spring)
);
buttonGlow.value = withTiming(1, { duration: 200 });
} else {
buttonScale.value = withSpring(1, animations.spring);
buttonGlow.value = withTiming(0, { duration: 200 });
}
}, [isSelected]);
const handlePressIn = () => {
if (!isSelected) {
buttonScale.value = withSpring(0.95, animations.spring);
buttonGlow.value = withTiming(0.5, { duration: 100 });
}
};
const handlePressOut = () => {
if (!isSelected) {
buttonScale.value = withSpring(1, animations.spring);
buttonGlow.value = withTiming(0, { duration: 150 });
}
};
const buttonAnimatedStyle = useAnimatedStyle(() => ({
transform: [{ scale: buttonScale.value }],
shadowOpacity: interpolate(buttonGlow.value, [0, 1], [0.2, 0.6]),
shadowRadius: interpolate(buttonGlow.value, [0, 1], [8, 20]),
}));
const borderAnimatedStyle = useAnimatedStyle(() => ({
borderWidth: interpolate(buttonGlow.value, [0, 1], [3, 4]),
borderColor: colors.text, // Solid white border
}));
return (
<TouchableOpacity
onPress={() => onPress(userId)}
onPressIn={handlePressIn}
onPressOut={handlePressOut}
activeOpacity={1}
style={styles.avatarButtonWrapper}
>
<Animated.View style={[
styles.avatarButton,
buttonAnimatedStyle,
{ shadowColor: colors.text }
]}>
<Animated.View style={[styles.avatarBorder, borderAnimatedStyle]}>
{avatarUrl ? (
<Image
source={{ uri: avatarUrl }}
style={styles.avatarImage}
contentFit="cover"
cachePolicy="memory-disk"
transition={200}
/>
) : (
<View style={styles.avatarFallback}>
<Text style={styles.avatarInitials}>
{getInitials(name)}
</Text>
</View>
)}
</Animated.View>
</Animated.View>
</TouchableOpacity>
);
}
const styles = StyleSheet.create({
cardWrapper: {
width: "90%",
maxWidth: 400,
flex: 1,
maxHeight: 580 - actionButtonOffset, // Account for avatar overhang
},
cardOuter: {
flex: 1,
borderRadius: radius.xxl,
overflow: "visible",
},
gradientContainer: {
flex: 1,
borderRadius: radius.xxl,
overflow: "visible",
},
cardInner: {
flex: 1,
paddingTop: spacing.md,
paddingBottom: spacing.sm,
paddingHorizontal: spacing.lg,
},
// Header row styles
headerRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: spacing.md,
},
headerSpacer: {
flex: 1,
},
packBadge: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: 'rgba(255,255,255,0.15)',
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: radius.full,
gap: spacing.xs,
maxWidth: '60%',
},
packBadgeText: {
...typography.caption1,
fontWeight: '600',
color: colors.text,
},
headerActions: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
},
headerButton: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: 'rgba(255,255,255,0.12)',
justifyContent: 'center',
alignItems: 'center',
},
// Content styles
content: {
flex: 1,
justifyContent: "center",
alignItems: "center",
paddingHorizontal: spacing.sm,
},
text: {
...typography.title1,
color: colors.text,
textAlign: "center",
lineHeight: 36,
},
// Footer styles
footer: {
paddingTop: spacing.md,
transform: [{ translateY: actionButtonOffset }],
},
avatarContainer: {
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'flex-end',
gap: spacing.lg,
},
avatarButtonWrapper: {
alignItems: 'center',
},
avatarButton: {
width: AVATAR_SIZE,
height: AVATAR_SIZE,
borderRadius: AVATAR_SIZE / 2,
justifyContent: 'center',
alignItems: 'center',
shadowOffset: { width: 0, height: 4 },
},
avatarBorder: {
width: AVATAR_SIZE,
height: AVATAR_SIZE,
borderRadius: AVATAR_SIZE / 2,
borderWidth: 3,
borderColor: colors.text,
},
avatarImage: {
width: AVATAR_SIZE - 6, // Account for border width (3px each side)
height: AVATAR_SIZE - 6,
borderRadius: (AVATAR_SIZE - 6) / 2,
},
avatarFallback: {
width: AVATAR_SIZE - 6,
height: AVATAR_SIZE - 6,
borderRadius: (AVATAR_SIZE - 6) / 2,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: colors.primary,
},
avatarInitials: {
...typography.title1,
color: colors.text,
fontWeight: 'bold',
},
skipButtonCenter: {
paddingVertical: spacing.sm,
paddingHorizontal: spacing.md,
marginBottom: 30, // Align above center of 72px avatars
},
skipText: {
...typography.caption1,
color: 'rgba(255,255,255,0.5)',
fontWeight: '600',
},
});
@@ -0,0 +1,10 @@
export { default as QuestionCard } from './QuestionCard';
export type { QuestionCardProps } from './QuestionCard';
export { default as QuestionCardText } from './QuestionCardText';
export type { QuestionCardTextProps } from './QuestionCardText';
export { default as QuestionCardWhoLikely } from './QuestionCardWhoLikely';
export type { QuestionCardWhoLikelyProps } from './QuestionCardWhoLikely';
export { default as QuestionCardPhoto } from './QuestionCardPhoto';
export type { QuestionCardPhotoProps } from './QuestionCardPhoto';
export { default as QuestionCardAudio } from './QuestionCardAudio';
export type { QuestionCardAudioProps } from './QuestionCardAudio';
@@ -1,29 +1,33 @@
import { useState, useEffect } from "react";
import {
View,
Text,
StyleSheet,
Modal,
TouchableOpacity,
TouchableWithoutFeedback,
ActivityIndicator,
import { useState, useEffect, type ReactNode } from "react";
import {
View,
Text,
TextInput,
StyleSheet,
Modal,
TouchableOpacity,
TouchableWithoutFeedback,
ActivityIndicator,
Platform,
} from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { LinearGradient } from "expo-linear-gradient";
import { BlurView } from "expo-blur";
import Animated, { FadeIn, FadeOut, SlideInDown, SlideOutDown } from "react-native-reanimated";
import type { ResponseWithQuestion, UpdateResponseResult } from "../../store";
import { useResponsesStore } from "../../store";
import type { AnswerType } from "@/types";
import { colors, spacing, typography, radius } from "../../theme";
import { DeleteMatchConfirmModal } from "./DeleteMatchConfirmModal";
import Animated, { FadeIn, FadeOut, SlideInDown, SlideOutDown } from "react-native-reanimated";
import type { ResponseWithQuestion } from "../../store";
import { useAuthStore, useResponsesStore } from "../../store";
import { useMediaPicker } from "../../hooks/useMediaPicker";
import { useResponseMediaUpload } from "../../hooks/useResponseMediaUpload";
import { useAudioRecorder } from "../../hooks/useAudioRecorder";
import type { AnswerType, ResponseData } from "@/types";
import { colors, spacing, typography, radius } from "../../theme";
import { DeleteMatchConfirmModal } from "./DeleteMatchConfirmModal";
// Premium color palette
const ACCENT = colors.premium.gold;
const ACCENT_RGBA = "rgba(212, 175, 55, ";
const ACCENT = colors.premium.gold;
const ACCENT_RGBA = "rgba(212, 175, 55, ";
const MAX_TEXT_LENGTH = 500;
// Answer button config
// Answer button config (includes 'maybe' for displaying existing responses)
const ANSWER_CONFIG = {
yes: {
color: colors.success,
@@ -31,20 +35,23 @@ const ANSWER_CONFIG = {
icon: "checkmark-circle" as const,
label: "Yes",
},
maybe: {
color: colors.warning,
rgba: "rgba(243, 156, 18, ",
icon: "help-circle" as const,
label: "Maybe",
},
no: {
color: colors.error,
rgba: "rgba(231, 76, 60, ",
icon: "close-circle" as const,
label: "No",
},
maybe: {
color: colors.warning,
rgba: "rgba(243, 156, 18, ",
icon: "help-circle" as const,
label: "Maybe",
},
};
// Only these answer types can be selected by users
type SelectableAnswerType = "yes" | "no";
interface EditResponseSheetProps {
visible: boolean;
response: ResponseWithQuestion | null;
@@ -52,33 +59,73 @@ interface EditResponseSheetProps {
onSuccess: () => void;
}
export function EditResponseSheet({ visible, response, onClose, onSuccess }: EditResponseSheetProps) {
const { updateResponse } = useResponsesStore();
const [isLoading, setIsLoading] = useState(false);
const [pendingAnswer, setPendingAnswer] = useState<AnswerType | null>(null);
const [confirmModalData, setConfirmModalData] = useState<{
matchId: string;
messageCount: number;
} | null>(null);
export function EditResponseSheet({ visible, response, onClose, onSuccess }: EditResponseSheetProps) {
const { updateResponse } = useResponsesStore();
const { user, partner } = useAuthStore();
const [isLoading, setIsLoading] = useState(false);
const [pendingAnswer, setPendingAnswer] = useState<AnswerType | null>(null);
const [confirmModalData, setConfirmModalData] = useState<{
matchId: string;
messageCount: number;
} | null>(null);
const [textValue, setTextValue] = useState("");
const [selectedWhoLikely, setSelectedWhoLikely] = useState<string | null>(null);
const questionId = response?.question_id ?? "";
const maxDurationSeconds = response?.question.config?.max_duration_seconds ?? 60;
const { takePhoto, pickMedia } = useMediaPicker({ imageQuality: 0.8 });
const { uploading, uploadPhoto, uploadAudio } = useResponseMediaUpload({
userId: user?.id ?? "",
questionId,
});
const {
state: audioState,
durationSeconds,
recordingUri,
startRecording,
stopRecording,
resetRecording,
} = useAudioRecorder({ maxDurationSeconds });
const isBusy = isLoading || uploading;
// Reset state when modal closes
useEffect(() => {
if (!visible) {
setIsLoading(false);
setPendingAnswer(null);
setConfirmModalData(null);
}
}, [visible]);
useEffect(() => {
if (!visible) {
setIsLoading(false);
setPendingAnswer(null);
setConfirmModalData(null);
setTextValue("");
setSelectedWhoLikely(null);
resetRecording();
}
}, [visible, resetRecording]);
useEffect(() => {
if (!visible || !response) return;
if (response.response_data?.type === "text_answer") {
setTextValue(response.response_data.text);
} else {
setTextValue("");
}
if (response.response_data?.type === "who_likely") {
setSelectedWhoLikely(response.response_data.chosen_user_id);
} else {
setSelectedWhoLikely(null);
}
}, [visible, response]);
if (!response) return null;
if (!response) return null;
const handleAnswerPress = async (newAnswer: AnswerType) => {
if (newAnswer === response.answer || isLoading) return;
setIsLoading(true);
setPendingAnswer(newAnswer);
const result = await updateResponse(response.question_id, newAnswer, false);
const handleAnswerPress = async (newAnswer: AnswerType, responseData?: ResponseData | null) => {
if (isBusy) return;
if (newAnswer === response.answer && typeof responseData === "undefined") return;
setIsLoading(true);
setPendingAnswer(newAnswer);
const result = await updateResponse(response.question_id, newAnswer, false, responseData);
if (result.requires_confirmation && result.match_id) {
// Show confirmation modal
@@ -120,32 +167,101 @@ export function EditResponseSheet({ visible, response, onClose, onSuccess }: Edi
}
};
const handleCancelDelete = () => {
setConfirmModalData(null);
setPendingAnswer(null);
};
const AnswerButton = ({ answer }: { answer: AnswerType }) => {
const config = ANSWER_CONFIG[answer];
const isSelected = response.answer === answer;
const isPending = pendingAnswer === answer;
return (
<TouchableOpacity
style={[
styles.answerButton,
isSelected && {
backgroundColor: `${config.rgba}0.2)`,
borderColor: `${config.rgba}0.4)`,
},
]}
onPress={() => handleAnswerPress(answer)}
disabled={isLoading}
activeOpacity={0.7}
>
{isPending && isLoading ? (
<ActivityIndicator color={config.color} size="small" />
) : (
const handleCancelDelete = () => {
setConfirmModalData(null);
setPendingAnswer(null);
};
const questionType = response.question.question_type ?? "swipe";
const isTextSaveDisabled = textValue.trim().length === 0 || isBusy;
const isNoSelected = response.answer === "no";
const userLabel = user?.name || "You";
const partnerLabel = partner?.name || "Partner";
const canUpload = !!user?.id && !!questionId;
const handleTextSave = async () => {
const trimmed = textValue.trim();
if (!trimmed) return;
await handleAnswerPress("yes", { type: "text_answer", text: trimmed });
};
const handleSetNo = async () => {
await handleAnswerPress("no", null);
};
const handleWhoLikelySelect = async (userId: string) => {
if (isBusy) return;
setSelectedWhoLikely(userId);
await handleAnswerPress("yes", { type: "who_likely", chosen_user_id: userId });
};
const handlePhotoPick = async (source: "camera" | "library") => {
if (isBusy || !canUpload) return;
const result = source === "camera" ? await takePhoto() : await pickMedia();
if (!result || result.mediaType !== "image") return;
const uploadResult = await uploadPhoto(result.uri);
if (!uploadResult.success || !uploadResult.mediaPath) {
console.error("Failed to upload photo response:", uploadResult.error);
return;
}
await handleAnswerPress("yes", { type: "photo", media_path: uploadResult.mediaPath });
};
const handleStartRecording = async () => {
if (isBusy) return;
await startRecording();
};
const handleStopRecording = async () => {
if (isBusy) return;
await stopRecording();
};
const handleAudioSave = async () => {
if (!recordingUri || isBusy || !canUpload) return;
const uploadResult = await uploadAudio(recordingUri, durationSeconds);
if (!uploadResult.success || !uploadResult.mediaPath) {
console.error("Failed to upload audio response:", uploadResult.error);
return;
}
await handleAnswerPress("yes", {
type: "audio",
media_path: uploadResult.mediaPath,
duration_seconds: durationSeconds,
});
resetRecording();
};
const handleDiscardRecording = () => {
if (isBusy) return;
resetRecording();
};
const AnswerButton = ({ answer }: { answer: SelectableAnswerType }) => {
const config = ANSWER_CONFIG[answer];
const isSelected = response.answer === answer;
const isPending = pendingAnswer === answer;
return (
<TouchableOpacity
style={[
styles.answerButton,
isSelected && {
backgroundColor: `${config.rgba}0.2)`,
borderColor: `${config.rgba}0.4)`,
},
]}
onPress={() => handleAnswerPress(answer)}
disabled={isBusy}
activeOpacity={0.7}
>
{isPending && isLoading ? (
<ActivityIndicator color={config.color} size="small" />
) : (
<Ionicons
name={config.icon}
size={28}
@@ -165,15 +281,252 @@ export function EditResponseSheet({ visible, response, onClose, onSuccess }: Edi
<Ionicons name="checkmark" size={14} color={config.color} />
</View>
)}
</TouchableOpacity>
);
};
return (
<Modal
visible={visible}
transparent
animationType="none"
</TouchableOpacity>
);
};
const ActionButton = ({
label,
icon,
color = ACCENT,
onPress,
disabled = false,
loading = false,
}: {
label: string;
icon: keyof typeof Ionicons.glyphMap;
color?: string;
onPress: () => void;
disabled?: boolean;
loading?: boolean;
}) => {
const isDisabled = disabled || loading;
const iconColor = isDisabled ? colors.textTertiary : color;
return (
<TouchableOpacity
style={[styles.actionButton, isDisabled && styles.actionButtonDisabled]}
onPress={onPress}
disabled={isDisabled}
activeOpacity={0.7}
>
{loading ? (
<ActivityIndicator color={iconColor} size="small" />
) : (
<Ionicons name={icon} size={20} color={iconColor} />
)}
<Text style={[styles.actionButtonText, { color: iconColor }]}>
{label}
</Text>
</TouchableOpacity>
);
};
const ChoiceButton = ({
label,
selected,
onPress,
disabled = false,
}: {
label: string;
selected: boolean;
onPress: () => void;
disabled?: boolean;
}) => (
<TouchableOpacity
style={[styles.choiceButton, selected && styles.choiceButtonSelected]}
onPress={onPress}
disabled={disabled}
activeOpacity={0.7}
>
<Text style={[styles.choiceButtonText, selected && styles.choiceButtonTextSelected]}>
{label}
</Text>
{selected && <Ionicons name="checkmark" size={16} color={ACCENT} />}
</TouchableOpacity>
);
const isSavingYes = isLoading && pendingAnswer === "yes";
const isSavingNo = isLoading && pendingAnswer === "no";
const actionContent = (() => {
if (questionType === "text_answer") {
return (
<View style={styles.editorContainer}>
<View style={styles.textInputContainer}>
<TextInput
style={styles.textInput}
value={textValue}
onChangeText={(value) => setTextValue(value.slice(0, MAX_TEXT_LENGTH))}
placeholder="Update your answer..."
placeholderTextColor={colors.textTertiary}
multiline
editable={!isBusy}
textAlignVertical="top"
maxLength={MAX_TEXT_LENGTH}
/>
<View style={styles.charCountContainer}>
<Text style={styles.charCount}>
{textValue.length}/{MAX_TEXT_LENGTH}
</Text>
</View>
</View>
<View style={styles.actionRow}>
<ActionButton
label="Set No"
icon="close"
color={ANSWER_CONFIG.no.color}
onPress={handleSetNo}
disabled={isBusy || isNoSelected}
loading={isSavingNo}
/>
<ActionButton
label="Save"
icon="checkmark"
color={ANSWER_CONFIG.yes.color}
onPress={handleTextSave}
disabled={isTextSaveDisabled}
loading={isSavingYes}
/>
</View>
</View>
);
}
if (questionType === "who_likely") {
return (
<View style={styles.editorContainer}>
<View style={styles.choiceRow}>
<ChoiceButton
label={userLabel}
selected={selectedWhoLikely === user?.id}
onPress={() => {
if (user?.id) {
handleWhoLikelySelect(user.id);
}
}}
disabled={isBusy || !user?.id}
/>
<ChoiceButton
label={partnerLabel}
selected={selectedWhoLikely === partner?.id}
onPress={() => {
if (partner?.id) {
handleWhoLikelySelect(partner.id);
}
}}
disabled={isBusy || !partner?.id}
/>
</View>
<View style={styles.actionRow}>
<ActionButton
label="Set No"
icon="close"
color={ANSWER_CONFIG.no.color}
onPress={handleSetNo}
disabled={isBusy || isNoSelected}
loading={isSavingNo}
/>
</View>
</View>
);
}
if (questionType === "photo") {
return (
<View style={styles.editorContainer}>
<View style={styles.actionRow}>
<ActionButton
label="Camera"
icon="camera"
onPress={() => handlePhotoPick("camera")}
disabled={isBusy || !canUpload}
loading={uploading}
/>
<ActionButton
label="Library"
icon="images"
onPress={() => handlePhotoPick("library")}
disabled={isBusy || !canUpload}
loading={uploading}
/>
</View>
<View style={styles.actionRow}>
<ActionButton
label="Set No"
icon="close"
color={ANSWER_CONFIG.no.color}
onPress={handleSetNo}
disabled={isBusy || isNoSelected}
loading={isSavingNo}
/>
</View>
</View>
);
}
if (questionType === "audio") {
const statusText = audioState === "recording"
? `Recording... ${durationSeconds}s`
: recordingUri
? `Recorded ${durationSeconds}s`
: "Tap record to start";
return (
<View style={styles.editorContainer}>
<Text style={styles.audioStatusText}>{statusText}</Text>
<View style={styles.actionRow}>
<ActionButton
label={audioState === "recording" ? "Stop" : recordingUri ? "Re-record" : "Record"}
icon={audioState === "recording" ? "square" : "mic"}
onPress={audioState === "recording" ? handleStopRecording : handleStartRecording}
disabled={isBusy}
/>
<ActionButton
label="Save"
icon="checkmark"
color={ANSWER_CONFIG.yes.color}
onPress={handleAudioSave}
disabled={!recordingUri || audioState === "recording" || isBusy || !canUpload}
loading={uploading || isSavingYes}
/>
</View>
{recordingUri && audioState !== "recording" && (
<TouchableOpacity
style={styles.linkButton}
onPress={handleDiscardRecording}
disabled={isBusy}
>
<Text style={styles.linkButtonText}>Discard recording</Text>
</TouchableOpacity>
)}
<View style={styles.actionRow}>
<ActionButton
label="Set No"
icon="close"
color={ANSWER_CONFIG.no.color}
onPress={handleSetNo}
disabled={isBusy || isNoSelected}
loading={isSavingNo}
/>
</View>
</View>
);
}
return (
<View style={styles.answerButtons}>
<AnswerButton answer="yes" />
<AnswerButton answer="no" />
</View>
);
})();
return (
<Modal
visible={visible}
transparent
animationType="none"
onRequestClose={onClose}
statusBarTranslucent
>
@@ -187,24 +540,24 @@ export function EditResponseSheet({ visible, response, onClose, onSuccess }: Edi
entering={SlideInDown.springify().damping(20)}
exiting={SlideOutDown.duration(200)}
>
{Platform.OS === "ios" ? (
<BlurView intensity={80} tint="dark" style={styles.sheet}>
<SheetContent
response={response}
AnswerButton={AnswerButton}
onClose={onClose}
/>
</BlurView>
) : (
<View style={[styles.sheet, styles.sheetAndroid]}>
<SheetContent
response={response}
AnswerButton={AnswerButton}
onClose={onClose}
/>
</View>
)}
</Animated.View>
{Platform.OS === "ios" ? (
<BlurView intensity={80} tint="dark" style={styles.sheet}>
<SheetContent
response={response}
actions={actionContent}
onClose={onClose}
/>
</BlurView>
) : (
<View style={[styles.sheet, styles.sheetAndroid]}>
<SheetContent
response={response}
actions={actionContent}
onClose={onClose}
/>
</View>
)}
</Animated.View>
</Animated.View>
{/* Confirmation Modal */}
@@ -219,15 +572,15 @@ export function EditResponseSheet({ visible, response, onClose, onSuccess }: Edi
}
// Sheet content extracted for reuse between iOS blur and Android solid background
function SheetContent({
response,
AnswerButton,
onClose,
}: {
response: ResponseWithQuestion;
AnswerButton: React.FC<{ answer: AnswerType }>;
onClose: () => void;
}) {
function SheetContent({
response,
actions,
onClose,
}: {
response: ResponseWithQuestion;
actions: ReactNode;
onClose: () => void;
}) {
return (
<>
{/* Handle */}
@@ -261,16 +614,12 @@ function SheetContent({
</View>
)}
{/* Answer buttons */}
<View style={styles.answerButtons}>
<AnswerButton answer="yes" />
<AnswerButton answer="maybe" />
<AnswerButton answer="no" />
</View>
{/* Bottom padding for safe area */}
<View style={styles.bottomPadding} />
</>
{/* Action content */}
<View style={styles.actionsContainer}>{actions}</View>
{/* Bottom padding for safe area */}
<View style={styles.bottomPadding} />
</>
);
}
@@ -354,19 +703,45 @@ const styles = StyleSheet.create({
marginBottom: spacing.lg,
gap: spacing.sm,
},
warningText: {
...typography.footnote,
color: colors.warning,
flex: 1,
},
answerButtons: {
flexDirection: "row",
gap: spacing.md,
marginBottom: spacing.lg,
},
answerButton: {
flex: 1,
alignItems: "center",
warningText: {
...typography.footnote,
color: colors.warning,
flex: 1,
},
actionsContainer: {
marginBottom: spacing.lg,
},
editorContainer: {
gap: spacing.md,
},
textInputContainer: {
backgroundColor: "rgba(255, 255, 255, 0.08)",
borderRadius: radius.lg,
borderWidth: 1,
borderColor: "rgba(255, 255, 255, 0.12)",
padding: spacing.md,
minHeight: 120,
},
textInput: {
...typography.body,
color: colors.text,
minHeight: 80,
},
charCountContainer: {
alignItems: "flex-end",
marginTop: spacing.xs,
},
charCount: {
...typography.caption2,
color: colors.textTertiary,
},
answerButtons: {
flexDirection: "row",
gap: spacing.md,
},
answerButton: {
flex: 1,
alignItems: "center",
justifyContent: "center",
paddingVertical: spacing.lg,
borderRadius: radius.lg,
@@ -380,18 +755,82 @@ const styles = StyleSheet.create({
color: colors.textSecondary,
fontWeight: "600",
},
selectedIndicator: {
position: "absolute",
top: spacing.sm,
right: spacing.sm,
width: 20,
selectedIndicator: {
position: "absolute",
top: spacing.sm,
right: spacing.sm,
width: 20,
height: 20,
borderRadius: 10,
backgroundColor: "rgba(255, 255, 255, 0.1)",
justifyContent: "center",
alignItems: "center",
},
bottomPadding: {
height: Platform.OS === "ios" ? 34 : 20,
},
});
justifyContent: "center",
alignItems: "center",
},
actionRow: {
flexDirection: "row",
gap: spacing.md,
alignItems: "center",
},
actionButton: {
flex: 1,
alignItems: "center",
justifyContent: "center",
paddingVertical: spacing.md,
borderRadius: radius.md,
backgroundColor: "rgba(255, 255, 255, 0.05)",
borderWidth: 1,
borderColor: "rgba(255, 255, 255, 0.1)",
gap: spacing.xs,
},
actionButtonDisabled: {
opacity: 0.6,
},
actionButtonText: {
...typography.caption1,
fontWeight: "600",
},
choiceRow: {
flexDirection: "row",
gap: spacing.md,
},
choiceButton: {
flex: 1,
alignItems: "center",
justifyContent: "center",
paddingVertical: spacing.md,
borderRadius: radius.md,
backgroundColor: "rgba(255, 255, 255, 0.06)",
borderWidth: 1,
borderColor: "rgba(255, 255, 255, 0.12)",
flexDirection: "row",
gap: spacing.xs,
},
choiceButtonSelected: {
borderColor: `${ACCENT_RGBA}0.6)`,
backgroundColor: `${ACCENT_RGBA}0.12)`,
},
choiceButtonText: {
...typography.callout,
color: colors.textSecondary,
fontWeight: "600",
},
choiceButtonTextSelected: {
color: ACCENT,
},
audioStatusText: {
...typography.caption1,
color: colors.textSecondary,
textAlign: "center",
},
linkButton: {
alignSelf: "center",
},
linkButtonText: {
...typography.caption1,
color: colors.textTertiary,
textDecorationLine: "underline",
},
bottomPadding: {
height: Platform.OS === "ios" ? 34 : 20,
},
});
@@ -6,17 +6,15 @@ import type { ResponseWithQuestion } from "../../store";
import { colors, spacing, typography, radius } from "../../theme";
// 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 ACCENT = colors.premium.gold;
const ACCENT_RGBA = "rgba(212, 175, 55, ";
// Answer colors (semantic)
const ANSWER_COLORS = {
yes: {
color: colors.success,
rgba: "rgba(46, 204, 113, ",
label: "YES",
const ANSWER_COLORS = {
yes: {
color: colors.success,
rgba: "rgba(46, 204, 113, ",
label: "YES",
},
no: {
color: colors.error,
@@ -27,18 +25,100 @@ const ANSWER_COLORS = {
color: colors.warning,
rgba: "rgba(243, 156, 18, ",
label: "MAYBE",
},
};
interface ResponseCardProps {
response: ResponseWithQuestion;
index: number;
onEditPress: () => void;
onChatPress?: () => void;
}
export function ResponseCard({ response, index, onEditPress, onChatPress }: ResponseCardProps) {
const answerStyle = ANSWER_COLORS[response.answer];
},
};
const RESPONSE_TYPE_CONFIG = {
text_answer: {
icon: "chatbubble-ellipses-outline" as const,
label: "TEXT",
},
photo: {
icon: "image-outline" as const,
label: "PHOTO",
},
audio: {
icon: "mic-outline" as const,
label: "AUDIO",
},
who_likely: {
icon: "people-outline" as const,
label: "WHO",
},
};
interface ResponseCardProps {
response: ResponseWithQuestion;
index: number;
onEditPress: () => void;
onChatPress?: () => void;
viewerId?: string;
viewerName?: string | null;
partnerName?: string | null;
}
function truncateText(value: string, maxLength: number) {
if (value.length <= maxLength) return value;
return `${value.slice(0, maxLength - 3)}...`;
}
function getResponseSummary(
response: ResponseWithQuestion,
viewerId?: string,
viewerName?: string | null,
partnerName?: string | null
) {
const data = response.response_data;
if (!data) return null;
if (data.type === "text_answer") {
return `"${truncateText(data.text, 90)}"`;
}
if (data.type === "photo") {
return "Photo response saved";
}
if (data.type === "audio") {
const duration = data.duration_seconds ? `${data.duration_seconds}s` : "";
return duration ? `Audio response (${duration})` : "Audio response saved";
}
if (data.type === "who_likely") {
const chosenName = data.chosen_user_id === viewerId
? viewerName || "you"
: partnerName || "your partner";
return `You chose ${chosenName}`;
}
return null;
}
export function ResponseCard({
response,
index,
onEditPress,
onChatPress,
viewerId,
viewerName,
partnerName,
}: ResponseCardProps) {
const answerStyle = ANSWER_COLORS[response.answer];
const questionType = response.question.question_type ?? "swipe";
const responseSummary = getResponseSummary(response, viewerId, viewerName, partnerName);
const typeKey = response.response_data?.type || (questionType !== "swipe" ? questionType : null);
const typeConfig = typeKey ? RESPONSE_TYPE_CONFIG[typeKey as keyof typeof RESPONSE_TYPE_CONFIG] : null;
const showTypeBadge = questionType !== "swipe" && response.answer !== "no" && !!typeConfig;
const badgeColor = showTypeBadge ? ACCENT : answerStyle.color;
const badgeRgba = showTypeBadge ? ACCENT_RGBA : answerStyle.rgba;
const badgeIcon = showTypeBadge && typeConfig
? typeConfig.icon
: response.answer === "yes"
? "checkmark"
: response.answer === "no"
? "close"
: "help";
const tagLabel = showTypeBadge && typeConfig ? typeConfig.label : answerStyle.label;
return (
<Animated.View entering={FadeInRight.delay(index * 30).duration(250)}>
@@ -50,56 +130,55 @@ export function ResponseCard({ response, index, onEditPress, onChatPress }: Resp
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
/>
{/* Top silk highlight */}
<LinearGradient
colors={[`${answerStyle.rgba}0.06)`, "transparent"]}
style={styles.cardSilkHighlight}
start={{ x: 0.5, y: 0 }}
end={{ x: 0.5, y: 1 }}
/>
{/* Top silk highlight */}
<LinearGradient
colors={[`${badgeRgba}0.06)`, "transparent"]}
style={styles.cardSilkHighlight}
start={{ x: 0.5, y: 0 }}
end={{ x: 0.5, y: 1 }}
/>
<View style={styles.row}>
{/* Answer badge */}
<View
style={[
styles.answerBadge,
{ backgroundColor: `${answerStyle.rgba}0.15)`, borderColor: `${answerStyle.rgba}0.3)` },
]}
>
<Ionicons
name={
response.answer === "yes"
? "checkmark"
: response.answer === "no"
? "close"
: "help"
}
size={18}
color={answerStyle.color}
/>
</View>
<View
style={[
styles.answerBadge,
{ backgroundColor: `${badgeRgba}0.15)`, borderColor: `${badgeRgba}0.3)` },
]}
>
<Ionicons
name={badgeIcon}
size={18}
color={badgeColor}
/>
</View>
{/* Content */}
<View style={styles.content}>
<Text style={styles.questionText} numberOfLines={2}>
{response.question.text}
</Text>
<View style={styles.metaRow}>
{/* Answer tag */}
<View
style={[
styles.tag,
{
backgroundColor: `${answerStyle.rgba}0.1)`,
borderColor: `${answerStyle.rgba}0.2)`,
},
]}
>
<Text style={[styles.tagText, { color: answerStyle.color }]}>
{answerStyle.label}
</Text>
</View>
<View style={styles.content}>
<Text style={styles.questionText} numberOfLines={2}>
{response.question.text}
</Text>
{responseSummary && (
<Text style={styles.responseSummary} numberOfLines={2}>
{responseSummary}
</Text>
)}
<View style={styles.metaRow}>
{/* Answer tag */}
<View
style={[
styles.tag,
{
backgroundColor: `${badgeRgba}0.1)`,
borderColor: `${badgeRgba}0.2)`,
},
]}
>
<Text style={[styles.tagText, { color: badgeColor }]}>
{tagLabel}
</Text>
</View>
{/* Pack name */}
<View style={styles.packBadge}>
@@ -175,13 +254,18 @@ const styles = StyleSheet.create({
content: {
flex: 1,
},
questionText: {
...typography.subhead,
color: colors.text,
fontWeight: "500",
marginBottom: spacing.xs,
lineHeight: 20,
},
questionText: {
...typography.subhead,
color: colors.text,
fontWeight: "500",
marginBottom: spacing.xs,
lineHeight: 20,
},
responseSummary: {
...typography.caption1,
color: colors.textSecondary,
marginBottom: spacing.xs,
},
metaRow: {
flexDirection: "row",
alignItems: "center",
@@ -0,0 +1,534 @@
import { useRef, useEffect, useState } from "react";
import {
View,
Text,
StyleSheet,
Modal,
TouchableOpacity,
Platform,
Linking,
Share,
useWindowDimensions,
Image,
} from "react-native";
import { BlurView } from "expo-blur";
import { LinearGradient } from "expo-linear-gradient";
import Animated, {
FadeIn,
FadeOut,
SlideInDown,
SlideOutDown,
} from "react-native-reanimated";
import { Ionicons } from "@expo/vector-icons";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import ViewShot from "react-native-view-shot";
import * as Sharing from "expo-sharing";
import * as FileSystem from "expo-file-system";
import { colors, gradients, radius, typography, spacing, blur } from "../../theme";
const logo = require("../../../assets/logo.png");
const MAX_CARD_WIDTH = 340;
interface SharePreviewModalProps {
visible: boolean;
onClose: () => void;
question: {
text: string;
partner_text?: string | null;
is_two_part?: boolean;
};
packName?: string;
cardColor?: string;
}
export function SharePreviewModal({
visible,
onClose,
question,
packName,
cardColor,
}: SharePreviewModalProps) {
const insets = useSafeAreaInsets();
const { width: screenWidth } = useWindowDimensions();
const cardWidth = Math.min(screenWidth - 64, MAX_CARD_WIDTH);
const shareViewRef = useRef<ViewShot>(null);
const [capturedUri, setCapturedUri] = useState<string | null>(null);
const [isCapturing, setIsCapturing] = useState(false);
// Capture the card when modal becomes visible
useEffect(() => {
if (visible && shareViewRef.current) {
setIsCapturing(true);
// Small delay to ensure the view is rendered
const timer = setTimeout(async () => {
try {
const uri = await shareViewRef.current?.capture?.();
if (uri) {
setCapturedUri(uri);
}
} catch (error) {
console.error('Error capturing share image:', error);
} finally {
setIsCapturing(false);
}
}, 100);
return () => clearTimeout(timer);
}
}, [visible]);
// Reset state when modal closes
useEffect(() => {
if (!visible) {
setCapturedUri(null);
}
}, [visible]);
const handleInstagramShare = async () => {
if (!capturedUri) return;
try {
// Check if Instagram is installed
const instagramUrl = 'instagram://app';
const canOpenInstagram = await Linking.canOpenURL(instagramUrl);
if (canOpenInstagram && Platform.OS === 'ios') {
// For iOS, we can use the Instagram Stories URL scheme
// First, we need to copy the image to a location Instagram can access
const base64 = await FileSystem.readAsStringAsync(capturedUri, {
encoding: FileSystem.EncodingType.Base64,
});
// Use Instagram Stories URL scheme
const instagramStoriesUrl = `instagram-stories://share?source_application=com.sauci.app`;
// Try to open Instagram Stories with the image
// Note: This requires the image to be in the pasteboard on iOS
await Linking.openURL(instagramStoriesUrl);
} else {
// Fallback to regular sharing
await handleMoreShare();
}
} catch (error) {
console.error('Instagram share error:', error);
// Fallback to regular sharing
await handleMoreShare();
}
};
const handleMessagesShare = async () => {
if (!capturedUri) {
// Fallback to text
await Share.share({
message: `${question.text}\n\nDiscover more on sauci.app`,
});
return;
}
try {
// On iOS, we can share directly to Messages
if (Platform.OS === 'ios') {
await Sharing.shareAsync(capturedUri, {
mimeType: 'image/png',
UTI: 'public.png',
});
} else {
await handleMoreShare();
}
} catch (error) {
console.error('Messages share error:', error);
await Share.share({
message: `${question.text}\n\nDiscover more on sauci.app`,
});
}
};
const handleMoreShare = async () => {
if (!capturedUri) {
await Share.share({
message: `${question.text}\n\nDiscover more on sauci.app`,
});
return;
}
try {
const isAvailable = await Sharing.isAvailableAsync();
if (isAvailable) {
await Sharing.shareAsync(capturedUri, {
mimeType: 'image/png',
dialogTitle: 'Share this question',
});
} else {
await Share.share({
message: `${question.text}\n\nDiscover more on sauci.app`,
});
}
} catch (error) {
console.error('Share error:', error);
}
};
const useBlur = Platform.OS === 'ios';
return (
<Modal
visible={visible}
transparent
animationType="none"
statusBarTranslucent
onRequestClose={onClose}
>
<View style={styles.overlay}>
{/* Backdrop */}
<Animated.View
entering={FadeIn.duration(200)}
exiting={FadeOut.duration(200)}
style={StyleSheet.absoluteFill}
>
{useBlur ? (
<BlurView
intensity={blur.heavy}
tint="dark"
style={StyleSheet.absoluteFill}
/>
) : (
<View style={styles.backdropFallback} />
)}
</Animated.View>
{/* Content */}
<Animated.View
entering={SlideInDown.springify().damping(20)}
exiting={SlideOutDown.duration(200)}
style={[styles.content, { paddingTop: insets.top }]}
>
{/* Header */}
<View style={styles.header}>
<TouchableOpacity
style={styles.closeButton}
onPress={onClose}
hitSlop={{ top: 20, bottom: 20, left: 20, right: 20 }}
>
<Ionicons name="close" size={28} color={colors.text} />
</TouchableOpacity>
<Text style={styles.title}>Share</Text>
<View style={styles.headerSpacer} />
</View>
{/* Card Preview */}
<View style={styles.previewContainer}>
<ViewShot
ref={shareViewRef}
options={{
format: 'png',
quality: 1,
result: 'tmpfile',
}}
>
<ShareableCard
question={question}
packName={packName}
cardWidth={cardWidth}
cardColor={cardColor}
/>
</ViewShot>
</View>
{/* Share Actions */}
<View style={[styles.actionsContainer, { paddingBottom: insets.bottom + spacing.lg }]}>
<ShareActionButton
icon="logo-instagram"
label="Instagram"
colors={['#833AB4', '#FD1D1D', '#F77737']}
onPress={handleInstagramShare}
disabled={isCapturing}
/>
<ShareActionButton
icon="chatbubble-ellipses"
label="Messages"
colors={['#34C759', '#30D158']}
onPress={handleMessagesShare}
disabled={isCapturing}
/>
<ShareActionButton
icon="share-outline"
label="More"
colors={[colors.glass.backgroundLight, colors.glass.backgroundLight]}
onPress={handleMoreShare}
disabled={isCapturing}
isOutline
/>
</View>
</Animated.View>
</View>
</Modal>
);
}
function ShareActionButton({
icon,
label,
colors: buttonColors,
onPress,
disabled,
isOutline,
}: {
icon: string;
label: string;
colors: string[];
onPress: () => void;
disabled?: boolean;
isOutline?: boolean;
}) {
return (
<TouchableOpacity
style={styles.actionButton}
onPress={onPress}
disabled={disabled}
activeOpacity={0.7}
>
<View style={[
styles.actionIconContainer,
isOutline && styles.actionIconOutline,
]}>
{!isOutline && (
<LinearGradient
colors={buttonColors as [string, string, ...string[]]}
style={StyleSheet.absoluteFill}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
/>
)}
<Ionicons
name={icon as any}
size={28}
color={colors.text}
/>
</View>
<Text style={styles.actionLabel}>{label}</Text>
</TouchableOpacity>
);
}
function ShareableCard({
question,
packName,
cardWidth,
cardColor,
}: {
question: {
text: string;
partner_text?: string | null;
is_two_part?: boolean;
};
packName?: string;
cardWidth: number;
cardColor?: string;
}) {
// Use the card color if provided, otherwise fall back to primary gradient
const useGradient = !cardColor;
return (
<View style={[styles.shareableCard, { width: cardWidth }]}>
{/* Background - solid color matching what the user saw, or gradient fallback */}
{useGradient ? (
<LinearGradient
colors={gradients.primary as [string, string]}
style={[StyleSheet.absoluteFill, { borderRadius: radius.xl }]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
/>
) : (
<View
style={[
StyleSheet.absoluteFill,
{ backgroundColor: cardColor, borderRadius: radius.xl }
]}
/>
)}
{/* Subtle overlay for depth */}
<View style={styles.cardOverlay} />
{/* Pack name badge */}
{packName && (
<View style={styles.packBadge}>
<Text style={styles.packBadgeText}>{packName}</Text>
</View>
)}
{/* Content */}
<View style={styles.shareableContent}>
<Text style={[styles.questionText, question.is_two_part && styles.questionTextSmaller]}>
{question.text}
</Text>
{/* Partner's version of the question */}
{question.partner_text && (
<View style={styles.partnerSection}>
<View style={styles.partnerDivider}>
<View style={styles.partnerDividerLine} />
<Text style={styles.partnerLabel}>Partner sees</Text>
<View style={styles.partnerDividerLine} />
</View>
<Text style={styles.partnerText}>{question.partner_text}</Text>
</View>
)}
</View>
{/* Branding with logo */}
<View style={styles.branding}>
<Image source={logo} style={styles.brandingLogo} />
<Text style={styles.brandingText}>sauci.app</Text>
</View>
</View>
);
}
const styles = StyleSheet.create({
overlay: {
flex: 1,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
},
backdropFallback: {
flex: 1,
backgroundColor: 'rgba(13, 13, 26, 0.95)',
},
content: {
flex: 1,
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: spacing.lg,
paddingVertical: spacing.md,
},
closeButton: {
width: 44,
height: 44,
justifyContent: 'center',
alignItems: 'center',
},
title: {
...typography.headline,
color: colors.text,
fontWeight: '600',
},
headerSpacer: {
width: 44,
},
previewContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
paddingHorizontal: spacing.lg,
},
actionsContainer: {
flexDirection: 'row',
justifyContent: 'center',
gap: spacing.xl,
paddingTop: spacing.xl,
paddingHorizontal: spacing.lg,
},
actionButton: {
alignItems: 'center',
gap: spacing.sm,
},
actionIconContainer: {
width: 64,
height: 64,
borderRadius: 32,
justifyContent: 'center',
alignItems: 'center',
overflow: 'hidden',
},
actionIconOutline: {
backgroundColor: colors.glass.backgroundLight,
borderWidth: 1,
borderColor: colors.glass.border,
},
actionLabel: {
...typography.caption1,
color: colors.text,
fontWeight: '500',
},
// Shareable card styles
shareableCard: {
aspectRatio: 0.7,
borderRadius: radius.xl,
overflow: 'hidden',
padding: spacing.lg,
},
cardOverlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0, 0, 0, 0.1)',
},
packBadge: {
alignSelf: 'flex-start',
backgroundColor: 'rgba(255, 255, 255, 0.2)',
paddingHorizontal: spacing.md,
paddingVertical: spacing.xs,
borderRadius: radius.full,
},
packBadgeText: {
...typography.caption1,
color: colors.text,
fontWeight: '600',
},
shareableContent: {
flex: 1,
justifyContent: 'center',
paddingVertical: spacing.xl,
},
questionText: {
...typography.title1,
color: colors.text,
fontWeight: '700',
lineHeight: 36,
},
questionTextSmaller: {
...typography.title2,
lineHeight: 30,
},
partnerSection: {
marginTop: spacing.lg,
},
partnerDivider: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: spacing.sm,
gap: spacing.sm,
},
partnerDividerLine: {
flex: 1,
height: 1,
backgroundColor: 'rgba(255, 255, 255, 0.3)',
},
partnerLabel: {
...typography.caption1,
color: 'rgba(255, 255, 255, 0.7)',
fontWeight: '500',
},
partnerText: {
...typography.subhead,
color: 'rgba(255, 255, 255, 0.8)',
fontStyle: 'italic',
},
branding: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: spacing.sm,
},
brandingLogo: {
width: 24,
height: 24,
resizeMode: 'contain',
},
brandingText: {
...typography.subhead,
color: colors.text,
fontWeight: '600',
},
});
@@ -0,0 +1 @@
export { SharePreviewModal } from './SharePreviewModal';
@@ -1,678 +0,0 @@
import { useState, useEffect } from "react";
import { View, Text, StyleSheet, Platform, useWindowDimensions, TouchableOpacity as RNTouchableOpacity } from "react-native";
import { Gesture, GestureDetector, TouchableOpacity } from "react-native-gesture-handler";
import { BlurView } from "expo-blur";
import { LinearGradient } from "expo-linear-gradient";
import Animated, {
useSharedValue,
useAnimatedStyle,
withSpring,
withRepeat,
withSequence,
runOnJS,
interpolate,
Extrapolate,
withTiming,
interpolateColor,
Easing,
} from "react-native-reanimated";
import { Ionicons } from "@expo/vector-icons";
import { colors, gradients, radius, shadows, blur, typography, spacing, animations } from "../../theme";
import { QuestionFeedbackModal } from "../feedback";
// Haptics helper - not supported on web
const triggerHaptic = async (style: 'light' | 'medium' | 'heavy' = 'medium') => {
if (Platform.OS === 'web') return;
const Haptics = await import('expo-haptics');
const feedbackStyle = style === 'light'
? Haptics.ImpactFeedbackStyle.Light
: style === 'heavy'
? Haptics.ImpactFeedbackStyle.Heavy
: Haptics.ImpactFeedbackStyle.Medium;
await Haptics.impactAsync(feedbackStyle);
};
const MAX_CARD_WIDTH = 400;
const SWIPE_THRESHOLD = 100;
interface Props {
question: { id: string; text: string; intensity: number; partner_text?: string | null; is_two_part?: boolean; partner_answered?: boolean };
onSwipe: (direction: "left" | "right" | "up" | "down") => void;
}
const AnimatedBlurView = Animated.createAnimatedComponent(BlurView);
export default function SwipeCard({ question, onSwipe }: Props) {
const { width: screenWidth } = useWindowDimensions();
const cardWidth = Math.min(screenWidth - 48, MAX_CARD_WIDTH);
const [showFeedbackModal, setShowFeedbackModal] = useState(false);
const translateX = useSharedValue(0);
const translateY = useSharedValue(0);
const rotation = useSharedValue(0);
const scale = useSharedValue(1);
const shadowExpand = useSharedValue(0);
const idleBreathing = useSharedValue(0);
const isGesturing = useSharedValue(false);
const context = useSharedValue({ x: 0, y: 0 });
// Subtle idle breathing animation
useEffect(() => {
idleBreathing.value = withRepeat(
withSequence(
withTiming(1, { duration: 2000, easing: Easing.inOut(Easing.sin) }),
withTiming(0, { duration: 2000, easing: Easing.inOut(Easing.sin) })
),
-1,
true
);
}, []);
const gesture = Gesture.Pan()
.activeOffsetX([-15, 15])
.activeOffsetY([-15, 15])
.onStart(() => {
context.value = { x: translateX.value, y: translateY.value };
scale.value = withTiming(1.02, { duration: animations.timing.fast });
shadowExpand.value = withTiming(1, { duration: animations.timing.fast });
isGesturing.value = true;
})
.onUpdate((event) => {
translateX.value = event.translationX + context.value.x;
translateY.value = event.translationY + context.value.y;
rotation.value = interpolate(
translateX.value,
[-cardWidth / 2, cardWidth / 2],
[-12, 12],
Extrapolate.CLAMP
);
})
.onEnd(() => {
scale.value = withTiming(1, { duration: animations.timing.fast });
shadowExpand.value = withTiming(0, { duration: animations.timing.fast });
isGesturing.value = false;
if (Math.abs(translateX.value) > SWIPE_THRESHOLD) {
const direction = translateX.value > 0 ? "right" : "left";
runOnJS(triggerHaptic)('medium');
translateX.value = withSpring(
direction === "right" ? screenWidth * 1.5 : -screenWidth * 1.5,
animations.springGentle
);
runOnJS(onSwipe)(direction);
} else if (translateY.value < -SWIPE_THRESHOLD) {
runOnJS(triggerHaptic)('medium');
translateY.value = withSpring(-screenWidth * 1.5, animations.springGentle);
runOnJS(onSwipe)("up");
} else if (translateY.value > SWIPE_THRESHOLD) {
runOnJS(triggerHaptic)('light');
translateY.value = withSpring(screenWidth * 1.5, animations.springGentle);
runOnJS(onSwipe)("down");
} else {
translateX.value = withSpring(0, animations.spring);
translateY.value = withSpring(0, animations.spring);
rotation.value = withSpring(0, animations.spring);
}
});
const animatedStyle = useAnimatedStyle(() => {
// Apply idle breathing only when card is at rest
const idleOffset = isGesturing.value ? 0 : interpolate(idleBreathing.value, [0, 1], [0, -6]);
return {
transform: [
{ translateX: translateX.value },
{ translateY: translateY.value + idleOffset },
{ rotate: `${rotation.value}deg` },
{ scale: scale.value },
],
};
});
// Dynamic shadow for card lift effect
const cardShadowStyle = useAnimatedStyle(() => ({
shadowOpacity: interpolate(shadowExpand.value, [0, 1], [0.3, 0.5]),
shadowRadius: interpolate(shadowExpand.value, [0, 1], [24, 36]),
shadowOffset: {
width: 0,
height: interpolate(shadowExpand.value, [0, 1], [12, 18]),
},
}));
const overlayStyle = (direction: "left" | "right" | "up" | "down") =>
useAnimatedStyle(() => {
let opacity = 0;
let backgroundColor = 'transparent';
if (direction === "right") {
opacity = interpolate(translateX.value, [0, SWIPE_THRESHOLD], [0, 0.9]);
backgroundColor = interpolateColor(
opacity,
[0, 0.9],
['transparent', 'rgba(46, 204, 113, 0.4)']
) as string;
} else if (direction === "left") {
opacity = interpolate(translateX.value, [0, -SWIPE_THRESHOLD], [0, 0.9]);
backgroundColor = interpolateColor(
opacity,
[0, 0.9],
['transparent', 'rgba(231, 76, 60, 0.4)']
) as string;
} else if (direction === "up") {
opacity = interpolate(translateY.value, [0, -SWIPE_THRESHOLD], [0, 0.9]);
backgroundColor = interpolateColor(
opacity,
[0, 0.9],
['transparent', 'rgba(243, 156, 18, 0.4)']
) as string;
} else {
opacity = interpolate(translateY.value, [0, SWIPE_THRESHOLD], [0, 0.9]);
backgroundColor = interpolateColor(
opacity,
[0, 0.9],
['transparent', 'rgba(108, 117, 125, 0.4)']
) as string;
}
return {
opacity,
backgroundColor,
};
});
const handleButtonPress = async (direction: "left" | "right" | "up") => {
await triggerHaptic('light');
onSwipe(direction);
};
const useBlur = Platform.OS === 'ios';
const handleFeedbackPress = () => {
setShowFeedbackModal(true);
};
return (
<>
<View style={[styles.cardWrapper, { width: cardWidth }]}>
<GestureDetector gesture={gesture}>
<Animated.View style={[styles.cardOuter, animatedStyle, cardShadowStyle, { shadowColor: '#000' }]}>
{useBlur ? (
<BlurView
intensity={blur.medium}
tint="dark"
style={styles.blurContainer}
>
<CardContent
question={question}
overlayStyle={overlayStyle}
handleButtonPress={handleButtonPress}
onFeedbackPress={handleFeedbackPress}
translateX={translateX}
translateY={translateY}
/>
</BlurView>
) : (
<View style={styles.fallbackContainer}>
<CardContent
question={question}
overlayStyle={overlayStyle}
handleButtonPress={handleButtonPress}
onFeedbackPress={handleFeedbackPress}
translateX={translateX}
translateY={translateY}
/>
</View>
)}
{/* Inner border highlight for depth */}
<View style={styles.innerBorder} pointerEvents="none" />
</Animated.View>
</GestureDetector>
</View>
<QuestionFeedbackModal
visible={showFeedbackModal}
onClose={() => setShowFeedbackModal(false)}
questionId={question.id}
questionText={question.text}
/>
</>
);
}
function CardContent({
question,
overlayStyle,
handleButtonPress,
onFeedbackPress,
translateX,
translateY,
}: {
question: Props['question'];
overlayStyle: (direction: "left" | "right" | "up" | "down") => any;
handleButtonPress: (direction: "left" | "right" | "up") => void;
onFeedbackPress: () => void;
translateX: Animated.SharedValue<number>;
translateY: Animated.SharedValue<number>;
}) {
const isHighIntensity = question.intensity >= 3;
// Badge scale animation based on swipe progress
const badgeScaleStyle = (direction: "left" | "right" | "up" | "down") =>
useAnimatedStyle(() => {
let progress = 0;
if (direction === "right") {
progress = interpolate(translateX.value, [0, SWIPE_THRESHOLD], [0, 1], Extrapolate.CLAMP);
} else if (direction === "left") {
progress = interpolate(translateX.value, [-SWIPE_THRESHOLD, 0], [1, 0], Extrapolate.CLAMP);
} else if (direction === "up") {
progress = interpolate(translateY.value, [-SWIPE_THRESHOLD, 0], [1, 0], Extrapolate.CLAMP);
} else {
progress = interpolate(translateY.value, [0, SWIPE_THRESHOLD], [0, 1], Extrapolate.CLAMP);
}
return {
transform: [{ scale: interpolate(progress, [0, 0.5, 1], [0.8, 1, 1.1]) }],
};
});
return (
<>
{/* Top highlight */}
<LinearGradient
colors={[colors.glass.highlight, 'transparent']}
style={styles.highlight}
start={{ x: 0.5, y: 0 }}
end={{ x: 0.5, y: 1 }}
pointerEvents="none"
/>
{/* Left edge highlight */}
<LinearGradient
colors={['rgba(255,255,255,0.08)', 'transparent']}
style={styles.edgeHighlightLeft}
start={{ x: 0, y: 0.5 }}
end={{ x: 1, y: 0.5 }}
pointerEvents="none"
/>
{/* Right edge highlight */}
<LinearGradient
colors={['transparent', 'rgba(255,255,255,0.08)']}
style={styles.edgeHighlightRight}
start={{ x: 0, y: 0.5 }}
end={{ x: 1, y: 0.5 }}
pointerEvents="none"
/>
{/* Feedback Button */}
<RNTouchableOpacity
style={styles.feedbackButton}
onPress={onFeedbackPress}
hitSlop={{ top: 20, bottom: 20, left: 20, right: 20 }}
activeOpacity={0.7}
>
<Ionicons
name="flag-outline"
size={22}
color={colors.textTertiary}
/>
</RNTouchableOpacity>
{/* Overlays with gradient badges */}
<Animated.View style={[styles.overlay, overlayStyle("right")]} pointerEvents="none">
<Animated.View style={[styles.overlayBadge, badgeScaleStyle("right")]}>
<LinearGradient
colors={gradients.success as [string, string]}
style={StyleSheet.absoluteFill}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
/>
<Text style={styles.overlayText}>YES</Text>
</Animated.View>
</Animated.View>
<Animated.View style={[styles.overlay, overlayStyle("left")]} pointerEvents="none">
<Animated.View style={[styles.overlayBadge, badgeScaleStyle("left")]}>
<LinearGradient
colors={gradients.error as [string, string]}
style={StyleSheet.absoluteFill}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
/>
<Text style={styles.overlayText}>NO</Text>
</Animated.View>
</Animated.View>
<Animated.View style={[styles.overlay, overlayStyle("up")]} pointerEvents="none">
<Animated.View style={[styles.overlayBadge, badgeScaleStyle("up")]}>
<LinearGradient
colors={gradients.warning as [string, string]}
style={StyleSheet.absoluteFill}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
/>
<Text style={styles.overlayText}>MAYBE</Text>
</Animated.View>
</Animated.View>
<Animated.View style={[styles.overlay, overlayStyle("down")]} pointerEvents="none">
<Animated.View style={[styles.overlayBadge, badgeScaleStyle("down")]}>
<View style={[StyleSheet.absoluteFill, { backgroundColor: colors.textTertiary }]} />
<Text style={styles.overlayText}>SKIP</Text>
</Animated.View>
</Animated.View>
{/* Content */}
<View style={styles.content}>
<View style={[
styles.intensityContainer,
isHighIntensity && styles.intensityContainerHigh
]}>
{[...Array(question.intensity)].map((_, i) => (
<Ionicons key={i} name="flame" size={16} color={colors.primary} />
))}
</View>
<Text style={[styles.text, question.is_two_part && styles.twoPartText]}>
{question.text}
</Text>
{/* Partner's version of the question - only show when user is answering first */}
{question.partner_text && !question.partner_answered && (
<View style={styles.partnerTextContainer}>
<View style={styles.partnerDivider}>
<View style={styles.partnerDividerLine} />
<View style={styles.partnerLabelContainer}>
<Ionicons name="swap-horizontal" size={12} color={colors.secondary} />
<Text style={styles.partnerLabel}>Partner sees</Text>
</View>
<View style={styles.partnerDividerLine} />
</View>
<Text style={styles.partnerText}>{question.partner_text}</Text>
</View>
)}
</View>
{/* Action Buttons */}
<View style={styles.footer}>
<View style={styles.buttonContainer}>
<ActionButton
onPress={() => handleButtonPress("left")}
colors={gradients.error as [string, string]}
icon="close"
/>
<ActionButton
onPress={() => handleButtonPress("up")}
colors={gradients.warning as [string, string]}
icon="help"
small
/>
<ActionButton
onPress={() => handleButtonPress("right")}
colors={gradients.success as [string, string]}
icon="heart"
/>
</View>
</View>
</>
);
}
function ActionButton({
onPress,
colors: buttonColors,
icon,
small = false,
}: {
onPress: () => void;
colors: [string, string];
icon: string;
small?: boolean;
}) {
const buttonScale = useSharedValue(1);
const buttonGlow = useSharedValue(0);
const handlePressIn = () => {
buttonScale.value = withSpring(0.92, animations.spring);
buttonGlow.value = withTiming(1, { duration: 150 });
};
const handlePressOut = () => {
buttonScale.value = withSpring(1, animations.spring);
buttonGlow.value = withTiming(0, { duration: 200 });
};
const buttonAnimatedStyle = useAnimatedStyle(() => ({
transform: [{ scale: buttonScale.value }],
shadowOpacity: interpolate(buttonGlow.value, [0, 1], [0.3, 0.6]),
shadowRadius: interpolate(buttonGlow.value, [0, 1], [8, 16]),
}));
return (
<TouchableOpacity
onPress={onPress}
onPressIn={handlePressIn}
onPressOut={handlePressOut}
activeOpacity={1}
>
<Animated.View style={[
styles.actionButton,
small && styles.actionButtonSmall,
buttonAnimatedStyle,
{ shadowColor: buttonColors[0] }
]}>
<LinearGradient
colors={buttonColors}
style={StyleSheet.absoluteFill}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
/>
<LinearGradient
colors={['rgba(255,255,255,0.25)', 'rgba(255,255,255,0.05)']}
style={styles.buttonHighlight}
start={{ x: 0.5, y: 0 }}
end={{ x: 0.5, y: 1 }}
/>
<Ionicons
name={icon as any}
size={small ? 20 : 28}
color={colors.text}
style={styles.buttonIcon}
/>
</Animated.View>
</TouchableOpacity>
);
}
const styles = StyleSheet.create({
cardWrapper: {
position: "absolute",
maxWidth: MAX_CARD_WIDTH,
height: 480,
},
cardOuter: {
width: "100%",
height: "100%",
borderRadius: radius.xxl,
overflow: "hidden",
},
blurContainer: {
flex: 1,
backgroundColor: colors.glass.background,
borderWidth: 1,
borderColor: colors.glass.border,
borderRadius: radius.xxl,
overflow: "hidden",
},
fallbackContainer: {
flex: 1,
backgroundColor: colors.glass.backgroundLight,
borderWidth: 1,
borderColor: colors.glass.border,
borderRadius: radius.xxl,
overflow: "hidden",
},
highlight: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: 80,
},
edgeHighlightLeft: {
position: 'absolute',
top: 0,
left: 0,
bottom: 0,
width: 60,
},
edgeHighlightRight: {
position: 'absolute',
top: 0,
right: 0,
bottom: 0,
width: 60,
},
innerBorder: {
position: 'absolute',
top: 2,
left: 2,
right: 2,
bottom: 2,
borderRadius: radius.xxl - 2,
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.06)',
},
feedbackButton: {
position: 'absolute',
top: spacing.md,
right: spacing.md,
width: 44,
height: 44,
borderRadius: 22,
backgroundColor: colors.glass.background,
justifyContent: 'center',
alignItems: 'center',
zIndex: 999,
elevation: 999,
borderWidth: 1,
borderColor: colors.glass.border,
},
content: {
flex: 1,
justifyContent: "center",
alignItems: "center",
padding: spacing.xl,
},
intensityContainer: {
flexDirection: "row",
marginBottom: spacing.lg,
backgroundColor: colors.primaryLight,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: radius.full,
gap: 2,
},
intensityContainerHigh: {
borderWidth: 1,
borderColor: colors.primary,
...shadows.glow(colors.primaryGlow),
},
text: {
...typography.title2,
color: colors.text,
textAlign: "center",
lineHeight: 32,
},
twoPartText: {
...typography.title3,
lineHeight: 28,
},
partnerTextContainer: {
marginTop: spacing.lg,
width: '100%',
paddingHorizontal: spacing.sm,
},
partnerDivider: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: spacing.sm,
},
partnerDividerLine: {
flex: 1,
height: 1,
backgroundColor: colors.glass.border,
},
partnerLabelContainer: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.md,
paddingVertical: spacing.xs,
backgroundColor: colors.secondaryLight,
borderRadius: radius.full,
gap: spacing.xs,
},
partnerLabel: {
...typography.caption1,
color: colors.secondary,
fontWeight: '500',
letterSpacing: 0.3,
},
partnerText: {
...typography.subhead,
color: colors.textSecondary,
textAlign: 'center',
fontStyle: 'italic',
},
footer: {
paddingBottom: spacing.lg,
paddingHorizontal: spacing.lg,
},
overlay: {
...StyleSheet.absoluteFillObject,
justifyContent: "center",
alignItems: "center",
zIndex: 10,
borderRadius: radius.xxl,
},
overlayBadge: {
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
borderRadius: radius.md,
transform: [{ rotate: "-15deg" }],
overflow: 'hidden',
},
overlayText: {
fontSize: 36,
fontWeight: "900",
color: colors.text,
letterSpacing: 2,
},
buttonContainer: {
flexDirection: "row",
justifyContent: "center",
alignItems: "center",
gap: spacing.md,
},
actionButton: {
width: 64,
height: 64,
borderRadius: 32,
justifyContent: "center",
alignItems: "center",
overflow: "hidden",
},
actionButtonSmall: {
width: 52,
height: 52,
borderRadius: 26,
},
buttonHighlight: {
position: "absolute",
top: 0,
left: 0,
right: 0,
height: "50%",
borderTopLeftRadius: 32,
borderTopRightRadius: 32,
},
buttonIcon: {
zIndex: 1,
},
});
@@ -1,17 +0,0 @@
/**
* SwipeCard component with platform-specific implementations.
*
* - On native (iOS/Android): Uses react-native-gesture-handler with Reanimated
* for smooth, native-level gesture performance (SwipeCard.native.tsx)
*
* - On web: Uses React Native's built-in PanResponder which is compatible with
* react-native-web (SwipeCard.web.tsx)
*
* Metro bundler automatically picks the right file based on the platform:
* - .native.tsx for iOS/Android
* - .web.tsx for web
*
* This file is a fallback that re-exports the web version for any other platform
* that might not have a specific implementation.
*/
export { default } from "./SwipeCard.web";
@@ -1,478 +0,0 @@
import { useState, useRef } from "react";
import { View, Text, StyleSheet, Animated, PanResponder, TouchableOpacity, useWindowDimensions } from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { QuestionFeedbackModal } from "../feedback";
const MAX_CARD_WIDTH = 400;
const SWIPE_THRESHOLD = 100;
interface Props {
question: { id: string; text: string; intensity: number; partner_text?: string | null; partner_answered?: boolean };
onSwipe: (direction: "left" | "right" | "up" | "down") => void;
}
/**
* Web-compatible SwipeCard using React Native's built-in PanResponder
* instead of react-native-gesture-handler which doesn't work on web.
*/
export default function SwipeCard({ question, onSwipe }: Props) {
const { width: screenWidth } = useWindowDimensions();
const cardWidth = Math.min(screenWidth - 48, MAX_CARD_WIDTH);
const [dismissed, setDismissed] = useState(false);
const [hoveredButton, setHoveredButton] = useState<string | null>(null);
const [showFeedbackModal, setShowFeedbackModal] = useState(false);
const translateX = useRef(new Animated.Value(0)).current;
const translateY = useRef(new Animated.Value(0)).current;
const scale = useRef(new Animated.Value(1)).current;
const rotation = translateX.interpolate({
inputRange: [-cardWidth / 2, cardWidth / 2],
outputRange: ["-15deg", "15deg"],
extrapolate: "clamp",
});
const rightOverlayOpacity = translateX.interpolate({
inputRange: [0, SWIPE_THRESHOLD],
outputRange: [0, 1],
extrapolate: "clamp",
});
const leftOverlayOpacity = translateX.interpolate({
inputRange: [-SWIPE_THRESHOLD, 0],
outputRange: [1, 0],
extrapolate: "clamp",
});
const upOverlayOpacity = translateY.interpolate({
inputRange: [-SWIPE_THRESHOLD, 0],
outputRange: [1, 0],
extrapolate: "clamp",
});
const downOverlayOpacity = translateY.interpolate({
inputRange: [0, SWIPE_THRESHOLD],
outputRange: [0, 1],
extrapolate: "clamp",
});
const panResponder = useRef(
PanResponder.create({
onStartShouldSetPanResponder: (_, gestureState) => {
// Don't activate if touch is in footer area (bottom 100px)
const CARD_HEIGHT = 500;
const FOOTER_START_Y = CARD_HEIGHT - 100;
return gestureState.y0 < FOOTER_START_Y;
},
onMoveShouldSetPanResponder: (_, gestureState) => {
// Don't activate if touch is in footer area
const CARD_HEIGHT = 500;
const FOOTER_START_Y = CARD_HEIGHT - 100;
return gestureState.y0 < FOOTER_START_Y && (Math.abs(gestureState.dx) > 5 || Math.abs(gestureState.dy) > 5);
},
onPanResponderGrant: () => {
Animated.timing(scale, {
toValue: 1.05,
duration: 100,
useNativeDriver: true,
}).start();
},
onPanResponderMove: (_, gestureState) => {
translateX.setValue(gestureState.dx);
translateY.setValue(gestureState.dy);
},
onPanResponderRelease: (_, gestureState) => {
Animated.timing(scale, {
toValue: 1,
duration: 100,
useNativeDriver: true,
}).start();
if (Math.abs(gestureState.dx) > SWIPE_THRESHOLD) {
// Horizontal swipe
const direction = gestureState.dx > 0 ? "right" : "left";
const targetX = direction === "right" ? screenWidth * 1.5 : -screenWidth * 1.5;
Animated.spring(translateX, {
toValue: targetX,
useNativeDriver: true,
}).start(() => {
setDismissed(true);
onSwipe(direction);
});
} else if (gestureState.dy < -SWIPE_THRESHOLD) {
// Vertical swipe up
Animated.spring(translateY, {
toValue: -screenWidth * 1.5,
useNativeDriver: true,
}).start(() => {
setDismissed(true);
onSwipe("up");
});
} else if (gestureState.dy > SWIPE_THRESHOLD) {
// Vertical swipe down - skip
Animated.spring(translateY, {
toValue: screenWidth * 1.5,
useNativeDriver: true,
}).start(() => {
setDismissed(true);
onSwipe("down");
});
} else {
// Reset position
Animated.parallel([
Animated.spring(translateX, {
toValue: 0,
useNativeDriver: true,
}),
Animated.spring(translateY, {
toValue: 0,
useNativeDriver: true,
}),
]).start();
}
},
})
).current;
if (dismissed) {
return null;
}
return (
<>
<Animated.View
style={[
styles.card,
webStyles.card as any,
{
width: cardWidth,
transform: [
{ translateX },
{ translateY },
{ rotate: rotation },
{ scale },
],
},
]}
{...panResponder.panHandlers}
>
{/* Feedback Button */}
<TouchableOpacity
style={styles.feedbackButton}
onPress={() => setShowFeedbackModal(true)}
activeOpacity={0.7}
>
<Ionicons name="flag-outline" size={18} color="#888" />
</TouchableOpacity>
{/* Overlays */}
<Animated.View
style={[
styles.overlay,
{ opacity: rightOverlayOpacity, backgroundColor: "rgba(76, 175, 80, 0.4)" },
]}
>
<Text style={styles.overlayText}>YES</Text>
</Animated.View>
<Animated.View
style={[
styles.overlay,
{ opacity: leftOverlayOpacity, backgroundColor: "rgba(244, 67, 54, 0.4)" },
]}
>
<Text style={styles.overlayText}>NO</Text>
</Animated.View>
<Animated.View
style={[
styles.overlay,
{ opacity: upOverlayOpacity, backgroundColor: "rgba(255, 152, 0, 0.4)" },
]}
>
<Text style={styles.overlayText}>MAYBE</Text>
</Animated.View>
<Animated.View
style={[
styles.overlay,
{ opacity: downOverlayOpacity, backgroundColor: "rgba(108, 117, 125, 0.4)" },
]}
>
<Text style={styles.overlayText}>SKIP</Text>
</Animated.View>
<View style={styles.content}>
<View style={styles.intensityContainer}>
{[...Array(question.intensity)].map((_, i) => (
<Ionicons key={i} name="flame" size={16} color="#e94560" />
))}
</View>
<Text style={[styles.text, question.partner_text ? styles.twoPartText : null]}>{question.text}</Text>
{/* Partner's version of the question - only show when user is answering first */}
{question.partner_text && !question.partner_answered && (
<View style={styles.partnerTextContainer}>
<View style={styles.partnerDivider}>
<View style={styles.partnerDividerLine} />
<View style={styles.partnerLabelContainer}>
<Ionicons name="swap-horizontal" size={12} color="#9b59b6" />
<Text style={styles.partnerLabel}>Partner sees</Text>
</View>
<View style={styles.partnerDividerLine} />
</View>
<Text style={styles.partnerText}>{question.partner_text}</Text>
</View>
)}
</View>
<View style={styles.footer}>
<View style={styles.buttonContainer}>
<TouchableOpacity
style={[
styles.actionButton,
styles.noButton,
hoveredButton === "no" && styles.buttonHovered,
]}
onPress={() => onSwipe("left")}
// @ts-expect-error - web-only props
onMouseEnter={() => setHoveredButton("no")}
onMouseLeave={() => setHoveredButton(null)}
activeOpacity={0.7}
>
<View style={styles.buttonInnerGlow} />
<Ionicons name="thumbs-down" size={24} color="#fff" style={styles.buttonIcon} />
</TouchableOpacity>
<TouchableOpacity
style={[
styles.actionButton,
styles.maybeButton,
hoveredButton === "maybe" && styles.buttonHovered,
]}
onPress={() => onSwipe("up")}
// @ts-expect-error - web-only props
onMouseEnter={() => setHoveredButton("maybe")}
onMouseLeave={() => setHoveredButton(null)}
activeOpacity={0.7}
>
<View style={styles.buttonInnerGlow} />
<Ionicons name="help-circle" size={24} color="#fff" style={styles.buttonIcon} />
</TouchableOpacity>
<TouchableOpacity
style={[
styles.actionButton,
styles.yesButton,
hoveredButton === "yes" && styles.buttonHovered,
]}
onPress={() => onSwipe("right")}
// @ts-expect-error - web-only props
onMouseEnter={() => setHoveredButton("yes")}
onMouseLeave={() => setHoveredButton(null)}
activeOpacity={0.7}
>
<View style={styles.buttonInnerGlow} />
<Ionicons name="thumbs-up" size={24} color="#fff" style={styles.buttonIcon} />
</TouchableOpacity>
</View>
</View>
</Animated.View>
<QuestionFeedbackModal
visible={showFeedbackModal}
onClose={() => setShowFeedbackModal(false)}
questionId={question.id}
questionText={question.text}
/>
</>
);
}
// Separate web-only styles that TypeScript doesn't recognize
const webStyles = {
card: { cursor: "grab" as const },
actionButton: { transition: "transform 0.2s ease, box-shadow 0.2s ease" },
};
const styles = StyleSheet.create({
card: {
position: "absolute",
height: 500,
maxWidth: MAX_CARD_WIDTH,
backgroundColor: "#16213e",
borderRadius: 24,
borderWidth: 1,
borderColor: "#0f3460",
shadowColor: "#000",
shadowOffset: {
width: 0,
height: 4,
},
shadowOpacity: 0.3,
shadowRadius: 4.65,
elevation: 8,
overflow: "hidden",
},
feedbackButton: {
position: 'absolute',
top: 12,
right: 12,
width: 32,
height: 32,
borderRadius: 16,
backgroundColor: 'rgba(255, 255, 255, 0.1)',
justifyContent: 'center',
alignItems: 'center',
zIndex: 5,
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.2)',
},
content: {
flex: 1,
justifyContent: "center",
alignItems: "center",
padding: 32,
},
intensityContainer: {
flexDirection: "row",
marginBottom: 24,
backgroundColor: "rgba(233, 69, 96, 0.1)",
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 12,
},
text: {
fontSize: 24,
fontWeight: "bold",
color: "#fff",
textAlign: "center",
lineHeight: 32,
},
twoPartText: {
fontSize: 20,
lineHeight: 28,
},
footer: {
paddingTop: 8,
paddingBottom: 16,
paddingHorizontal: 8,
alignItems: "center",
backgroundColor: "rgba(0,0,0,0.2)",
},
hint: {
color: "#666",
fontSize: 12,
marginBottom: 4,
},
overlay: {
...StyleSheet.absoluteFillObject,
justifyContent: "center",
alignItems: "center",
zIndex: 10,
},
overlayText: {
fontSize: 48,
fontWeight: "900",
color: "#fff",
textShadowColor: "rgba(0,0,0,0.5)",
textShadowOffset: { width: 0, height: 2 },
textShadowRadius: 4,
transform: [{ rotate: "-15deg" }],
borderWidth: 4,
borderColor: "#fff",
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: 12,
},
partnerTextContainer: {
marginTop: 24,
width: "100%",
paddingHorizontal: 8,
},
partnerDivider: {
flexDirection: "row",
alignItems: "center",
marginBottom: 8,
},
partnerDividerLine: {
flex: 1,
height: 1,
backgroundColor: "rgba(255, 255, 255, 0.08)",
},
partnerLabelContainer: {
flexDirection: "row",
alignItems: "center",
paddingHorizontal: 16,
paddingVertical: 4,
backgroundColor: "rgba(155, 89, 182, 0.15)",
borderRadius: 9999,
gap: 4,
},
partnerLabel: {
fontSize: 12,
color: "#9b59b6",
fontWeight: "500",
letterSpacing: 0.3,
},
partnerText: {
fontSize: 14,
color: "rgba(255, 255, 255, 0.7)",
textAlign: "center",
fontStyle: "italic",
},
buttonContainer: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
paddingHorizontal: 32,
paddingVertical: 16,
gap: 20,
},
actionButton: {
alignItems: "center",
justifyContent: "center",
width: 64,
height: 64,
borderRadius: 32,
shadowColor: "#000",
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.4,
shadowRadius: 8,
elevation: 8,
borderWidth: 2,
overflow: "hidden",
},
noButton: {
backgroundColor: "#E74C3C",
borderColor: "rgba(255, 255, 255, 0.3)",
},
maybeButton: {
backgroundColor: "#F39C12",
borderColor: "rgba(255, 255, 255, 0.3)",
},
yesButton: {
backgroundColor: "#2ECC71",
borderColor: "rgba(255, 255, 255, 0.3)",
},
buttonInnerGlow: {
position: "absolute",
top: 0,
left: 0,
right: 0,
height: "50%",
backgroundColor: "rgba(255, 255, 255, 0.15)",
borderTopLeftRadius: 32,
borderTopRightRadius: 32,
},
buttonIcon: {
zIndex: 1,
textShadowColor: "rgba(0, 0, 0, 0.3)",
textShadowOffset: { width: 0, height: 1 },
textShadowRadius: 2,
},
buttonHovered: {
transform: [{ scale: 1.1 }],
shadowOpacity: 0.6,
shadowRadius: 12,
},
});
@@ -1,587 +0,0 @@
/**
* SwipeCardPremium - PoC of premium/boutique styling for the swipe experience
*
* Inspired by the "Coming Soon" Ann Summers aesthetic
*/
import { useState, useEffect } from "react";
import { View, Text, StyleSheet, Platform, useWindowDimensions, Pressable } from "react-native";
import { Gesture, GestureDetector, TouchableOpacity } from "react-native-gesture-handler";
import { BlurView } from "expo-blur";
import { LinearGradient } from "expo-linear-gradient";
import Animated, {
useSharedValue,
useAnimatedStyle,
withSpring,
withRepeat,
withSequence,
runOnJS,
interpolate,
Extrapolate,
withTiming,
interpolateColor,
Easing,
FadeIn,
} from "react-native-reanimated";
import { Ionicons } from "@expo/vector-icons";
import { colors, gradients, featureColors, radius, shadows, blur, typography, spacing, animations } from "../../theme";
// Use feature colors for consistency
const { accent: ACCENT } = featureColors.dares;
const { accent: ROSE } = featureColors.quiz;
// RGBA helpers for custom opacity levels (derived from theme colors)
// Gold: #D4AF37 = rgb(212, 175, 55)
// Rose: #E8A4AE = rgb(232, 164, 174)
const rgba = {
gold: (opacity: number) => `rgba(212, 175, 55, ${opacity})`,
rose: (opacity: number) => `rgba(232, 164, 174, ${opacity})`,
};
// Haptics helper
const triggerHaptic = async (style: 'light' | 'medium' | 'heavy' = 'medium') => {
if (Platform.OS === 'web') return;
const Haptics = await import('expo-haptics');
const feedbackStyle = style === 'light'
? Haptics.ImpactFeedbackStyle.Light
: style === 'heavy'
? Haptics.ImpactFeedbackStyle.Heavy
: Haptics.ImpactFeedbackStyle.Medium;
await Haptics.impactAsync(feedbackStyle);
};
const MAX_CARD_WIDTH = 400;
const SWIPE_THRESHOLD = 100;
interface Props {
question: { id: string; text: string; intensity: number; partner_text?: string | null; is_two_part?: boolean; partner_answered?: boolean };
onSwipe: (direction: "left" | "right" | "up" | "down") => void;
onReport?: () => void;
}
export default function SwipeCardPremium({ question, onSwipe, onReport }: Props) {
const { width: screenWidth } = useWindowDimensions();
const cardWidth = Math.min(screenWidth - 48, MAX_CARD_WIDTH);
const translateX = useSharedValue(0);
const translateY = useSharedValue(0);
const rotation = useSharedValue(0);
const scale = useSharedValue(1);
const shadowExpand = useSharedValue(0);
const idleBreathing = useSharedValue(0);
const isGesturing = useSharedValue(false);
const context = useSharedValue({ x: 0, y: 0 });
// Subtle idle breathing animation
useEffect(() => {
idleBreathing.value = withRepeat(
withSequence(
withTiming(1, { duration: 3000, easing: Easing.inOut(Easing.sin) }),
withTiming(0, { duration: 3000, easing: Easing.inOut(Easing.sin) })
),
-1,
true
);
}, []);
const gesture = Gesture.Pan()
.activeOffsetX([-15, 15])
.activeOffsetY([-15, 15])
.onStart(() => {
context.value = { x: translateX.value, y: translateY.value };
scale.value = withTiming(1.02, { duration: animations.timing.fast });
shadowExpand.value = withTiming(1, { duration: animations.timing.fast });
isGesturing.value = true;
})
.onUpdate((event) => {
translateX.value = event.translationX + context.value.x;
translateY.value = event.translationY + context.value.y;
rotation.value = interpolate(
translateX.value,
[-cardWidth / 2, cardWidth / 2],
[-8, 8], // Reduced rotation for elegance
Extrapolate.CLAMP
);
})
.onEnd(() => {
scale.value = withTiming(1, { duration: animations.timing.fast });
shadowExpand.value = withTiming(0, { duration: animations.timing.fast });
isGesturing.value = false;
if (Math.abs(translateX.value) > SWIPE_THRESHOLD) {
const direction = translateX.value > 0 ? "right" : "left";
runOnJS(triggerHaptic)('medium');
translateX.value = withSpring(
direction === "right" ? screenWidth * 1.5 : -screenWidth * 1.5,
animations.springGentle
);
runOnJS(onSwipe)(direction);
} else if (translateY.value < -SWIPE_THRESHOLD) {
runOnJS(triggerHaptic)('medium');
translateY.value = withSpring(-screenWidth * 1.5, animations.springGentle);
runOnJS(onSwipe)("up");
} else if (translateY.value > SWIPE_THRESHOLD) {
runOnJS(triggerHaptic)('light');
translateY.value = withSpring(screenWidth * 1.5, animations.springGentle);
runOnJS(onSwipe)("down");
} else {
translateX.value = withSpring(0, animations.spring);
translateY.value = withSpring(0, animations.spring);
rotation.value = withSpring(0, animations.spring);
}
});
const animatedStyle = useAnimatedStyle(() => {
const idleOffset = isGesturing.value ? 0 : interpolate(idleBreathing.value, [0, 1], [0, -4]);
return {
transform: [
{ translateX: translateX.value },
{ translateY: translateY.value + idleOffset },
{ rotate: `${rotation.value}deg` },
{ scale: scale.value },
],
};
});
const cardShadowStyle = useAnimatedStyle(() => ({
shadowOpacity: interpolate(shadowExpand.value, [0, 1], [0.2, 0.4]),
shadowRadius: interpolate(shadowExpand.value, [0, 1], [20, 32]),
shadowOffset: {
width: 0,
height: interpolate(shadowExpand.value, [0, 1], [10, 16]),
},
}));
const overlayStyle = (direction: "left" | "right" | "up" | "down") =>
useAnimatedStyle(() => {
let opacity = 0;
if (direction === "right") {
opacity = interpolate(translateX.value, [0, SWIPE_THRESHOLD], [0, 1]);
} else if (direction === "left") {
opacity = interpolate(translateX.value, [0, -SWIPE_THRESHOLD], [0, 1]);
} else if (direction === "up") {
opacity = interpolate(translateY.value, [0, -SWIPE_THRESHOLD], [0, 1]);
} else {
opacity = interpolate(translateY.value, [0, SWIPE_THRESHOLD], [0, 1]);
}
return { opacity };
});
const handleButtonPress = async (direction: "left" | "right" | "up") => {
await triggerHaptic('light');
onSwipe(direction);
};
return (
<View style={[styles.cardWrapper, { width: cardWidth }]}>
{/* Report Button - outside GestureDetector to receive touches */}
{onReport && (
<Pressable
style={styles.reportButton}
onPress={onReport}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<Ionicons name="flag-outline" size={16} color={colors.textTertiary} />
</Pressable>
)}
<GestureDetector gesture={gesture}>
<Animated.View style={[styles.cardOuter, animatedStyle, cardShadowStyle, { shadowColor: ACCENT }]}>
{/* Premium dark background */}
<View style={styles.cardBackground}>
{/* Subtle gradient overlay */}
<LinearGradient
colors={['rgba(22, 33, 62, 0.95)', 'rgba(13, 13, 26, 0.98)']}
style={StyleSheet.absoluteFill}
start={{ x: 0.5, y: 0 }}
end={{ x: 0.5, y: 1 }}
/>
{/* Top silk highlight */}
<LinearGradient
colors={[rgba.gold(0.08), 'transparent']}
style={styles.silkHighlight}
start={{ x: 0.5, y: 0 }}
end={{ x: 0.5, y: 1 }}
/>
{/* Swipe Overlays - Premium style */}
<Animated.View style={[styles.overlay, styles.overlayYes, overlayStyle("right")]} pointerEvents="none">
<View style={styles.overlayBadgePremium}>
<Text style={styles.overlayTextPremium}>YES</Text>
</View>
</Animated.View>
<Animated.View style={[styles.overlay, styles.overlayNo, overlayStyle("left")]} pointerEvents="none">
<View style={[styles.overlayBadgePremium, styles.overlayBadgeNo]}>
<Text style={[styles.overlayTextPremium, styles.overlayTextNo]}>NO</Text>
</View>
</Animated.View>
<Animated.View style={[styles.overlay, styles.overlayMaybe, overlayStyle("up")]} pointerEvents="none">
<View style={[styles.overlayBadgePremium, styles.overlayBadgeMaybe]}>
<Text style={[styles.overlayTextPremium, styles.overlayTextMaybe]}>MAYBE</Text>
</View>
</Animated.View>
<Animated.View style={[styles.overlay, styles.overlaySkip, overlayStyle("down")]} pointerEvents="none">
<View style={[styles.overlayBadgePremium, styles.overlayBadgeSkip]}>
<Text style={[styles.overlayTextPremium, styles.overlayTextSkip]}>SKIP</Text>
</View>
</Animated.View>
{/* Content */}
<View style={styles.content}>
{/* Intensity badge - more prominent */}
<View style={styles.intensityBadge}>
<Text style={styles.intensityLabel}>INTENSITY</Text>
<View style={styles.intensityRow}>
{[...Array(5)].map((_, i) => (
<Ionicons
key={i}
name={i < question.intensity ? "flame" : "flame-outline"}
size={18}
color={i < question.intensity ? ACCENT : 'rgba(255, 255, 255, 0.2)'}
/>
))}
</View>
</View>
{/* Decorative separator */}
<View style={styles.separator}>
<View style={styles.separatorLine} />
<View style={styles.separatorDiamond} />
<View style={styles.separatorLine} />
</View>
{/* Question text */}
<Text style={styles.questionText}>
{question.text}
</Text>
{/* Partner's version of the question - only show when user is answering first */}
{question.partner_text && !question.partner_answered && (
<View style={styles.partnerTextContainer}>
<View style={styles.partnerDivider}>
<View style={styles.partnerDividerLine} />
<View style={styles.partnerLabelBadge}>
<Ionicons name="swap-horizontal" size={10} color={ROSE} />
<Text style={styles.partnerLabelText}>PARTNER SEES</Text>
</View>
<View style={styles.partnerDividerLine} />
</View>
<Text style={styles.partnerQuestionText}>{question.partner_text}</Text>
</View>
)}
{/* Bottom separator */}
<View style={styles.separator}>
<View style={styles.separatorLine} />
<View style={styles.separatorDiamond} />
<View style={styles.separatorLine} />
</View>
</View>
{/* Premium Action Buttons */}
<View style={styles.footer}>
<View style={styles.buttonContainer}>
<PremiumButton
onPress={() => handleButtonPress("left")}
icon="close"
variant="no"
/>
<PremiumButton
onPress={() => handleButtonPress("up")}
icon="help"
variant="maybe"
small
/>
<PremiumButton
onPress={() => handleButtonPress("right")}
icon="heart"
variant="yes"
/>
</View>
</View>
</View>
{/* Premium border */}
<View style={styles.premiumBorder} pointerEvents="none" />
</Animated.View>
</GestureDetector>
</View>
);
}
function PremiumButton({
onPress,
icon,
variant,
small = false,
}: {
onPress: () => void;
icon: string;
variant: 'yes' | 'no' | 'maybe';
small?: boolean;
}) {
const buttonScale = useSharedValue(1);
const handlePressIn = () => {
buttonScale.value = withSpring(0.92, animations.spring);
};
const handlePressOut = () => {
buttonScale.value = withSpring(1, animations.spring);
};
const buttonAnimatedStyle = useAnimatedStyle(() => ({
transform: [{ scale: buttonScale.value }],
}));
const getColors = () => {
switch (variant) {
case 'yes': return { bg: rgba.gold(0.15), border: rgba.gold(0.3), icon: ACCENT };
case 'no': return { bg: rgba.rose(0.15), border: rgba.rose(0.3), icon: ROSE };
case 'maybe': return { bg: 'rgba(255, 255, 255, 0.08)', border: 'rgba(255, 255, 255, 0.15)', icon: colors.textSecondary };
}
};
const c = getColors();
return (
<TouchableOpacity
onPress={onPress}
onPressIn={handlePressIn}
onPressOut={handlePressOut}
activeOpacity={1}
>
<Animated.View style={[
styles.premiumButton,
small && styles.premiumButtonSmall,
{ backgroundColor: c.bg, borderColor: c.border },
buttonAnimatedStyle,
]}>
<Ionicons
name={icon as any}
size={small ? 18 : 24}
color={c.icon}
/>
</Animated.View>
</TouchableOpacity>
);
}
const styles = StyleSheet.create({
cardWrapper: {
position: "absolute",
maxWidth: MAX_CARD_WIDTH,
height: 500,
},
cardOuter: {
width: "100%",
height: "100%",
borderRadius: radius.xl,
overflow: "hidden",
},
cardBackground: {
flex: 1,
backgroundColor: '#0d0d1a',
borderRadius: radius.xl,
overflow: "hidden",
},
silkHighlight: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: 120,
},
reportButton: {
position: 'absolute',
top: spacing.md,
right: spacing.md,
zIndex: 100,
padding: spacing.sm,
opacity: 0.6,
},
premiumBorder: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
borderRadius: radius.xl,
borderWidth: 1,
borderColor: 'rgba(212, 175, 55, 0.2)', // gold @ 0.2
},
content: {
flex: 1,
justifyContent: "center",
alignItems: "center",
paddingHorizontal: spacing.xl,
},
intensityBadge: {
alignItems: 'center',
backgroundColor: 'rgba(212, 175, 55, 0.08)', // gold @ 0.08
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
borderRadius: radius.lg,
borderWidth: 1,
borderColor: 'rgba(212, 175, 55, 0.15)', // gold @ 0.15
marginBottom: spacing.md,
},
intensityLabel: {
...typography.caption2,
fontWeight: '600',
letterSpacing: 2,
color: 'rgba(212, 175, 55, 0.7)', // gold @ 0.7
marginBottom: spacing.xs,
},
intensityRow: {
flexDirection: 'row',
gap: 4,
},
separator: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
marginVertical: spacing.lg,
width: 120,
},
separatorLine: {
flex: 1,
height: 1,
backgroundColor: 'rgba(212, 175, 55, 0.2)', // gold @ 0.2
},
separatorDiamond: {
width: 6,
height: 6,
backgroundColor: ACCENT,
transform: [{ rotate: '45deg' }],
marginHorizontal: spacing.md,
opacity: 0.5,
},
questionText: {
...typography.title2,
color: colors.text,
textAlign: "center",
lineHeight: 32,
paddingHorizontal: spacing.sm,
},
partnerTextContainer: {
marginTop: spacing.lg,
width: '100%',
paddingHorizontal: spacing.sm,
},
partnerDivider: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: spacing.sm,
},
partnerDividerLine: {
flex: 1,
height: 1,
backgroundColor: rgba.rose(0.2),
},
partnerLabelBadge: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.md,
paddingVertical: spacing.xs,
backgroundColor: rgba.rose(0.1),
borderRadius: radius.full,
borderWidth: 1,
borderColor: rgba.rose(0.2),
gap: spacing.xs,
},
partnerLabelText: {
...typography.caption2,
fontWeight: '600',
letterSpacing: 1.5,
color: rgba.rose(0.8),
},
partnerQuestionText: {
...typography.subhead,
color: colors.textSecondary,
textAlign: 'center',
fontStyle: 'italic',
opacity: 0.85,
},
footer: {
paddingBottom: spacing.xl,
paddingHorizontal: spacing.lg,
},
buttonContainer: {
flexDirection: "row",
justifyContent: "center",
alignItems: "center",
gap: spacing.lg,
},
premiumButton: {
width: 60,
height: 60,
borderRadius: 30,
justifyContent: "center",
alignItems: "center",
borderWidth: 1,
},
premiumButtonSmall: {
width: 48,
height: 48,
borderRadius: 24,
},
// Overlays
overlay: {
...StyleSheet.absoluteFillObject,
justifyContent: "center",
alignItems: "center",
zIndex: 10,
borderRadius: radius.xl,
},
overlayYes: {
backgroundColor: 'rgba(212, 175, 55, 0.15)', // gold @ 0.15
},
overlayNo: {
backgroundColor: 'rgba(232, 164, 174, 0.15)', // rose @ 0.15
},
overlayMaybe: {
backgroundColor: 'rgba(255, 255, 255, 0.08)',
},
overlaySkip: {
backgroundColor: 'rgba(0, 0, 0, 0.3)',
},
overlayBadgePremium: {
paddingHorizontal: spacing.xl,
paddingVertical: spacing.md,
borderRadius: radius.full,
backgroundColor: 'rgba(212, 175, 55, 0.2)', // gold @ 0.2
borderWidth: 1,
borderColor: 'rgba(212, 175, 55, 0.4)', // gold @ 0.4
transform: [{ rotate: "-12deg" }],
},
overlayBadgeNo: {
backgroundColor: 'rgba(232, 164, 174, 0.2)', // rose @ 0.2
borderColor: 'rgba(232, 164, 174, 0.4)', // rose @ 0.4
},
overlayBadgeMaybe: {
backgroundColor: 'rgba(255, 255, 255, 0.1)',
borderColor: 'rgba(255, 255, 255, 0.2)',
},
overlayBadgeSkip: {
backgroundColor: 'rgba(255, 255, 255, 0.1)',
borderColor: 'rgba(255, 255, 255, 0.15)',
},
overlayTextPremium: {
fontSize: 28,
fontWeight: "600",
letterSpacing: 4,
color: ACCENT,
},
overlayTextNo: {
color: ROSE,
},
overlayTextMaybe: {
color: colors.textSecondary,
},
overlayTextSkip: {
color: colors.textTertiary,
fontSize: 24,
},
});
@@ -1,2 +0,0 @@
export { default as SwipeCard } from './SwipeCard';
export { default as SwipeCardPremium } from './SwipeCardPremium';
@@ -34,7 +34,7 @@ const STEPS: TutorialStep[] = [
{
key: "match",
title: "You've Got a Match!",
description: "When both you and your partner swipe YES or MAYBE on the same question, it creates a match. This means you're both interested!",
description: "When both you and your partner swipe YES on the same question, it creates a match. This means you're both interested!",
icon: "heart",
gradient: gradients.primary as [string, string],
},
@@ -185,11 +185,6 @@ export default function MatchesTutorial({ onComplete }: Props) {
<Ionicons name="heart" size={16} color={colors.text} />
<Text style={styles.badgeText}>YES + YES</Text>
</LinearGradient>
<View style={styles.badgeConnector} />
<View style={[styles.matchBadge, styles.maybeBadge]}>
<Ionicons name="heart-half" size={16} color={colors.primary} />
<Text style={[styles.badgeText, styles.maybeBadgeText]}>YES + MAYBE</Text>
</View>
</View>
</View>
)}
@@ -249,9 +244,9 @@ export default function MatchesTutorial({ onComplete }: Props) {
<Text style={styles.answerCardQuestion} numberOfLines={1}>
"Try something new together"
</Text>
<View style={[styles.answerCardBadge, styles.answerCardBadgeMaybe]}>
<Ionicons name="help" size={12} color={colors.warning} />
<Text style={[styles.answerCardBadgeText, styles.answerCardBadgeTextMaybe]}>MAYBE</Text>
<View style={[styles.answerCardBadge, styles.answerCardBadgeNo]}>
<Ionicons name="close" size={12} color={colors.error} />
<Text style={[styles.answerCardBadgeText, styles.answerCardBadgeTextNo]}>NO</Text>
</View>
</View>
</View>
@@ -384,25 +379,12 @@ const styles = StyleSheet.create({
borderRadius: radius.full,
gap: spacing.xs,
},
maybeBadge: {
backgroundColor: colors.glass.background,
borderWidth: 1,
borderColor: colors.glass.border,
},
badgeText: {
...typography.caption1,
color: colors.text,
fontWeight: "700",
letterSpacing: 0.5,
},
maybeBadgeText: {
color: colors.primary,
},
badgeConnector: {
width: 20,
height: 2,
backgroundColor: colors.glass.border,
},
chatDemo: {
marginTop: spacing.lg,
gap: spacing.sm,
@@ -508,18 +490,18 @@ const styles = StyleSheet.create({
borderRadius: radius.full,
gap: 2,
},
answerCardBadgeMaybe: {
backgroundColor: "rgba(243, 156, 18, 0.2)",
answerCardBadgeNo: {
backgroundColor: "rgba(231, 76, 60, 0.2)",
borderWidth: 1,
borderColor: colors.warning,
borderColor: colors.error,
},
answerCardBadgeText: {
...typography.caption2,
fontWeight: "700",
color: colors.text,
},
answerCardBadgeTextMaybe: {
color: colors.warning,
answerCardBadgeTextNo: {
color: colors.error,
},
answerCardEdit: {
marginLeft: spacing.sm,
@@ -1,705 +0,0 @@
import { useEffect, useState } from "react";
import { View, Text, StyleSheet, Pressable, useWindowDimensions, Platform } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { BlurView } from "expo-blur";
import { LinearGradient } from "expo-linear-gradient";
import { Ionicons } from "@expo/vector-icons";
import Animated, {
useSharedValue,
useAnimatedStyle,
withTiming,
withRepeat,
withSequence,
withDelay,
withSpring,
FadeIn,
FadeOut,
interpolate,
Easing,
runOnJS,
} from "react-native-reanimated";
import { colors, gradients, radius, shadows, blur, typography, spacing, animations } from "../../theme";
import { Events } from "../../lib/analytics";
interface Props {
onComplete: () => void;
}
interface TutorialStep {
key: string;
label: string;
description: string;
icon: string;
color: string;
gradient: [string, string];
translateX: number;
translateY: number;
isSwipe: boolean;
}
const STEPS: TutorialStep[] = [
{
key: "right",
label: "YES",
description: "Swipe right if you're interested",
icon: "heart",
color: colors.success,
gradient: gradients.success as [string, string],
translateX: 80,
translateY: 0,
isSwipe: true,
},
{
key: "left",
label: "NO",
description: "Swipe left if you're not interested",
icon: "close",
color: colors.error,
gradient: gradients.error as [string, string],
translateX: -80,
translateY: 0,
isSwipe: true,
},
{
key: "up",
label: "MAYBE",
description: "Swipe up if you're unsure",
icon: "help",
color: colors.warning,
gradient: gradients.warning as [string, string],
translateX: 0,
translateY: -80,
isSwipe: true,
},
{
key: "down",
label: "SKIP",
description: "Swipe down to skip for now",
icon: "arrow-down",
color: colors.textTertiary,
gradient: ["#6c757d", "#495057"],
translateX: 0,
translateY: 80,
isSwipe: true,
},
{
key: "feedback",
label: "FEEDBACK",
description: "Tap the flag to report issues with a question",
icon: "flag-outline",
color: colors.primary,
gradient: gradients.primary as [string, string],
translateX: 0,
translateY: 0,
isSwipe: false,
},
];
export default function SwipeTutorial({ onComplete }: Props) {
const { width: screenWidth, height: screenHeight } = useWindowDimensions();
const insets = useSafeAreaInsets();
const [currentStep, setCurrentStep] = useState(0);
const cardTranslateX = useSharedValue(0);
const cardTranslateY = useSharedValue(0);
const cardRotation = useSharedValue(0);
const cardScale = useSharedValue(1);
const overlayOpacity = useSharedValue(0);
const pulseScale = useSharedValue(1);
const handOffset = useSharedValue(0);
const currentStep_ = STEPS[currentStep];
const isSwipeStep = currentStep_.isSwipe;
// Track tutorial started on mount
useEffect(() => {
Events.tutorialStarted("swipe");
Events.tutorialStepViewed("swipe", 0, STEPS[0].key);
}, []);
// Track step changes
useEffect(() => {
if (currentStep > 0) {
Events.tutorialStepViewed("swipe", currentStep, STEPS[currentStep].key);
}
}, [currentStep]);
useEffect(() => {
// Reset animations for current step
cardTranslateX.value = 0;
cardTranslateY.value = 0;
cardRotation.value = 0;
overlayOpacity.value = 0;
handOffset.value = 0;
// Start the swipe demonstration animation
const animateSwipe = () => {
if (!isSwipeStep) {
// For non-swipe steps (feedback), just pulse the card
cardScale.value = withRepeat(
withSequence(
withTiming(1.02, { duration: 600 }),
withTiming(1, { duration: 600 })
),
-1,
true
);
return;
}
// Pulse the card first
cardScale.value = withSequence(
withTiming(1.02, { duration: 200 }),
withTiming(1, { duration: 200 })
);
// Animate hand movement
handOffset.value = withRepeat(
withSequence(
withTiming(1, { duration: 800, easing: Easing.out(Easing.cubic) }),
withDelay(300, withTiming(0, { duration: 400 }))
),
-1,
false
);
// Animate card swipe
cardTranslateX.value = withRepeat(
withSequence(
withDelay(200, withTiming(currentStep_.translateX * 0.7, {
duration: 600,
easing: Easing.out(Easing.cubic)
})),
withDelay(400, withSpring(0, animations.spring))
),
-1,
false
);
cardTranslateY.value = withRepeat(
withSequence(
withDelay(200, withTiming(currentStep_.translateY * 0.7, {
duration: 600,
easing: Easing.out(Easing.cubic)
})),
withDelay(400, withSpring(0, animations.spring))
),
-1,
false
);
// Rotate card for horizontal swipes
if (currentStep_.translateX !== 0) {
cardRotation.value = withRepeat(
withSequence(
withDelay(200, withTiming(currentStep_.translateX > 0 ? 8 : -8, {
duration: 600
})),
withDelay(400, withSpring(0, animations.spring))
),
-1,
false
);
}
// Show overlay during swipe
overlayOpacity.value = withRepeat(
withSequence(
withDelay(400, withTiming(0.8, { duration: 400 })),
withDelay(200, withTiming(0, { duration: 400 }))
),
-1,
false
);
};
// Pulse animation for the indicator
pulseScale.value = withRepeat(
withSequence(
withTiming(1.2, { duration: 800 }),
withTiming(1, { duration: 800 })
),
-1,
true
);
const timeout = setTimeout(animateSwipe, 300);
return () => clearTimeout(timeout);
}, [currentStep]);
const handleNext = () => {
if (currentStep < STEPS.length - 1) {
setCurrentStep(currentStep + 1);
} else {
Events.tutorialCompleted("swipe");
onComplete();
}
};
const handleSkip = () => {
Events.tutorialSkipped("swipe", currentStep);
onComplete();
};
const cardAnimatedStyle = useAnimatedStyle(() => ({
transform: [
{ translateX: cardTranslateX.value },
{ translateY: cardTranslateY.value },
{ rotate: `${cardRotation.value}deg` },
{ scale: cardScale.value },
],
}));
const overlayAnimatedStyle = useAnimatedStyle(() => ({
opacity: overlayOpacity.value,
}));
const pulseAnimatedStyle = useAnimatedStyle(() => ({
transform: [{ scale: pulseScale.value }],
opacity: interpolate(pulseScale.value, [1, 1.2], [0.6, 0.2]),
}));
const handAnimatedStyle = useAnimatedStyle(() => {
const progress = handOffset.value;
return {
transform: [
{ translateX: currentStep_.translateX * progress * 0.5 },
{ translateY: currentStep_.translateY * progress * 0.5 },
],
opacity: interpolate(progress, [0, 0.2, 0.8, 1], [0, 1, 1, 0]),
};
});
return (
<Animated.View
entering={FadeIn.duration(300)}
exiting={FadeOut.duration(200)}
style={styles.container}
>
<BlurView intensity={blur.heavy} tint="dark" style={styles.blur}>
<View style={[styles.content, { paddingBottom: Math.max(40, insets.bottom + (Platform.OS === 'ios' ? 100 : 80)) }]}>
{/* Skip button */}
<Pressable style={styles.skipButton} onPress={handleSkip}>
<Text style={styles.skipText}>Skip</Text>
</Pressable>
{/* Title */}
<Animated.View entering={FadeIn.delay(100).duration(400)}>
<Text style={styles.title}>How to Play</Text>
<Text style={styles.subtitle}>
Swipe to answer questions and find your matches
</Text>
</Animated.View>
{/* Demo Card Area */}
<View style={styles.demoArea}>
{/* Direction indicators around the card - only for swipe steps */}
{isSwipeStep && (
<View style={styles.indicatorsContainer}>
{STEPS.filter(s => s.isSwipe).map((dir, index) => (
<View
key={dir.key}
style={[
styles.directionIndicator,
dir.key === "right" && styles.indicatorRight,
dir.key === "left" && styles.indicatorLeft,
dir.key === "up" && styles.indicatorUp,
dir.key === "down" && styles.indicatorDown,
]}
>
{index === currentStep && (
<Animated.View style={[styles.pulseRing, pulseAnimatedStyle]}>
<View style={[styles.pulseInner, { backgroundColor: dir.color }]} />
</Animated.View>
)}
<LinearGradient
colors={index === currentStep ? dir.gradient : ['rgba(255,255,255,0.1)', 'rgba(255,255,255,0.05)'] as [string, string]}
style={[
styles.indicatorBadge,
index === currentStep && styles.indicatorActive,
]}
>
<Ionicons
name={dir.icon as any}
size={20}
color={index === currentStep ? colors.text : colors.textTertiary}
/>
</LinearGradient>
</View>
))}
</View>
)}
{/* Demo Card */}
<Animated.View style={[styles.demoCard, cardAnimatedStyle, shadows.xl]}>
<LinearGradient
colors={[colors.glass.backgroundLight, colors.glass.background]}
style={styles.demoCardInner}
>
{/* Feedback button indicator */}
<View style={[
styles.feedbackButtonDemo,
!isSwipeStep && styles.feedbackButtonHighlight
]}>
{!isSwipeStep && (
<Animated.View style={[styles.feedbackPulse, pulseAnimatedStyle]}>
<View style={[styles.pulseInner, { backgroundColor: currentStep_.color }]} />
</Animated.View>
)}
<Ionicons name="flag-outline" size={18} color={!isSwipeStep ? colors.text : colors.textTertiary} />
</View>
{/* Overlay - only for swipe steps */}
{isSwipeStep && (
<Animated.View
style={[
styles.demoOverlay,
overlayAnimatedStyle,
{ backgroundColor: `${currentStep_.color}40` }
]}
>
<View style={[styles.overlayBadge, { backgroundColor: currentStep_.color }]}>
<Text style={styles.overlayText}>{currentStep_.label}</Text>
</View>
</Animated.View>
)}
{/* Card content */}
<View style={styles.demoCardContent}>
<View style={styles.intensityDemo}>
<Ionicons name="flame" size={14} color={colors.primary} />
<Ionicons name="flame" size={14} color={colors.primary} />
</View>
<Text style={styles.demoQuestion}>
Would you try a new restaurant together?
</Text>
</View>
</LinearGradient>
{/* Hand gesture indicator - only for swipe steps */}
{isSwipeStep && (
<Animated.View style={[styles.handIndicator, handAnimatedStyle]}>
<View style={styles.handCircle}>
<Ionicons name="hand-left" size={24} color={colors.text} />
</View>
</Animated.View>
)}
</Animated.View>
</View>
{/* Current instruction */}
<Animated.View
key={currentStep}
entering={FadeIn.duration(300)}
style={styles.instructionContainer}
>
<LinearGradient
colors={currentStep_.gradient}
style={styles.instructionBadge}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
>
<Ionicons name={currentStep_.icon as any} size={24} color={colors.text} />
<Text style={styles.instructionLabel}>{currentStep_.label}</Text>
</LinearGradient>
<Text style={styles.instructionText}>{currentStep_.description}</Text>
</Animated.View>
{/* Progress dots */}
<View style={styles.dotsContainer}>
{STEPS.map((_, index) => (
<View
key={index}
style={[
styles.dot,
index === currentStep && styles.dotActive,
index < currentStep && styles.dotCompleted,
]}
/>
))}
</View>
{/* Next button */}
<Pressable onPress={handleNext} style={({ pressed }) => [
styles.nextButton,
pressed && styles.nextButtonPressed
]}>
<LinearGradient
colors={gradients.primary as [string, string]}
style={styles.nextButtonGradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
>
<Text style={styles.nextButtonText}>
{currentStep < STEPS.length - 1 ? "Next" : "Got it!"}
</Text>
<Ionicons
name={currentStep < STEPS.length - 1 ? "arrow-forward" : "checkmark"}
size={20}
color={colors.text}
/>
</LinearGradient>
</Pressable>
</View>
</BlurView>
</Animated.View>
);
}
const styles = StyleSheet.create({
container: {
...StyleSheet.absoluteFillObject,
zIndex: 1000,
},
blur: {
flex: 1,
backgroundColor: 'rgba(13, 13, 26, 0.85)',
},
content: {
flex: 1,
paddingHorizontal: spacing.lg,
paddingTop: 80,
alignItems: "center",
},
skipButton: {
position: "absolute",
top: 60,
right: spacing.lg,
paddingVertical: spacing.sm,
paddingHorizontal: spacing.md,
},
skipText: {
...typography.callout,
color: colors.textSecondary,
},
title: {
...typography.title1,
color: colors.text,
textAlign: "center",
marginBottom: spacing.sm,
},
subtitle: {
...typography.body,
color: colors.textSecondary,
textAlign: "center",
marginBottom: spacing.xl,
},
demoArea: {
flex: 1,
justifyContent: "center",
alignItems: "center",
width: "100%",
maxHeight: 350,
},
indicatorsContainer: {
position: "absolute",
width: 280,
height: 280,
},
directionIndicator: {
position: "absolute",
alignItems: "center",
justifyContent: "center",
},
indicatorRight: {
right: -20,
top: "50%",
marginTop: -24,
},
indicatorLeft: {
left: -20,
top: "50%",
marginTop: -24,
},
indicatorUp: {
top: -20,
left: "50%",
marginLeft: -24,
},
indicatorDown: {
bottom: -20,
left: "50%",
marginLeft: -24,
},
pulseRing: {
position: "absolute",
width: 64,
height: 64,
borderRadius: 32,
justifyContent: "center",
alignItems: "center",
},
pulseInner: {
width: "100%",
height: "100%",
borderRadius: 32,
},
indicatorBadge: {
width: 48,
height: 48,
borderRadius: 24,
justifyContent: "center",
alignItems: "center",
borderWidth: 1,
borderColor: colors.glass.border,
},
indicatorActive: {
borderColor: "transparent",
},
demoCard: {
width: 220,
height: 280,
borderRadius: radius.xl,
overflow: "visible",
},
demoCardInner: {
flex: 1,
borderRadius: radius.xl,
borderWidth: 1,
borderColor: colors.glass.border,
overflow: "hidden",
},
feedbackButtonDemo: {
position: 'absolute',
top: spacing.md,
right: spacing.md,
width: 36,
height: 36,
borderRadius: 18,
backgroundColor: colors.glass.background,
justifyContent: 'center',
alignItems: 'center',
zIndex: 100,
borderWidth: 1,
borderColor: colors.glass.border,
},
feedbackButtonHighlight: {
backgroundColor: colors.primary,
borderColor: colors.primary,
},
feedbackPulse: {
position: "absolute",
width: 56,
height: 56,
borderRadius: 28,
justifyContent: "center",
alignItems: "center",
},
demoOverlay: {
...StyleSheet.absoluteFillObject,
justifyContent: "center",
alignItems: "center",
zIndex: 10,
borderRadius: radius.xl,
},
overlayBadge: {
paddingHorizontal: spacing.md,
paddingVertical: spacing.xs,
borderRadius: radius.sm,
transform: [{ rotate: "-15deg" }],
},
overlayText: {
fontSize: 24,
fontWeight: "900",
color: colors.text,
letterSpacing: 2,
},
demoCardContent: {
flex: 1,
justifyContent: "center",
alignItems: "center",
padding: spacing.lg,
},
intensityDemo: {
flexDirection: "row",
backgroundColor: colors.primaryLight,
paddingHorizontal: spacing.sm,
paddingVertical: spacing.xs,
borderRadius: radius.full,
gap: 2,
marginBottom: spacing.md,
},
demoQuestion: {
...typography.headline,
color: colors.text,
textAlign: "center",
},
handIndicator: {
position: "absolute",
bottom: 40,
left: "50%",
marginLeft: -20,
},
handCircle: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: colors.primary,
justifyContent: "center",
alignItems: "center",
...shadows.md,
},
instructionContainer: {
alignItems: "center",
marginTop: spacing.xl,
marginBottom: spacing.lg,
},
instructionBadge: {
flexDirection: "row",
alignItems: "center",
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
borderRadius: radius.full,
gap: spacing.sm,
marginBottom: spacing.md,
},
instructionLabel: {
...typography.headline,
color: colors.text,
fontWeight: "700",
},
instructionText: {
...typography.body,
color: colors.textSecondary,
textAlign: "center",
},
dotsContainer: {
flexDirection: "row",
gap: spacing.sm,
marginBottom: spacing.xl,
},
dot: {
width: 8,
height: 8,
borderRadius: 4,
backgroundColor: colors.glass.border,
},
dotActive: {
backgroundColor: colors.primary,
width: 24,
},
dotCompleted: {
backgroundColor: colors.primary,
},
nextButton: {
width: "100%",
maxWidth: 280,
borderRadius: radius.lg,
overflow: "hidden",
},
nextButtonPressed: {
opacity: 0.9,
transform: [{ scale: 0.98 }],
},
nextButtonGradient: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
paddingVertical: spacing.md,
gap: spacing.sm,
},
nextButtonText: {
...typography.headline,
color: colors.text,
},
});
@@ -1,2 +1 @@
export { default as SwipeTutorial } from './SwipeTutorial';
export { default as MatchesTutorial } from './MatchesTutorial';
@@ -2,10 +2,10 @@ import React from 'react';
import { View, StyleSheet, ViewStyle, DimensionValue } from 'react-native';
import { colors, spacing } from '../../theme';
export type SeparatorVariant = 'rose' | 'gold' | 'muted';
export type SeparatorVariant = 'primary' | 'rose' | 'gold' | 'muted';
export interface DecorativeSeparatorProps {
/** Color variant: 'rose' (default), 'gold' (premium/yes-yes), 'muted' */
/** Color variant: 'primary' (default), 'rose' (quiz), 'gold' (premium/dares), 'muted' */
variant?: SeparatorVariant;
/** Width of the separator. Default: 140 */
width?: DimensionValue;
@@ -16,6 +16,10 @@ export interface DecorativeSeparatorProps {
}
const VARIANT_COLORS: Record<SeparatorVariant, { line: string; diamond: string }> = {
primary: {
line: 'rgba(225, 48, 108, 0.3)',
diamond: colors.primary,
},
rose: {
line: 'rgba(232, 164, 174, 0.3)',
diamond: colors.premium.rose,
@@ -35,7 +39,7 @@ const VARIANT_COLORS: Record<SeparatorVariant, { line: string; diamond: string }
* Used for visual separation in waiting states, headers, and empty states.
*/
export const DecorativeSeparator: React.FC<DecorativeSeparatorProps> = ({
variant = 'rose',
variant = 'primary',
width = 140,
marginVertical = spacing.lg,
style,
+52 -98
View File
@@ -1,98 +1,52 @@
import React from 'react';
import { StyleSheet, View, ViewStyle, Platform } from 'react-native';
import { BlurView } from 'expo-blur';
import { LinearGradient } from 'expo-linear-gradient';
import { colors, radius, shadows, blur } from '../../theme';
interface GlassCardProps {
children: React.ReactNode;
style?: ViewStyle;
intensity?: 'light' | 'medium' | 'heavy';
variant?: 'default' | 'elevated' | 'subtle';
noPadding?: boolean;
}
export function GlassCard({
children,
style,
intensity = 'medium',
variant = 'default',
noPadding = false,
}: GlassCardProps) {
const blurIntensity = blur[intensity];
const shadowStyle = variant === 'elevated' ? shadows.lg : variant === 'subtle' ? shadows.sm : shadows.md;
const borderColor = variant === 'elevated' ? colors.glass.borderLight : colors.glass.border;
// Use BlurView on iOS, fallback to solid background on Android for performance
const useBlur = Platform.OS === 'ios';
return (
<View style={[styles.container, shadowStyle, style]}>
{useBlur ? (
<BlurView
intensity={blurIntensity}
tint="dark"
style={[styles.blurContainer, { borderColor }]}
>
{/* Top highlight gradient */}
<LinearGradient
colors={[colors.glass.highlight, 'transparent']}
style={styles.highlight}
start={{ x: 0.5, y: 0 }}
end={{ x: 0.5, y: 1 }}
/>
<View style={[styles.content, noPadding && styles.noPadding]}>
{children}
</View>
</BlurView>
) : (
// Android fallback with solid background
<View style={[styles.fallbackContainer, { borderColor }]}>
<LinearGradient
colors={[colors.glass.highlight, 'transparent']}
style={styles.highlight}
start={{ x: 0.5, y: 0 }}
end={{ x: 0.5, y: 1 }}
/>
<View style={[styles.content, noPadding && styles.noPadding]}>
{children}
</View>
</View>
)}
</View>
);
}
const styles = StyleSheet.create({
container: {
borderRadius: radius.lg,
overflow: 'hidden',
},
blurContainer: {
overflow: 'hidden',
borderRadius: radius.lg,
borderWidth: 1,
backgroundColor: colors.glass.background,
},
fallbackContainer: {
overflow: 'hidden',
borderRadius: radius.lg,
borderWidth: 1,
backgroundColor: colors.glass.backgroundLight,
},
highlight: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: 60,
},
content: {
padding: 16,
},
noPadding: {
padding: 0,
},
});
export default GlassCard;
import React from 'react';
import { StyleSheet, View, ViewStyle } from 'react-native';
import { colors, radius, shadows } from '../../theme';
interface GlassCardProps {
children: React.ReactNode;
style?: ViewStyle;
intensity?: 'light' | 'medium' | 'heavy'; // Kept for API compatibility
variant?: 'default' | 'elevated' | 'subtle';
noPadding?: boolean;
}
export function GlassCard({
children,
style,
variant = 'default',
noPadding = false,
}: GlassCardProps) {
const shadowStyle = variant === 'elevated' ? shadows.lg : variant === 'subtle' ? shadows.sm : shadows.md;
const borderColor = variant === 'elevated' ? colors.glass.borderLight : colors.glass.border;
return (
<View style={[styles.container, shadowStyle, style]}>
<View style={[styles.flatContainer, { borderColor }]}>
<View style={[styles.content, noPadding && styles.noPadding]}>
{children}
</View>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
borderRadius: radius.lg,
overflow: 'hidden',
},
flatContainer: {
overflow: 'hidden',
borderRadius: radius.lg,
backgroundColor: colors.backgroundLight, // Use solid card color
// Removed border for flatter look
},
content: {
padding: 16,
},
noPadding: {
padding: 0,
},
});
export default GlassCard;
+117 -140
View File
@@ -1,140 +1,117 @@
import React, { useState } from 'react';
import { StyleSheet, TextInput, View, Text, TextInputProps, ViewStyle, Platform } from 'react-native';
import { BlurView } from 'expo-blur';
import Animated, {
useSharedValue,
useAnimatedStyle,
withTiming,
interpolateColor,
} from 'react-native-reanimated';
import { colors, radius, blur, animations, typography } from '../../theme';
interface GlassInputProps extends Omit<TextInputProps, 'style'> {
label?: string;
error?: string;
containerStyle?: ViewStyle;
icon?: React.ReactNode;
}
const AnimatedBlurView = Animated.createAnimatedComponent(BlurView);
const AnimatedView = Animated.View;
export function GlassInput({
label,
error,
containerStyle,
icon,
...props
}: GlassInputProps) {
const [isFocused, setIsFocused] = useState(false);
const focusAnim = useSharedValue(0);
const handleFocus = (e: any) => {
setIsFocused(true);
focusAnim.value = withTiming(1, { duration: animations.timing.fast });
props.onFocus?.(e);
};
const handleBlur = (e: any) => {
setIsFocused(false);
focusAnim.value = withTiming(0, { duration: animations.timing.fast });
props.onBlur?.(e);
};
const animatedBorderStyle = useAnimatedStyle(() => ({
borderColor: interpolateColor(
focusAnim.value,
[0, 1],
[error ? colors.error : colors.glass.border, error ? colors.error : colors.primary]
),
}));
const useBlur = Platform.OS === 'ios';
const inputContent = (
<View style={styles.inputWrapper}>
{icon && <View style={styles.icon}>{icon}</View>}
<TextInput
{...props}
style={[styles.input, icon ? styles.inputWithIcon : undefined]}
placeholderTextColor={colors.textTertiary}
onFocus={handleFocus}
onBlur={handleBlur}
selectionColor={colors.primary}
/>
</View>
);
return (
<View style={[styles.container, containerStyle]}>
{label && <Text style={styles.label}>{label}</Text>}
{useBlur ? (
<AnimatedView style={[styles.blurWrapper, animatedBorderStyle]}>
<BlurView
intensity={blur.light}
tint="dark"
style={styles.blurContainer}
>
{inputContent}
</BlurView>
</AnimatedView>
) : (
<AnimatedView style={[styles.fallbackContainer, animatedBorderStyle]}>
{inputContent}
</AnimatedView>
)}
{error && <Text style={styles.error}>{error}</Text>}
</View>
);
}
const styles = StyleSheet.create({
container: {
marginBottom: 16,
},
label: {
...typography.subhead,
color: colors.textSecondary,
marginBottom: 8,
marginLeft: 4,
},
blurWrapper: {
borderRadius: radius.md,
borderWidth: 1,
overflow: 'hidden',
},
blurContainer: {
backgroundColor: colors.glass.background,
},
fallbackContainer: {
borderRadius: radius.md,
borderWidth: 1,
backgroundColor: colors.glass.backgroundLight,
},
inputWrapper: {
flexDirection: 'row',
alignItems: 'center',
},
icon: {
paddingLeft: 16,
},
input: {
flex: 1,
padding: 16,
fontSize: 16,
color: colors.text,
},
inputWithIcon: {
paddingLeft: 12,
},
error: {
...typography.caption1,
color: colors.error,
marginTop: 6,
marginLeft: 4,
},
});
export default GlassInput;
import React, { useState } from 'react';
import { StyleSheet, TextInput, View, Text, TextInputProps, ViewStyle, Platform } from 'react-native';
import Animated, {
useSharedValue,
useAnimatedStyle,
withTiming,
interpolateColor,
} from 'react-native-reanimated';
import { colors, radius, animations, typography } from '../../theme';
interface GlassInputProps extends Omit<TextInputProps, 'style'> {
label?: string;
error?: string;
containerStyle?: ViewStyle;
icon?: React.ReactNode;
}
const AnimatedView = Animated.View;
export function GlassInput({
label,
error,
containerStyle,
icon,
...props
}: GlassInputProps) {
const [isFocused, setIsFocused] = useState(false);
const focusAnim = useSharedValue(0);
const handleFocus = (e: any) => {
setIsFocused(true);
focusAnim.value = withTiming(1, { duration: animations.timing.fast });
props.onFocus?.(e);
};
const handleBlur = (e: any) => {
setIsFocused(false);
focusAnim.value = withTiming(0, { duration: animations.timing.fast });
props.onBlur?.(e);
};
const animatedContainerStyle = useAnimatedStyle(() => ({
backgroundColor: interpolateColor(
focusAnim.value,
[0, 1],
[colors.backgroundLight, colors.backgroundLight] // Consistent flat background
),
borderColor: interpolateColor(
focusAnim.value,
[0, 1],
[error ? colors.error : colors.border, error ? colors.error : colors.primary]
),
}));
return (
<View style={[styles.container, containerStyle]}>
{label && <Text style={styles.label}>{label}</Text>}
<AnimatedView style={[styles.inputContainer, animatedContainerStyle]}>
<View style={styles.inputWrapper}>
{icon && <View style={styles.icon}>{icon}</View>}
<TextInput
{...props}
style={[styles.input, icon ? styles.inputWithIcon : undefined]}
placeholderTextColor={colors.textTertiary}
onFocus={handleFocus}
onBlur={handleBlur}
selectionColor={colors.primary}
/>
</View>
</AnimatedView>
{error && <Text style={styles.error}>{error}</Text>}
</View>
);
}
const styles = StyleSheet.create({
container: {
marginBottom: 16,
},
label: {
...typography.subhead,
color: colors.textSecondary,
marginBottom: 8,
marginLeft: 4,
},
inputContainer: {
borderRadius: radius.lg,
borderWidth: 1,
overflow: 'hidden',
},
inputWrapper: {
flexDirection: 'row',
alignItems: 'center',
},
icon: {
paddingLeft: 16,
},
input: {
flex: 1,
padding: 16,
fontSize: 16,
color: colors.text,
},
inputWithIcon: {
paddingLeft: 12,
},
error: {
...typography.caption1,
color: colors.error,
marginTop: 6,
marginLeft: 4,
},
});
export default GlassInput;
@@ -66,11 +66,18 @@ export function GlassToggle({
const backgroundColor = interpolateColor(
progress.value,
[0, 1],
['rgba(255, 255, 255, 0.1)', 'transparent'] // We use LinearGradient for active state
[colors.backgroundLight, 'transparent'] // Use backgroundLight for inactive
);
const borderColor = interpolateColor(
progress.value,
[0, 1],
[colors.border, 'transparent'] // Use standard border for inactive
);
return {
backgroundColor,
borderColor,
};
});
@@ -114,7 +121,7 @@ const styles = StyleSheet.create({
height: '100%',
borderRadius: radius.full,
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.1)',
borderColor: colors.border, // Use standard border
overflow: 'hidden',
justifyContent: 'center',
padding: PADDING,
@@ -1,8 +1,9 @@
import React, { useEffect, useState } from 'react';
import { View, Text, StyleSheet, Pressable, Platform, Image, Modal, TouchableOpacity } from 'react-native';
import { View, Text, StyleSheet, Pressable, Platform, Modal, TouchableOpacity } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient';
import { BlurView } from 'expo-blur';
import { Image } from 'expo-image';
import Animated, {
useAnimatedStyle,
useSharedValue,
@@ -126,6 +127,9 @@ export function IntensitySlider({
<Image
source={{ uri: partnerAvatar }}
style={styles.partnerAvatarSmall}
cachePolicy="memory-disk"
contentFit="cover"
transition={150}
/>
) : (
<Ionicons name="heart" size={8} color={colors.secondary} />
@@ -189,7 +193,13 @@ export function IntensitySlider({
onPress={() => setShowInfoModal(true)}
>
{partnerAvatar ? (
<Image source={{ uri: partnerAvatar }} style={styles.partnerAvatarTiny} />
<Image
source={{ uri: partnerAvatar }}
style={styles.partnerAvatarTiny}
cachePolicy="memory-disk"
contentFit="cover"
transition={150}
/>
) : (
<View style={styles.partnerIconTiny}>
<Ionicons name="person" size={10} color={colors.secondary} />
@@ -276,7 +286,13 @@ export function IntensitySlider({
<View style={styles.comparisonPerson}>
{partnerAvatar ? (
<Image source={{ uri: partnerAvatar }} style={styles.comparisonAvatarImage} />
<Image
source={{ uri: partnerAvatar }}
style={styles.comparisonAvatarImage}
cachePolicy="memory-disk"
contentFit="cover"
transition={150}
/>
) : (
<View style={[styles.comparisonAvatar, { backgroundColor: HEAT_COLORS[(partnerValue ?? 1) - 1] }]}>
<Text style={styles.comparisonEmoji}>{partnerLevel?.emoji}</Text>
@@ -388,13 +404,13 @@ const styles = StyleSheet.create({
borderRadius: 18,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(255, 255, 255, 0.05)',
backgroundColor: colors.backgroundLight, // Flat
borderWidth: 2,
borderColor: 'rgba(255, 255, 255, 0.1)',
borderColor: colors.border,
},
levelIndicatorActive: {
backgroundColor: 'rgba(233, 69, 96, 0.15)',
borderColor: 'rgba(233, 69, 96, 0.3)',
backgroundColor: colors.background, // Nested
borderColor: colors.primary,
},
levelIndicatorPartner: {
borderColor: colors.secondary,
@@ -435,15 +451,15 @@ const styles = StyleSheet.create({
color: colors.secondary,
},
currentCard: {
backgroundColor: 'rgba(255, 255, 255, 0.05)',
backgroundColor: colors.backgroundLight, // Flat
borderRadius: radius.md,
padding: spacing.md,
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.08)',
borderColor: colors.border,
},
currentCardWarning: {
borderColor: 'rgba(243, 156, 18, 0.3)',
backgroundColor: 'rgba(243, 156, 18, 0.05)',
borderColor: colors.warning,
backgroundColor: colors.background, // Use background for warning state to differentiate
},
currentHeader: {
flexDirection: 'row',
@@ -486,7 +502,7 @@ const styles = StyleSheet.create({
marginTop: spacing.sm,
paddingTop: spacing.sm,
borderTopWidth: 1,
borderTopColor: 'rgba(255, 255, 255, 0.08)',
borderTopColor: colors.border,
},
partnerAvatarTiny: {
width: 20,
@@ -509,7 +525,6 @@ const styles = StyleSheet.create({
mismatchTextWarning: {
color: colors.warning,
},
// Modal styles
modalOverlay: {
flex: 1,
backgroundColor: 'rgba(0, 0, 0, 0.7)',
@@ -522,13 +537,13 @@ const styles = StyleSheet.create({
maxWidth: 360,
borderRadius: radius.xl,
overflow: 'hidden',
backgroundColor: Platform.OS === 'ios' ? 'transparent' : colors.backgroundLight,
backgroundColor: colors.backgroundLight, // Flat
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.1)',
borderColor: colors.border,
},
modalInner: {
padding: spacing.lg,
backgroundColor: Platform.OS === 'ios' ? 'rgba(26, 26, 46, 0.85)' : 'transparent',
backgroundColor: colors.backgroundLight,
},
modalHeader: {
alignItems: 'center',
@@ -551,7 +566,7 @@ const styles = StyleSheet.create({
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(255, 255, 255, 0.05)',
backgroundColor: colors.background,
borderRadius: radius.lg,
padding: spacing.md,
marginBottom: spacing.lg,
@@ -627,12 +642,12 @@ const styles = StyleSheet.create({
color: colors.warning,
},
modalCloseButton: {
backgroundColor: colors.glass.background,
backgroundColor: colors.background,
borderRadius: radius.md,
paddingVertical: spacing.md,
alignItems: 'center',
borderWidth: 1,
borderColor: colors.glass.border,
borderColor: colors.border,
},
modalCloseText: {
...typography.subhead,