mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18:19:43 +00:00
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:
1 parent
8612b16b93
commit
1daf386765
114 files changed
+14877
-7911
No files matched your search
@@ -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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user