wellnua-lite/components/FloatingCallBubble.tsx
Sergei aec300bd98 feat: Add floating bubble during voice calls
- Create VoiceCallContext for global voice call state management
- Add FloatingCallBubble component with drag support
- Add minimize button to voice call screen
- Show bubble when call is minimized, tap to return to call
- Button shows active call state with green color

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

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

264 lines
6.9 KiB
TypeScript

/**
* Floating Call Bubble Component
*
* Shows a floating bubble during active voice calls.
* Can be dragged around the screen.
* Tapping it returns to the full voice call screen.
*/
import React, { useEffect, useRef, useState } from 'react';
import {
View,
Text,
StyleSheet,
TouchableOpacity,
Animated,
PanResponder,
Dimensions,
Platform,
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
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, maximizeCall, 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 */}
<TouchableOpacity
style={styles.bubble}
onPress={maximizeCall}
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>
{/* End call button */}
<TouchableOpacity style={styles.endButton} onPress={endCall}>
<Ionicons name="call" size={14} color={AppColors.white} style={{ transform: [{ rotate: '135deg' }] }} />
</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'],
},
endButton: {
position: 'absolute',
top: -4,
right: -4,
width: 24,
height: 24,
borderRadius: 12,
backgroundColor: AppColors.error,
justifyContent: 'center',
alignItems: 'center',
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.25,
shadowRadius: 4,
elevation: 5,
},
});