wellnua-lite/components/VoiceFAB.tsx
Sergei 66a8395602 Add haptic feedback to VoiceFAB on press
🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

147 lines
3.4 KiB
TypeScript

/**
* Voice Floating Action Button Component
*
* A floating action button for initiating voice calls with Julia AI.
* Shows on screens where voice chat is available.
* 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;
}
const FAB_SIZE = 56;
export function VoiceFAB({ onPress, style, disabled = 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;
// 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]);
// 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,
]}
>
<TouchableOpacity
style={[styles.fab, disabled && styles.fabDisabled]}
onPress={onPress}
onPressIn={handlePressIn}
onPressOut={handlePressOut}
disabled={disabled}
activeOpacity={0.9}
>
<Ionicons
name="mic"
size={28}
color={disabled ? AppColors.textMuted : AppColors.white}
/>
</TouchableOpacity>
</Animated.View>
);
}
const styles = StyleSheet.create({
container: {
position: 'absolute',
right: 16,
zIndex: 100,
},
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,
},
fabDisabled: {
backgroundColor: AppColors.surface,
shadowOpacity: 0.1,
},
});