287 lines
8.2 KiB
TypeScript
287 lines
8.2 KiB
TypeScript
import React, { useEffect, useRef } from 'react';
|
|
import {
|
|
View,
|
|
Text,
|
|
TouchableOpacity,
|
|
StyleSheet,
|
|
Dimensions,
|
|
Animated,
|
|
Platform,
|
|
} from 'react-native';
|
|
import { Ionicons } from '@expo/vector-icons';
|
|
import { useCoachMarks } from '../context/CoachMarksContext';
|
|
import { useApp } from '../context/AppContext';
|
|
import { useColors } from '../constants/Colors';
|
|
|
|
const { width: SCREEN_W, height: SCREEN_H } = Dimensions.get('window');
|
|
const HIGHLIGHT_PADDING = 10;
|
|
const TOOLTIP_VERTICAL_OFFSET = 32;
|
|
|
|
export const CoachMarksOverlay: React.FC = () => {
|
|
const { isActive, currentStep, steps, layouts, next, skip } = useCoachMarks();
|
|
const { isDarkMode, colorPalette } = useApp();
|
|
const colors = useColors(isDarkMode, colorPalette);
|
|
|
|
const fadeAnim = useRef(new Animated.Value(0)).current;
|
|
const scaleAnim = useRef(new Animated.Value(0.88)).current;
|
|
const pulseAnim = useRef(new Animated.Value(1)).current;
|
|
|
|
// Fade in when tour starts or step changes
|
|
useEffect(() => {
|
|
if (isActive) {
|
|
fadeAnim.setValue(0);
|
|
scaleAnim.setValue(0.88);
|
|
Animated.parallel([
|
|
Animated.timing(fadeAnim, { toValue: 1, duration: 320, useNativeDriver: true }),
|
|
Animated.spring(scaleAnim, { toValue: 1, tension: 80, friction: 9, useNativeDriver: true }),
|
|
]).start();
|
|
|
|
// Pulse animation on highlight
|
|
Animated.loop(
|
|
Animated.sequence([
|
|
Animated.timing(pulseAnim, { toValue: 1.06, duration: 900, useNativeDriver: true }),
|
|
Animated.timing(pulseAnim, { toValue: 1, duration: 900, useNativeDriver: true }),
|
|
])
|
|
).start();
|
|
} else {
|
|
pulseAnim.stopAnimation();
|
|
}
|
|
}, [isActive, currentStep]);
|
|
|
|
if (!isActive || steps.length === 0) return null;
|
|
|
|
const step = steps[currentStep];
|
|
const layout = layouts[step.elementKey];
|
|
|
|
// Fallback wenn Element noch nicht gemessen
|
|
const highlight = layout
|
|
? {
|
|
x: layout.x - HIGHLIGHT_PADDING,
|
|
y: layout.y - HIGHLIGHT_PADDING,
|
|
w: layout.width + HIGHLIGHT_PADDING * 2,
|
|
h: layout.height + HIGHLIGHT_PADDING * 2,
|
|
r: Math.min(layout.width, layout.height) / 2 + HIGHLIGHT_PADDING,
|
|
}
|
|
: { x: SCREEN_W / 2 - 40, y: SCREEN_H / 2 - 40, w: 80, h: 80, r: 40 };
|
|
|
|
// Tooltip-Position berechnen
|
|
const tooltipW = 260;
|
|
const tooltipMaxH = 140;
|
|
let tooltipX = Math.max(12, Math.min(SCREEN_W - tooltipW - 12, highlight.x + highlight.w / 2 - tooltipW / 2));
|
|
let tooltipY: number;
|
|
const spaceBelow = SCREEN_H - (highlight.y + highlight.h);
|
|
const spaceAbove = highlight.y;
|
|
|
|
if (step.tooltipSide === 'above' || (step.tooltipSide !== 'below' && spaceAbove > spaceBelow)) {
|
|
tooltipY = highlight.y - tooltipMaxH - 24;
|
|
if (tooltipY < 60) tooltipY = highlight.y + highlight.h + 24;
|
|
} else {
|
|
tooltipY = highlight.y + highlight.h + 24;
|
|
if (tooltipY + tooltipMaxH > SCREEN_H - 60) tooltipY = highlight.y - tooltipMaxH - 24;
|
|
}
|
|
|
|
// Keep coachmark bubbles slightly higher to avoid overlap with bright focus circles.
|
|
tooltipY -= TOOLTIP_VERTICAL_OFFSET;
|
|
const minTooltipY = 24;
|
|
const maxTooltipY = SCREEN_H - tooltipMaxH - 24;
|
|
tooltipY = Math.max(minTooltipY, Math.min(maxTooltipY, tooltipY));
|
|
|
|
const arrowPointsUp = tooltipY > highlight.y;
|
|
|
|
return (
|
|
<Animated.View style={[StyleSheet.absoluteFill, styles.root, { opacity: fadeAnim }]} pointerEvents="box-none">
|
|
{/* Dark overlay — 4 Rechtecke um die Aussparung */}
|
|
{/* Oben */}
|
|
<View style={[styles.overlay, { top: 0, left: 0, right: 0, height: Math.max(0, highlight.y) }]} />
|
|
{/* Links */}
|
|
<View style={[styles.overlay, {
|
|
top: highlight.y, left: 0, width: Math.max(0, highlight.x), height: highlight.h,
|
|
}]} />
|
|
{/* Rechts */}
|
|
<View style={[styles.overlay, {
|
|
top: highlight.y,
|
|
left: highlight.x + highlight.w,
|
|
right: 0,
|
|
height: highlight.h,
|
|
}]} />
|
|
{/* Unten */}
|
|
<View style={[styles.overlay, {
|
|
top: highlight.y + highlight.h,
|
|
left: 0,
|
|
right: 0,
|
|
bottom: 0,
|
|
}]} />
|
|
|
|
{/* Pulsierender Ring um das Highlight */}
|
|
<Animated.View
|
|
style={[
|
|
styles.highlightRing,
|
|
{
|
|
left: highlight.x - 4,
|
|
top: highlight.y - 4,
|
|
width: highlight.w + 8,
|
|
height: highlight.h + 8,
|
|
borderRadius: highlight.r + 4,
|
|
borderColor: colors.primary,
|
|
transform: [{ scale: pulseAnim }],
|
|
},
|
|
]}
|
|
pointerEvents="none"
|
|
/>
|
|
|
|
{/* Tooltip-Karte */}
|
|
<Animated.View
|
|
style={[
|
|
styles.tooltip,
|
|
{
|
|
left: tooltipX,
|
|
top: tooltipY,
|
|
width: tooltipW,
|
|
backgroundColor: isDarkMode ? colors.surface : '#ffffff',
|
|
borderColor: colors.border,
|
|
shadowColor: '#000',
|
|
transform: [{ scale: scaleAnim }],
|
|
},
|
|
]}
|
|
>
|
|
{/* Arrow */}
|
|
<View
|
|
style={[
|
|
styles.arrow,
|
|
arrowPointsUp ? styles.arrowUp : styles.arrowDown,
|
|
{
|
|
left: Math.max(12, Math.min(tooltipW - 28, highlight.x + highlight.w / 2 - tooltipX - 8)),
|
|
borderBottomColor: arrowPointsUp ? (isDarkMode ? colors.surface : '#ffffff') : undefined,
|
|
borderTopColor: !arrowPointsUp ? (isDarkMode ? colors.surface : '#ffffff') : undefined,
|
|
},
|
|
]}
|
|
/>
|
|
|
|
{/* Schritt-Indikator */}
|
|
<View style={styles.stepRow}>
|
|
{steps.map((_, i) => (
|
|
<View
|
|
key={i}
|
|
style={[
|
|
styles.stepDot,
|
|
{ backgroundColor: i === currentStep ? colors.primary : colors.border },
|
|
i === currentStep && { width: 16 },
|
|
]}
|
|
/>
|
|
))}
|
|
</View>
|
|
|
|
<Text style={[styles.tooltipTitle, { color: colors.text }]}>{step.title}</Text>
|
|
<Text style={[styles.tooltipDesc, { color: colors.textSecondary }]}>{step.description}</Text>
|
|
|
|
<View style={styles.tooltipFooter}>
|
|
<TouchableOpacity onPress={skip} style={styles.skipBtn}>
|
|
<Text style={[styles.skipText, { color: colors.textMuted }]}>Überspringen</Text>
|
|
</TouchableOpacity>
|
|
<TouchableOpacity
|
|
onPress={next}
|
|
style={[styles.nextBtn, { backgroundColor: colors.primary }]}
|
|
activeOpacity={0.82}
|
|
>
|
|
<Text style={[styles.nextText, { color: colors.onPrimary }]}>
|
|
{currentStep === steps.length - 1 ? 'Fertig' : 'Weiter'}
|
|
</Text>
|
|
<Ionicons
|
|
name={currentStep === steps.length - 1 ? 'checkmark' : 'arrow-forward'}
|
|
size={14}
|
|
color={colors.onPrimary}
|
|
/>
|
|
</TouchableOpacity>
|
|
</View>
|
|
</Animated.View>
|
|
</Animated.View>
|
|
);
|
|
};
|
|
|
|
const styles = StyleSheet.create({
|
|
root: {
|
|
zIndex: 9999,
|
|
elevation: 9999,
|
|
},
|
|
overlay: {
|
|
position: 'absolute',
|
|
backgroundColor: 'rgba(0,0,0,0.72)',
|
|
},
|
|
highlightRing: {
|
|
position: 'absolute',
|
|
borderWidth: 2.5,
|
|
},
|
|
tooltip: {
|
|
position: 'absolute',
|
|
borderRadius: 18,
|
|
borderWidth: 1,
|
|
padding: 16,
|
|
gap: 8,
|
|
shadowOffset: { width: 0, height: 8 },
|
|
shadowOpacity: 0.2,
|
|
shadowRadius: 20,
|
|
elevation: 12,
|
|
},
|
|
stepRow: {
|
|
flexDirection: 'row',
|
|
gap: 4,
|
|
alignItems: 'center',
|
|
marginBottom: 2,
|
|
},
|
|
stepDot: {
|
|
width: 6,
|
|
height: 6,
|
|
borderRadius: 3,
|
|
},
|
|
tooltipTitle: {
|
|
fontSize: 15,
|
|
fontWeight: '700',
|
|
lineHeight: 20,
|
|
},
|
|
tooltipDesc: {
|
|
fontSize: 13,
|
|
lineHeight: 18,
|
|
},
|
|
tooltipFooter: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
marginTop: 4,
|
|
},
|
|
skipBtn: {
|
|
padding: 4,
|
|
},
|
|
skipText: {
|
|
fontSize: 13,
|
|
},
|
|
nextBtn: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: 6,
|
|
paddingHorizontal: 16,
|
|
paddingVertical: 9,
|
|
borderRadius: 20,
|
|
},
|
|
nextText: {
|
|
fontSize: 13,
|
|
fontWeight: '600',
|
|
},
|
|
arrow: {
|
|
position: 'absolute',
|
|
width: 0,
|
|
height: 0,
|
|
borderLeftWidth: 8,
|
|
borderRightWidth: 8,
|
|
borderLeftColor: 'transparent',
|
|
borderRightColor: 'transparent',
|
|
},
|
|
arrowUp: {
|
|
top: -8,
|
|
borderBottomWidth: 8,
|
|
},
|
|
arrowDown: {
|
|
bottom: -8,
|
|
borderTopWidth: 8,
|
|
},
|
|
});
|