Account for tab bar height (60px) when calculating maximum Y position for the draggable bubble. Previously the bubble could be dragged over the tab bar navigation, now it stays above it. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
244 lines
6.4 KiB
TypeScript
244 lines
6.4 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 TAB_BAR_HEIGHT = 60; // Tab bar content height (without safe area)
|
|
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
|
|
// Account for tab bar height + safe area to avoid overlapping navigation
|
|
const minY = insets.top + 16;
|
|
const maxY = SCREEN_HEIGHT - BUBBLE_SIZE - insets.bottom - TAB_BAR_HEIGHT - 16;
|
|
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'],
|
|
},
|
|
});
|