import { useState, useEffect } from "react"; import { View, Text, StyleSheet, Platform, TouchableOpacity } from "react-native"; import { LinearGradient } from "expo-linear-gradient"; import { createAudioPlayer, type AudioPlayer } from "expo-audio"; import Animated, { useSharedValue, useAnimatedStyle, withSpring, withRepeat, withSequence, withTiming, interpolate, Easing, FadeInDown, FadeIn, type SharedValue, } from "react-native-reanimated"; import { Ionicons } from "@expo/vector-icons"; import { colors, gradients, radius, shadows, typography, spacing, animations } from "../../theme"; import { useAudioRecorder } from "../../hooks/useAudioRecorder"; 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 QuestionCardAudioProps { question: { id: string; text: string; intensity: number; partner_text?: string | null; partner_answered?: boolean; config?: { max_duration_seconds?: number }; }; packInfo?: { name: string; icon: string; color?: string } | null; onAnswer: (answer: 'yes' | 'skip', responseData?: { type: 'audio'; media_path: string; duration_seconds: number }) => void; onReport?: () => void; } export default function QuestionCardAudio({ question, packInfo, onAnswer, onReport }: QuestionCardAudioProps) { const [showShareModal, setShowShareModal] = useState(false); const maxDuration = question.config?.max_duration_seconds ?? 60; const { state, durationSeconds, recordingUri, startRecording, stopRecording, resetRecording } = useAudioRecorder({ maxDurationSeconds: maxDuration, }); const [sound, setSound] = useState(null); const [isPlaying, setIsPlaying] = useState(false); const [playbackPosition, setPlaybackPosition] = useState(0); 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 ); }, []); // Cleanup sound on unmount useEffect(() => { return () => { if (sound) { sound.remove(); } }; }, [sound]); const animatedStyle = useAnimatedStyle(() => { const idleOffset = interpolate(idleBreathing.value, [0, 1], [0, -4]); return { transform: [{ translateY: idleOffset }], }; }); const handleStartRecording = async () => { await triggerHaptic('medium'); await startRecording(); }; const handleStopRecording = async () => { await triggerHaptic('light'); await stopRecording(); }; const handlePlayPreview = async () => { if (!recordingUri) return; await triggerHaptic('light'); if (isPlaying && sound) { sound.pause(); setIsPlaying(false); return; } if (sound) { sound.play(); setIsPlaying(true); return; } const newSound = createAudioPlayer({ uri: recordingUri }); newSound.addListener('playbackStatusUpdate', (status) => { setPlaybackPosition(status.currentTime); if (status.didJustFinish) { setIsPlaying(false); setPlaybackPosition(0); } }); newSound.play(); setSound(newSound); setIsPlaying(true); }; const handleConfirm = async () => { if (!recordingUri) return; await triggerHaptic('medium'); if (sound) { sound.remove(); setSound(null); } onAnswer('yes', { type: 'audio', media_path: recordingUri, duration_seconds: durationSeconds }); }; const handleReRecord = async () => { await triggerHaptic('light'); if (sound) { sound.remove(); setSound(null); } setIsPlaying(false); setPlaybackPosition(0); resetRecording(); }; const handleCancel = async () => { await triggerHaptic('light'); if (sound) { sound.remove(); setSound(null); } setIsPlaying(false); setPlaybackPosition(0); resetRecording(); }; const handleSkip = async () => { await triggerHaptic('light'); if (sound) { sound.remove(); setSound(null); } onAnswer('skip'); }; const renderContent = () => { if (state === 'recording') { return ( ); } if (state === 'stopped' && recordingUri) { return ( ); } return ( ); }; return ( <> {renderContent()} setShowShareModal(false)} question={question} packName={packInfo?.name} cardColor={packInfo?.color} /> ); } // State A - Ready to record function ReadyToRecordContent({ question, packInfo, maxDuration, onRecord, onSkip, onReport, onSharePress, }: { question: QuestionCardAudioProps['question']; packInfo?: { name: string; icon: string; color?: string } | null; maxDuration: number; onRecord: () => void; onSkip: () => void; onReport?: () => void; onSharePress: () => void; }) { return ( {/* Header Row */} {packInfo && ( {packInfo.name} )} {!packInfo && } {onReport && ( )} {/* Content */} {question.text} Up to {maxDuration} seconds {/* Footer */} {/* Left - No/Skip */} {/* Middle - Skip (Label) */} Skip {/* Right - Record */} ); } // State B - Recording function RecordingContent({ question, packInfo, durationSeconds, maxDuration, onStop, onReport, }: { question: QuestionCardAudioProps['question']; packInfo?: { name: string; icon: string } | null; durationSeconds: number; maxDuration: number; onStop: () => void; onReport?: () => void; }) { const pulseAnim = useSharedValue(0); useEffect(() => { pulseAnim.value = withRepeat( withSequence( withTiming(1, { duration: 500, easing: Easing.inOut(Easing.sin) }), withTiming(0, { duration: 500, easing: Easing.inOut(Easing.sin) }) ), -1, true ); }, []); const pulseStyle = useAnimatedStyle(() => ({ opacity: interpolate(pulseAnim.value, [0, 1], [0.6, 1]), transform: [{ scale: interpolate(pulseAnim.value, [0, 1], [1, 1.1]) }], })); const formatTime = (seconds: number) => { const mins = Math.floor(seconds / 60); const secs = Math.floor(seconds % 60); return `${mins}:${secs.toString().padStart(2, '0')}`; }; return ( {/* Header Row */} {packInfo && ( {packInfo.name} )} {!packInfo && } {onReport && ( )} {/* Content */} {question.text} Recording {formatTime(durationSeconds)} {formatTime(maxDuration - durationSeconds)} remaining {/* Footer */} ); } // State C - Preview function PreviewContent({ question, packInfo, durationSeconds, isPlaying, playbackPosition, onPlayPause, onConfirm, onReRecord, onCancel, onReport, }: { question: QuestionCardAudioProps['question']; packInfo?: { name: string; icon: string } | null; durationSeconds: number; isPlaying: boolean; playbackPosition: number; onPlayPause: () => void; onConfirm: () => void; onReRecord: () => void; onCancel: () => void; onReport?: () => void; }) { const formatTime = (seconds: number) => { const mins = Math.floor(seconds / 60); const secs = Math.floor(seconds % 60); return `${mins}:${secs.toString().padStart(2, '0')}`; }; return ( {/* Header Row */} {packInfo && ( {packInfo.name} )} {!packInfo && } {onReport && ( )} {/* Content */} {question.text} {formatTime(playbackPosition)} / {formatTime(durationSeconds)} {/* Footer */} Cancel Re-record ); } // Audio level visualization component function AudioLevelBars({ isActive }: { isActive: boolean }) { const barAnimations = [ useSharedValue(0.3), useSharedValue(0.3), useSharedValue(0.3), useSharedValue(0.3), useSharedValue(0.3), ]; useEffect(() => { if (isActive) { barAnimations.forEach((anim, index) => { const delay = index * 100; anim.value = withRepeat( withSequence( withTiming(Math.random() * 0.5 + 0.5, { duration: 200 + Math.random() * 200, easing: Easing.inOut(Easing.sin) }), withTiming(Math.random() * 0.3 + 0.2, { duration: 200 + Math.random() * 200, easing: Easing.inOut(Easing.sin) }) ), -1, true ); }); } else { barAnimations.forEach((anim) => { anim.value = withTiming(0.3, { duration: 300 }); }); } }, [isActive]); return ( {barAnimations.map((anim, index) => ( ))} ); } function AudioLevelBar({ anim }: { anim: SharedValue }) { const barStyle = useAnimatedStyle(() => ({ height: interpolate(anim.value, [0, 1], [8, 40]), })); return ( ); } // Record button component function RecordButton({ onPress }: { onPress: () => void }) { const buttonScale = useSharedValue(1); const buttonGlow = useSharedValue(0); const handlePressIn = () => { buttonScale.value = withSpring(0.92, animations.spring); buttonGlow.value = withTiming(1, { duration: 150 }); }; const handlePressOut = () => { buttonScale.value = withSpring(1, animations.spring); buttonGlow.value = withTiming(0, { duration: 200 }); }; const buttonAnimatedStyle = useAnimatedStyle(() => ({ transform: [{ scale: buttonScale.value }], shadowOpacity: interpolate(buttonGlow.value, [0, 1], [0.3, 0.6]), shadowRadius: interpolate(buttonGlow.value, [0, 1], [8, 16]), })); return ( ); } // Stop button component function StopButton({ onPress }: { onPress: () => void }) { const buttonScale = useSharedValue(1); const pulseAnim = useSharedValue(0); useEffect(() => { pulseAnim.value = withRepeat( withSequence( withTiming(1, { duration: 800, easing: Easing.inOut(Easing.sin) }), withTiming(0, { duration: 800, easing: Easing.inOut(Easing.sin) }) ), -1, true ); }, []); const handlePressIn = () => { buttonScale.value = withSpring(0.92, animations.spring); }; const handlePressOut = () => { buttonScale.value = withSpring(1, animations.spring); }; const buttonAnimatedStyle = useAnimatedStyle(() => ({ transform: [{ scale: buttonScale.value }], })); const glowStyle = useAnimatedStyle(() => ({ opacity: interpolate(pulseAnim.value, [0, 1], [0.3, 0.6]), transform: [{ scale: interpolate(pulseAnim.value, [0, 1], [1, 1.3]) }], })); return ( ); } // Play/Pause button component function PlayPauseButton({ isPlaying, onPress }: { isPlaying: boolean; 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 ( ); } // Confirm button component function ConfirmButton({ onPress }: { onPress: () => void }) { const buttonScale = useSharedValue(1); const buttonGlow = useSharedValue(0); const handlePressIn = () => { buttonScale.value = withSpring(0.92, animations.spring); buttonGlow.value = withTiming(1, { duration: 150 }); }; const handlePressOut = () => { buttonScale.value = withSpring(1, animations.spring); buttonGlow.value = withTiming(0, { duration: 200 }); }; const buttonAnimatedStyle = useAnimatedStyle(() => ({ transform: [{ scale: buttonScale.value }], shadowOpacity: interpolate(buttonGlow.value, [0, 1], [0.3, 0.6]), shadowRadius: interpolate(buttonGlow.value, [0, 1], [8, 16]), })); return ( ); } 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, }, waveformPlaceholder: { marginTop: spacing.xl, alignItems: 'center', gap: spacing.sm, }, durationLimitText: { ...typography.caption1, color: 'rgba(255,255,255,0.5)', }, // Footer styles footer: { paddingTop: spacing.md, transform: [{ translateY: actionButtonOffset }], }, previewFooter: { paddingTop: spacing.md, paddingBottom: spacing.lg, }, audioButtonContainer: { flexDirection: "row", justifyContent: "space-between", alignItems: "flex-end", width: '100%', paddingHorizontal: spacing.md, }, centerButtonContainer: { alignItems: "center", justifyContent: "center", paddingBottom: spacing.sm, }, 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', }, circleActionButton: { 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, }, recordButtonWrapper: { alignItems: 'center', gap: spacing.xs, }, recordButton: { width: 72, height: 72, borderRadius: 36, justifyContent: "center", alignItems: "center", overflow: "hidden", elevation: 4, shadowColor: colors.primary, shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.3, shadowRadius: 4, }, buttonIcon: { zIndex: 1, }, // Recording state styles recordingIndicatorContainer: { alignItems: 'center', gap: spacing.lg, }, recordingHeader: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm, }, recordingDot: { width: 12, height: 12, borderRadius: 6, backgroundColor: colors.error, }, recordingText: { ...typography.headline, color: colors.error, fontWeight: '600', }, recordingTimer: { ...typography.largeTitle, color: colors.text, fontWeight: 'bold', fontVariant: ['tabular-nums'], }, progressBarContainer: { width: '100%', alignItems: 'center', gap: spacing.xs, }, progressBarBackground: { width: '100%', height: 4, backgroundColor: 'rgba(255,255,255,0.2)', borderRadius: radius.full, overflow: 'hidden', }, progressBarFill: { height: '100%', backgroundColor: colors.text, borderRadius: radius.full, }, progressText: { ...typography.caption1, color: 'rgba(255,255,255,0.6)', }, stopButtonContainer: { alignItems: 'center', justifyContent: 'center', }, stopButtonGlow: { position: 'absolute', width: 100, height: 100, borderRadius: 50, backgroundColor: colors.error, }, stopButton: { width: 80, height: 80, borderRadius: 40, justifyContent: "center", alignItems: "center", overflow: "hidden", }, stopIcon: { width: 24, height: 24, borderRadius: 4, backgroundColor: colors.text, zIndex: 1, }, // Preview state styles previewContent: { flex: 1, paddingTop: spacing.sm, }, previewText: { ...typography.headline, color: colors.text, textAlign: "center", marginBottom: spacing.lg, }, playbackContainer: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: spacing.lg, }, playPauseButton: { width: 72, height: 72, borderRadius: 36, justifyContent: "center", alignItems: "center", overflow: "hidden", }, playbackWaveform: { width: '100%', paddingHorizontal: spacing.xl, }, playbackDuration: { alignItems: 'center', }, playbackTimeText: { ...typography.subhead, color: 'rgba(255,255,255,0.7)', fontVariant: ['tabular-nums'], }, audioLevelContainer: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', height: 50, gap: spacing.sm, }, audioLevelBar: { width: 6, backgroundColor: colors.text, borderRadius: radius.full, }, 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", }, });