Add question types, nudge feature, and share modal

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

No files matched your search

@@ -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';