FAQ-Bug-Fix + Component-Extraction:
- DoneSlide nutzte qkey.replace('q','a') → 'faq_q1'.replace('q','a')='faa_q1'
weil .replace nur das ERSTE q matched (in "fa**q**"), nicht das in "q1".
→ Antworten resolved gegen non-existent key, raw key gerendert.
- Fix: explizite ID-Array [1,2,4,5,8] mit `help.faq_q\${id}` / `help.faq_a\${id}`.
- Shared FaqAccordion-Component extrahiert (components/FaqAccordion.tsx)
mit 2 Varianten: 'card' (help/faq.tsx) + 'pills' (DoneSlide inline).
- app/help/faq.tsx + DoneSlide nutzen jetzt beide den shared component.
ScreenshotPointer-Alignment für iOS Screen-Time-Permission:
- iOS Family-Controls-Dialog: "Continue/Continuer/Fortfahren" ist LINKS-grau,
"Don't Allow" ist RECHTS-blau (Apple platziert decline prominent, accept
zurückhaltend bei Screen-Time-Permission). Pointer muss daher nach LINKS,
nicht zentriert wie beim NEFilter-Dialog.
- ScreenshotPointer: neuer alignment-Prop ('left'|'center'|'right') →
translateX (-80|0|+80 dp).
- ProtectionSlide iOS Phase B: pointerAlignment="left" durchgereicht.
- Phase A (url_filter) + alle Android-Phasen bleiben center.
Release-Prep (zied):
- CHANGELOG.md v0.3.0-Block erweitert (TTS, Stripe-Pricing, Keyboard-Fix,
Single-Banner, FAQ-Extraktion, i18n-Status, Backend-Pending-Migration).
- version 0.3.0 + buildNumber 10 + versionCode 10 schon vorher gesetzt.
- eas.json production-Profil ready; Android-serviceAccountKeyPath bleibt
TODO (User-Action: Google-Cloud-Service-Account anlegen).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
128 lines
4.5 KiB
TypeScript
128 lines
4.5 KiB
TypeScript
import { useEffect, useRef } from 'react';
|
|
import { Animated, Easing, Text, View } from 'react-native';
|
|
import { Ionicons } from '@expo/vector-icons';
|
|
import { useColors } from '../../lib/theme';
|
|
|
|
/**
|
|
* "Tap-Here"-Indicator UNTER einem Screenshot.
|
|
*
|
|
* Vorher: absolut-positionierter Pulse-Ring INNERHALB des Screenshots
|
|
* → musste per-locale fine-tuned werden weil Apple-Dialog-Dimensionen
|
|
* pro Sprache variieren (DE-Text länger als EN, Modal höher, Button
|
|
* rutscht runter…).
|
|
*
|
|
* Jetzt: render-unter dem Screenshot. Layout-agnostic. Animation lenkt
|
|
* Aufmerksamkeit auf die richtige Region ohne pixel-genaue Position.
|
|
*
|
|
* ┌──────────────────────────┐
|
|
* │ <iOS Dialog Screenshot> │
|
|
* │ [Nicht erlauben] │
|
|
* │ [Erlauben] │
|
|
* └──────────────────────────┘
|
|
* ▲
|
|
* ┌─────────────┐
|
|
* │ ▲ Tippe │ ← Animated bouncing pill mit Pfeil + Label
|
|
* │ "Erlauben" │
|
|
* └─────────────┘
|
|
*/
|
|
export function ScreenshotPointer({
|
|
buttonLabel,
|
|
alignment = 'center',
|
|
}: {
|
|
/** Der Text auf dem korrekten Button im iOS-Dialog. Wird ins Label übernommen. */
|
|
buttonLabel: string;
|
|
/**
|
|
* Horizontale Position des Pointer-Pakets relativ zum Screenshot.
|
|
* 'center' = unter zentrierten Buttons (z.B. iOS NEFilter "Erlauben" unten).
|
|
* 'left' = unter links-positioniertem Button (z.B. iOS Family Controls
|
|
* "Fortfahren"/"Continuer" — Apple platziert die Zustimmung
|
|
* links-grau, Decline rechts-blau bei Screen-Time-Permission).
|
|
* 'right' = unter rechts-positioniertem Button (symmetrisch).
|
|
*/
|
|
alignment?: 'left' | 'center' | 'right';
|
|
}) {
|
|
const colors = useColors();
|
|
// Fixed Offset in dp — auf einem 390pt-iPhone shiftet das ~80pt vom Mittel-
|
|
// punkt weg, das matched die Button-Position innerhalb des ~80%-breiten
|
|
// System-Dialog-Modals gut genug ohne pixel-genaue Kalibrierung.
|
|
const offsetX = alignment === 'left' ? -80 : alignment === 'right' ? 80 : 0;
|
|
const bounce = useRef(new Animated.Value(0)).current;
|
|
const pulse = useRef(new Animated.Value(0)).current;
|
|
|
|
useEffect(() => {
|
|
const bounceLoop = Animated.loop(
|
|
Animated.sequence([
|
|
Animated.timing(bounce, {
|
|
toValue: 1,
|
|
duration: 600,
|
|
useNativeDriver: true,
|
|
easing: Easing.out(Easing.cubic),
|
|
}),
|
|
Animated.timing(bounce, {
|
|
toValue: 0,
|
|
duration: 600,
|
|
useNativeDriver: true,
|
|
easing: Easing.in(Easing.cubic),
|
|
}),
|
|
]),
|
|
);
|
|
const pulseLoop = Animated.loop(
|
|
Animated.sequence([
|
|
Animated.timing(pulse, { toValue: 1, duration: 900, useNativeDriver: true }),
|
|
Animated.timing(pulse, { toValue: 0, duration: 900, useNativeDriver: true }),
|
|
]),
|
|
);
|
|
bounceLoop.start();
|
|
pulseLoop.start();
|
|
return () => {
|
|
bounceLoop.stop();
|
|
pulseLoop.stop();
|
|
};
|
|
}, [bounce, pulse]);
|
|
|
|
const translateY = bounce.interpolate({ inputRange: [0, 1], outputRange: [0, -6] });
|
|
const arrowOpacity = pulse.interpolate({ inputRange: [0, 1], outputRange: [0.6, 1] });
|
|
const arrowScale = pulse.interpolate({ inputRange: [0, 1], outputRange: [1, 1.12] });
|
|
|
|
return (
|
|
<View style={{ alignItems: 'center', marginTop: 8 }}>
|
|
{/* Animated Up-Arrow zeigt auf den Screenshot (auf dessen relevanten Button) */}
|
|
<Animated.View
|
|
style={{
|
|
opacity: arrowOpacity,
|
|
transform: [{ translateX: offsetX }, { translateY }, { scale: arrowScale }],
|
|
}}
|
|
>
|
|
<Ionicons name="caret-up" size={28} color={colors.brandOrange} />
|
|
</Animated.View>
|
|
|
|
{/* Label-Pille */}
|
|
<Animated.View
|
|
style={{
|
|
marginTop: 4,
|
|
backgroundColor: colors.brandOrange,
|
|
borderRadius: 999,
|
|
paddingVertical: 8,
|
|
paddingHorizontal: 16,
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: 8,
|
|
transform: [{ translateX: offsetX }, { translateY }],
|
|
}}
|
|
>
|
|
<Text style={{ fontSize: 16 }}>👆</Text>
|
|
<Text
|
|
style={{
|
|
fontFamily: 'Nunito_700Bold',
|
|
fontSize: 14,
|
|
color: '#ffffff',
|
|
letterSpacing: 0.2,
|
|
}}
|
|
>
|
|
{buttonLabel}
|
|
</Text>
|
|
</Animated.View>
|
|
</View>
|
|
);
|
|
}
|