mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18:19:43 +00:00
expo-av has no SDK 56 compatible release, and its EXEventEmitter.h interface was removed from expo-modules-core, so the iOS release archive fails compiling Pods/EXAV. Move video playback to expo-video's useVideoPlayer/VideoView, and audio playback and recording to expo-audio's createAudioPlayer/AudioModule.AudioRecorder, preserving existing controls, mute/loop/resize behavior, and the recording audio session configuration. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1117 lines
34 KiB
TypeScript
1117 lines
34 KiB
TypeScript
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<AudioPlayer | null>(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 (
|
|
<RecordingContent
|
|
question={question}
|
|
packInfo={packInfo}
|
|
durationSeconds={durationSeconds}
|
|
maxDuration={maxDuration}
|
|
onStop={handleStopRecording}
|
|
onReport={onReport}
|
|
/>
|
|
);
|
|
}
|
|
|
|
if (state === 'stopped' && recordingUri) {
|
|
return (
|
|
<PreviewContent
|
|
question={question}
|
|
packInfo={packInfo}
|
|
durationSeconds={durationSeconds}
|
|
isPlaying={isPlaying}
|
|
playbackPosition={playbackPosition}
|
|
onPlayPause={handlePlayPreview}
|
|
onConfirm={handleConfirm}
|
|
onReRecord={handleReRecord}
|
|
onCancel={handleCancel}
|
|
onReport={onReport}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<ReadyToRecordContent
|
|
question={question}
|
|
packInfo={packInfo}
|
|
maxDuration={maxDuration}
|
|
onRecord={handleStartRecording}
|
|
onSkip={handleSkip}
|
|
onReport={onReport}
|
|
onSharePress={handleSharePress}
|
|
/>
|
|
);
|
|
};
|
|
|
|
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 }
|
|
]}
|
|
>
|
|
{renderContent()}
|
|
</View>
|
|
</Animated.View>
|
|
</Animated.View>
|
|
|
|
<SharePreviewModal
|
|
visible={showShareModal}
|
|
onClose={() => 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 (
|
|
<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 style={styles.waveformPlaceholder}>
|
|
<Ionicons name="mic-outline" size={48} color="rgba(255,255,255,0.4)" />
|
|
<Text style={styles.durationLimitText}>Up to {maxDuration} seconds</Text>
|
|
</View>
|
|
</View>
|
|
|
|
{/* Footer */}
|
|
<View style={styles.footer}>
|
|
<View style={styles.audioButtonContainer}>
|
|
{/* Left - No/Skip */}
|
|
<TouchableOpacity
|
|
onPress={onSkip}
|
|
style={styles.circleActionButton}
|
|
>
|
|
<View style={[StyleSheet.absoluteFill, { backgroundColor: actionButtonBackground, borderRadius: 36 }]} />
|
|
<Ionicons name="close" size={32} color={colors.error} style={styles.buttonIcon} />
|
|
</TouchableOpacity>
|
|
|
|
{/* Middle - Skip (Label) */}
|
|
<TouchableOpacity
|
|
style={styles.skipButtonCenter}
|
|
onPress={onSkip}
|
|
activeOpacity={0.7}
|
|
>
|
|
<Text style={styles.skipText}>Skip</Text>
|
|
</TouchableOpacity>
|
|
|
|
{/* Right - Record */}
|
|
<RecordButton onPress={onRecord} />
|
|
</View>
|
|
</View>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
// 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 (
|
|
<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.content}>
|
|
<Text style={styles.previewText} numberOfLines={2}>
|
|
{question.text}
|
|
</Text>
|
|
|
|
<Animated.View
|
|
entering={FadeIn.duration(300)}
|
|
style={styles.recordingIndicatorContainer}
|
|
>
|
|
<View style={styles.recordingHeader}>
|
|
<Animated.View style={[styles.recordingDot, pulseStyle]} />
|
|
<Text style={styles.recordingText}>Recording</Text>
|
|
</View>
|
|
|
|
<Text style={styles.recordingTimer}>{formatTime(durationSeconds)}</Text>
|
|
|
|
<AudioLevelBars isActive={true} />
|
|
|
|
<View style={styles.progressBarContainer}>
|
|
<View style={styles.progressBarBackground}>
|
|
<View
|
|
style={[
|
|
styles.progressBarFill,
|
|
{ width: `${(durationSeconds / maxDuration) * 100}%` }
|
|
]}
|
|
/>
|
|
</View>
|
|
<Text style={styles.progressText}>
|
|
{formatTime(maxDuration - durationSeconds)} remaining
|
|
</Text>
|
|
</View>
|
|
</Animated.View>
|
|
</View>
|
|
|
|
{/* Footer */}
|
|
<View style={styles.footer}>
|
|
<View style={styles.centerButtonContainer}>
|
|
<StopButton onPress={onStop} />
|
|
</View>
|
|
</View>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
// 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 (
|
|
<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>
|
|
|
|
<Animated.View
|
|
entering={FadeIn.duration(300)}
|
|
style={styles.playbackContainer}
|
|
>
|
|
<PlayPauseButton isPlaying={isPlaying} onPress={onPlayPause} />
|
|
|
|
<View style={styles.playbackWaveform}>
|
|
<AudioLevelBars isActive={isPlaying} />
|
|
</View>
|
|
|
|
<View style={styles.playbackDuration}>
|
|
<Text style={styles.playbackTimeText}>
|
|
{formatTime(playbackPosition)} / {formatTime(durationSeconds)}
|
|
</Text>
|
|
</View>
|
|
</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={onReRecord}
|
|
activeOpacity={0.7}
|
|
>
|
|
<Ionicons name="refresh" size={20} color="rgba(255,255,255,0.7)" />
|
|
<Text style={styles.secondaryButtonText}>Re-record</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
</View>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
// 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 (
|
|
<View style={styles.audioLevelContainer}>
|
|
{barAnimations.map((anim, index) => (
|
|
<AudioLevelBar key={index} anim={anim} />
|
|
))}
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function AudioLevelBar({ anim }: { anim: SharedValue<number> }) {
|
|
const barStyle = useAnimatedStyle(() => ({
|
|
height: interpolate(anim.value, [0, 1], [8, 40]),
|
|
}));
|
|
|
|
return (
|
|
<Animated.View
|
|
style={[styles.audioLevelBar, barStyle]}
|
|
/>
|
|
);
|
|
}
|
|
|
|
// 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 (
|
|
<TouchableOpacity
|
|
onPress={onPress}
|
|
onPressIn={handlePressIn}
|
|
onPressOut={handlePressOut}
|
|
activeOpacity={1}
|
|
style={styles.recordButtonWrapper}
|
|
>
|
|
<Animated.View style={[
|
|
styles.recordButton,
|
|
buttonAnimatedStyle,
|
|
{ backgroundColor: actionButtonBackground }
|
|
]}>
|
|
<Ionicons
|
|
name="mic"
|
|
size={32}
|
|
color="#FFFFFF"
|
|
style={styles.buttonIcon}
|
|
/>
|
|
</Animated.View>
|
|
</TouchableOpacity>
|
|
);
|
|
}
|
|
|
|
// 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 (
|
|
<TouchableOpacity
|
|
onPress={onPress}
|
|
onPressIn={handlePressIn}
|
|
onPressOut={handlePressOut}
|
|
activeOpacity={1}
|
|
>
|
|
<View style={styles.stopButtonContainer}>
|
|
<Animated.View style={[styles.stopButtonGlow, glowStyle]} />
|
|
<Animated.View style={[styles.stopButton, buttonAnimatedStyle]}>
|
|
<LinearGradient
|
|
colors={gradients.error as [string, string]}
|
|
style={StyleSheet.absoluteFill}
|
|
start={{ x: 0, y: 0 }}
|
|
end={{ x: 1, y: 1 }}
|
|
/>
|
|
<View style={styles.stopIcon} />
|
|
</Animated.View>
|
|
</View>
|
|
</TouchableOpacity>
|
|
);
|
|
}
|
|
|
|
// 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 (
|
|
<TouchableOpacity
|
|
onPress={onPress}
|
|
onPressIn={handlePressIn}
|
|
onPressOut={handlePressOut}
|
|
activeOpacity={1}
|
|
>
|
|
<Animated.View style={[styles.playPauseButton, buttonAnimatedStyle]}>
|
|
<LinearGradient
|
|
colors={['#ffffff', '#f0f0f0']}
|
|
style={StyleSheet.absoluteFill}
|
|
start={{ x: 0, y: 0 }}
|
|
end={{ x: 1, y: 1 }}
|
|
/>
|
|
<Ionicons
|
|
name={isPlaying ? "pause" : "play"}
|
|
size={28}
|
|
color={colors.primary}
|
|
style={styles.buttonIcon}
|
|
/>
|
|
</Animated.View>
|
|
</TouchableOpacity>
|
|
);
|
|
}
|
|
|
|
// 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 (
|
|
<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,
|
|
},
|
|
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",
|
|
},
|
|
});
|