/** * VoiceIndicator - Animated visual feedback for voice conversation * Shows pulsing circles when listening or speaking */ import React, { useEffect, useRef } from 'react'; import { View, StyleSheet, Animated, Text, TouchableOpacity } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { AppColors, BorderRadius, FontSizes, Spacing } from '@/constants/theme'; interface VoiceIndicatorProps { mode: 'listening' | 'speaking' | 'idle'; onTap?: (currentMode: 'listening' | 'speaking') => void; } export function VoiceIndicator({ mode, onTap }: VoiceIndicatorProps) { // Animation values for 3 concentric circles const ring1Scale = useRef(new Animated.Value(1)).current; const ring2Scale = useRef(new Animated.Value(1)).current; const ring3Scale = useRef(new Animated.Value(1)).current; const ring1Opacity = useRef(new Animated.Value(0.8)).current; const ring2Opacity = useRef(new Animated.Value(0.6)).current; const ring3Opacity = useRef(new Animated.Value(0.4)).current; // Inner circle pulse const innerPulse = useRef(new Animated.Value(1)).current; useEffect(() => { if (mode === 'idle') { // Reset all animations ring1Scale.setValue(1); ring2Scale.setValue(1); ring3Scale.setValue(1); ring1Opacity.setValue(0); ring2Opacity.setValue(0); ring3Opacity.setValue(0); innerPulse.setValue(1); return; } // Create pulsing animation for rings const createRingAnimation = ( scale: Animated.Value, opacity: Animated.Value, delay: number ) => { return Animated.loop( Animated.sequence([ Animated.delay(delay), Animated.parallel([ Animated.timing(scale, { toValue: 2.5, duration: 1500, useNativeDriver: true, }), Animated.timing(opacity, { toValue: 0, duration: 1500, useNativeDriver: true, }), ]), Animated.parallel([ Animated.timing(scale, { toValue: 1, duration: 0, useNativeDriver: true, }), Animated.timing(opacity, { toValue: mode === 'listening' ? 0.8 : 0.6, duration: 0, useNativeDriver: true, }), ]), ]) ); }; // Inner pulse animation const innerPulseAnimation = Animated.loop( Animated.sequence([ Animated.timing(innerPulse, { toValue: 1.15, duration: 400, useNativeDriver: true, }), Animated.timing(innerPulse, { toValue: 1, duration: 400, useNativeDriver: true, }), ]) ); // Reset opacity values ring1Opacity.setValue(mode === 'listening' ? 0.8 : 0.6); ring2Opacity.setValue(mode === 'listening' ? 0.6 : 0.4); ring3Opacity.setValue(mode === 'listening' ? 0.4 : 0.3); // Start all animations const anim1 = createRingAnimation(ring1Scale, ring1Opacity, 0); const anim2 = createRingAnimation(ring2Scale, ring2Opacity, 500); const anim3 = createRingAnimation(ring3Scale, ring3Opacity, 1000); anim1.start(); anim2.start(); anim3.start(); innerPulseAnimation.start(); return () => { anim1.stop(); anim2.stop(); anim3.stop(); innerPulseAnimation.stop(); }; }, [mode]); if (mode === 'idle') { return null; } const isListening = mode === 'listening'; const primaryColor = isListening ? AppColors.primary : '#4CAF50'; const secondaryColor = isListening ? '#2196F3' : '#66BB6A'; // Handle tap anywhere on the indicator const handlePress = () => { if (mode !== 'idle') { onTap?.(mode as 'listening' | 'speaking'); } }; return ( {/* Animated rings */} {/* Inner pulsing circle with icon */} {/* Status text */} {isListening ? 'Listening...' : 'Speaking...'} {/* Tap hint - shows what will happen when tapped */} {isListening ? 'Tap to cancel' : 'Tap to interrupt & speak'} ); } const styles = StyleSheet.create({ container: { alignItems: 'center', justifyContent: 'center', paddingVertical: Spacing.xl, backgroundColor: 'rgba(255, 255, 255, 0.95)', borderRadius: BorderRadius.lg, marginHorizontal: Spacing.md, marginBottom: Spacing.md, shadowColor: '#000', shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.1, shadowRadius: 8, elevation: 4, }, ringsContainer: { width: 120, height: 120, alignItems: 'center', justifyContent: 'center', }, ring: { position: 'absolute', width: 60, height: 60, borderRadius: 30, }, innerCircle: { width: 70, height: 70, borderRadius: 35, alignItems: 'center', justifyContent: 'center', shadowColor: '#000', shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.3, shadowRadius: 6, elevation: 8, }, statusText: { fontSize: FontSizes.lg, fontWeight: '600', marginTop: Spacing.md, }, hintContainer: { flexDirection: 'row', alignItems: 'center', marginTop: Spacing.md, paddingHorizontal: Spacing.md, paddingVertical: Spacing.sm, backgroundColor: 'rgba(244, 67, 54, 0.1)', borderRadius: BorderRadius.full, }, hintContainerSpeak: { backgroundColor: 'rgba(33, 150, 243, 0.1)', }, hintText: { marginLeft: Spacing.xs, fontSize: FontSizes.sm, color: AppColors.error, fontWeight: '500', }, hintTextSpeak: { color: AppColors.primary, }, }); export default VoiceIndicator;