wellnua-lite/components/VoiceFAB.tsx
Sergei dbf6a8a74a Add red pulsing animation to VoiceFAB when listening
- Add pulse ring that expands and fades out while in listening mode
- Animation uses native driver for smooth 60fps performance
- Ring starts at FAB size and scales to 1.8x with opacity fade

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-27 16:31:19 -08:00

223 lines
5.4 KiB
TypeScript

/**
* Voice Floating Action Button Component
*
* A floating action button for toggling voice listening mode.
* Tap to start/stop listening.
* Hidden when a call is already active.
*/
import React, { useRef, useEffect } from 'react';
import {
StyleSheet,
TouchableOpacity,
Animated,
ViewStyle,
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import * as Haptics from 'expo-haptics';
import { AppColors, BorderRadius } from '@/constants/theme';
import { useVoiceCall } from '@/contexts/VoiceCallContext';
interface VoiceFABProps {
onPress: () => void;
style?: ViewStyle;
disabled?: boolean;
isListening?: boolean;
}
const FAB_SIZE = 56;
export function VoiceFAB({ onPress, style, disabled = false, isListening = false }: VoiceFABProps) {
const { isCallActive } = useVoiceCall();
const insets = useSafeAreaInsets();
// Animation values
const scale = useRef(new Animated.Value(1)).current;
const opacity = useRef(new Animated.Value(1)).current;
const pulseScale = useRef(new Animated.Value(1)).current;
const pulseOpacity = useRef(new Animated.Value(0)).current;
// Hide FAB when call is active
useEffect(() => {
if (isCallActive) {
Animated.parallel([
Animated.timing(scale, {
toValue: 0,
duration: 200,
useNativeDriver: true,
}),
Animated.timing(opacity, {
toValue: 0,
duration: 200,
useNativeDriver: true,
}),
]).start();
} else {
Animated.parallel([
Animated.spring(scale, {
toValue: 1,
friction: 5,
tension: 40,
useNativeDriver: true,
}),
Animated.timing(opacity, {
toValue: 1,
duration: 200,
useNativeDriver: true,
}),
]).start();
}
}, [isCallActive, scale, opacity]);
// Pulse animation when listening
useEffect(() => {
if (isListening && !isCallActive) {
// Start pulsing animation
const pulseAnimation = Animated.loop(
Animated.sequence([
Animated.parallel([
Animated.timing(pulseScale, {
toValue: 1.8,
duration: 1000,
useNativeDriver: true,
}),
Animated.timing(pulseOpacity, {
toValue: 0,
duration: 1000,
useNativeDriver: true,
}),
]),
Animated.parallel([
Animated.timing(pulseScale, {
toValue: 1,
duration: 0,
useNativeDriver: true,
}),
Animated.timing(pulseOpacity, {
toValue: 0.6,
duration: 0,
useNativeDriver: true,
}),
]),
])
);
pulseAnimation.start();
return () => {
pulseAnimation.stop();
pulseScale.setValue(1);
pulseOpacity.setValue(0);
};
} else {
pulseScale.setValue(1);
pulseOpacity.setValue(0);
}
}, [isListening, isCallActive, pulseScale, pulseOpacity]);
// Press animation with haptic feedback
const handlePressIn = () => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
Animated.spring(scale, {
toValue: 0.9,
friction: 5,
useNativeDriver: true,
}).start();
};
const handlePressOut = () => {
Animated.spring(scale, {
toValue: 1,
friction: 5,
useNativeDriver: true,
}).start();
};
// Don't render if call is active
if (isCallActive) {
return null;
}
return (
<Animated.View
style={[
styles.container,
{
bottom: insets.bottom + 80, // Above tab bar
transform: [{ scale }],
opacity,
},
style,
]}
>
{/* Pulse ring when listening */}
{isListening && (
<Animated.View
style={[
styles.pulseRing,
{
transform: [{ scale: pulseScale }],
opacity: pulseOpacity,
},
]}
/>
)}
<TouchableOpacity
style={[
styles.fab,
isListening && styles.fabListening,
disabled && styles.fabDisabled,
]}
onPress={onPress}
onPressIn={handlePressIn}
onPressOut={handlePressOut}
disabled={disabled}
activeOpacity={0.9}
>
<Ionicons
name={isListening ? 'mic' : 'mic-outline'}
size={28}
color={disabled ? AppColors.textMuted : AppColors.white}
/>
</TouchableOpacity>
</Animated.View>
);
}
const styles = StyleSheet.create({
container: {
position: 'absolute',
left: 0,
right: 0,
alignItems: 'center',
zIndex: 100,
},
pulseRing: {
position: 'absolute',
width: FAB_SIZE,
height: FAB_SIZE,
borderRadius: BorderRadius.full,
backgroundColor: AppColors.error,
},
fab: {
width: FAB_SIZE,
height: FAB_SIZE,
borderRadius: BorderRadius.full,
backgroundColor: AppColors.success,
justifyContent: 'center',
alignItems: 'center',
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 8,
elevation: 8,
},
fabListening: {
backgroundColor: AppColors.error,
},
fabDisabled: {
backgroundColor: AppColors.surface,
shadowOpacity: 0.1,
},
});