wellnua-lite/components/FloatingCallBubble.tsx
Sergei 560722e8af fix: Tap on floating bubble ends the call
Changed tap behavior on the floating call bubble to end the call
instead of maximizing it. Removed the separate small end call button
since it's no longer needed.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-24 20:41:36 -08:00

242 lines
6.2 KiB
TypeScript

/**
* Floating Call Bubble Component
*
* Shows a floating bubble during active voice calls.
* Can be dragged around the screen.
* Tapping it ends the call.
*/
import React, { useEffect, useRef, useState } from 'react';
import {
View,
Text,
StyleSheet,
TouchableOpacity,
Animated,
PanResponder,
Dimensions,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { AppColors, FontSizes, Spacing } from '@/constants/theme';
import { useVoiceCall } from '@/contexts/VoiceCallContext';
const BUBBLE_SIZE = 70;
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get('window');
export function FloatingCallBubble() {
const { callState, endCall } = useVoiceCall();
const insets = useSafeAreaInsets();
// Animation values
const pan = useRef(new Animated.ValueXY({
x: SCREEN_WIDTH - BUBBLE_SIZE - 16,
y: insets.top + 100,
})).current;
const scale = useRef(new Animated.Value(0)).current;
const pulseAnim = useRef(new Animated.Value(1)).current;
// Local duration state (updates from context)
const [displayDuration, setDisplayDuration] = useState(callState.callDuration);
// Update display duration when context changes
useEffect(() => {
setDisplayDuration(callState.callDuration);
}, [callState.callDuration]);
// Duration timer (local increment for smooth display)
useEffect(() => {
if (callState.isActive && callState.isMinimized) {
const interval = setInterval(() => {
setDisplayDuration(prev => prev + 1);
}, 1000);
return () => clearInterval(interval);
}
}, [callState.isActive, callState.isMinimized]);
// Show/hide animation
useEffect(() => {
if (callState.isActive && callState.isMinimized) {
// Show bubble
Animated.spring(scale, {
toValue: 1,
friction: 5,
tension: 40,
useNativeDriver: true,
}).start();
} else {
// Hide bubble
Animated.timing(scale, {
toValue: 0,
duration: 200,
useNativeDriver: true,
}).start();
}
}, [callState.isActive, callState.isMinimized, scale]);
// Pulse animation
useEffect(() => {
if (callState.isActive && callState.isMinimized) {
const pulse = Animated.loop(
Animated.sequence([
Animated.timing(pulseAnim, {
toValue: 1.1,
duration: 1000,
useNativeDriver: true,
}),
Animated.timing(pulseAnim, {
toValue: 1,
duration: 1000,
useNativeDriver: true,
}),
])
);
pulse.start();
return () => pulse.stop();
}
}, [callState.isActive, callState.isMinimized, pulseAnim]);
// Pan responder for dragging
const panResponder = useRef(
PanResponder.create({
onStartShouldSetPanResponder: () => true,
onMoveShouldSetPanResponder: () => true,
onPanResponderGrant: () => {
pan.extractOffset();
},
onPanResponderMove: Animated.event([null, { dx: pan.x, dy: pan.y }], {
useNativeDriver: false,
}),
onPanResponderRelease: (_, gestureState) => {
pan.flattenOffset();
// Snap to edge
const currentX = (pan.x as any)._value;
const currentY = (pan.y as any)._value;
const snapToLeft = currentX < SCREEN_WIDTH / 2;
const targetX = snapToLeft ? 16 : SCREEN_WIDTH - BUBBLE_SIZE - 16;
// Clamp Y within screen bounds
const minY = insets.top + 16;
const maxY = SCREEN_HEIGHT - BUBBLE_SIZE - insets.bottom - 100;
const targetY = Math.max(minY, Math.min(currentY, maxY));
Animated.spring(pan, {
toValue: { x: targetX, y: targetY },
friction: 7,
useNativeDriver: false,
}).start();
},
})
).current;
// Format duration as mm:ss
const formatDuration = (seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
};
// Don't render if not showing
if (!callState.isActive || !callState.isMinimized) {
return null;
}
return (
<Animated.View
style={[
styles.container,
{
transform: [
{ translateX: pan.x },
{ translateY: pan.y },
{ scale },
],
},
]}
{...panResponder.panHandlers}
>
{/* Pulse ring */}
<Animated.View
style={[
styles.pulseRing,
{
transform: [{ scale: pulseAnim }],
},
]}
/>
{/* Main bubble - tap to end call */}
<TouchableOpacity
style={styles.bubble}
onPress={endCall}
activeOpacity={0.9}
>
<View style={styles.avatarContainer}>
<Text style={styles.avatarText}>J</Text>
</View>
<View style={styles.durationBadge}>
<Text style={styles.durationText}>{formatDuration(displayDuration)}</Text>
</View>
</TouchableOpacity>
</Animated.View>
);
}
const styles = StyleSheet.create({
container: {
position: 'absolute',
zIndex: 9999,
width: BUBBLE_SIZE,
height: BUBBLE_SIZE,
},
pulseRing: {
position: 'absolute',
width: BUBBLE_SIZE,
height: BUBBLE_SIZE,
borderRadius: BUBBLE_SIZE / 2,
backgroundColor: 'rgba(90, 200, 168, 0.3)',
},
bubble: {
width: BUBBLE_SIZE,
height: BUBBLE_SIZE,
borderRadius: BUBBLE_SIZE / 2,
backgroundColor: AppColors.success,
justifyContent: 'center',
alignItems: 'center',
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 8,
elevation: 10,
},
avatarContainer: {
width: 44,
height: 44,
borderRadius: 22,
backgroundColor: 'rgba(255, 255, 255, 0.2)',
justifyContent: 'center',
alignItems: 'center',
},
avatarText: {
fontSize: FontSizes.xl,
fontWeight: '600',
color: AppColors.white,
},
durationBadge: {
position: 'absolute',
bottom: -4,
backgroundColor: 'rgba(0, 0, 0, 0.7)',
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: 8,
},
durationText: {
fontSize: 10,
fontWeight: '600',
color: AppColors.white,
fontVariant: ['tabular-nums'],
},
});