Greenlens/components/CoachMarksOverlay.tsx

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,
},
});