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
@@ -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';
|
||||
Reference in new issue
Block a user