chahinebrini 14452b2a46 refactor(native): Pressable → TouchableOpacity sweep (style-fn swallows Android styles)
Alle <Pressable style={({pressed}) => ({...})}> ersetzt — style-Funktion
droppt auf Android (New Arch) intermittierend width/height, führt zu 0×0
unsichtbaren Elementen. TouchableOpacity mit activeOpacity ist stabil.

Außerdem übrige Pressables (plain style) aus components/ und app/
migriert sowie zwei überschüssige </View>-Tags in chat.tsx + RoomCard.tsx
entfernt die TS-Fehler verursacht haben.

64 Dateien, typecheck sauber.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 15:43:10 +02:00

148 lines
7.0 KiB
TypeScript

// 4-7-8 Atemübung: Card (in-chat) + Drawer (bottom sheet).
import { useEffect, useRef, useState } from 'react';
import { View, Text, TouchableOpacity, Animated, StyleSheet } from 'react-native';
import { BREATH_PHASES, TOTAL_ROUNDS, type BreathState } from '../../lib/sosConstants';
import { useColors } from '../../lib/theme';
type Props = { onDone: () => void; onSpeak?: (text: string) => Promise<void> | void };
export function BreathingCard({ onDone, onSpeak }: Props) {
const colors = useColors();
const [breathState, setBreathState] = useState<BreathState>('idle');
const [countdown, setCountdown] = useState(3);
const [round, setRound] = useState(1);
const [phaseIndex, setPhaseIndex] = useState(0);
const [count, setCount] = useState(BREATH_PHASES[0]!.duration);
const pulse = useRef(new Animated.Value(1)).current;
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const animRef = useRef<Animated.CompositeAnimation | null>(null);
const currentPhase = BREATH_PHASES[phaseIndex]!;
function runPulse(target: number, seconds: number) {
animRef.current?.stop();
animRef.current = Animated.timing(pulse, { toValue: target, duration: seconds * 1000, useNativeDriver: true });
animRef.current.start();
}
// Countdown: visually 3 → 2 → 1 → 0 ("Los!"), then transition to active
useEffect(() => {
if (breathState !== 'countdown') return;
if (countdown > 0) {
const t = setTimeout(() => setCountdown((c) => c - 1), 1000);
return () => clearTimeout(t);
} else {
// Kein "Los!" sprechen — würde laufende Lyra-Antwort abbrechen
const t = setTimeout(() => setBreathState('active'), 500);
return () => clearTimeout(t);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [breathState, countdown]);
// Breathing phases with TTS guidance
useEffect(() => {
if (breathState !== 'active') return;
let remaining = currentPhase.duration;
setCount(remaining);
runPulse(currentPhase.phase === 'exhale' ? 1 : 1.22, currentPhase.duration);
// Speak only if speakLine defined — short words avoid overlap (inhale=4s, exhale=8s)
let speakTimer: ReturnType<typeof setTimeout> | null = null;
if (currentPhase.speakLine) {
speakTimer = setTimeout(() => onSpeak?.(currentPhase.speakLine!), 350);
}
timerRef.current = setInterval(() => {
remaining -= 1;
setCount(remaining);
if (remaining <= 0) {
clearInterval(timerRef.current!);
const next = phaseIndex + 1;
if (next >= BREATH_PHASES.length) {
if (round >= TOTAL_ROUNDS) {
// Lob ZUERST komplett ausspielen, DANN onDone (das triggert Lyras nächste Frage)
(async () => {
try { await onSpeak?.('Sehr gut! Du hast alle drei Runden geschafft. Wunderbar gemacht!'); } catch {}
onDone();
})();
return;
}
// Nächste Runde still starten
setRound((r) => r + 1);
setPhaseIndex(0);
} else {
setPhaseIndex(next);
}
}
}, 1000);
return () => {
if (timerRef.current) clearInterval(timerRef.current);
if (speakTimer) clearTimeout(speakTimer);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [breathState, phaseIndex, round]);
return (
<View style={st.breathCardInner}>
{breathState === 'idle' ? (
<View style={{ alignItems: 'center', gap: 16 }}>
<Text style={st.breathTitle}>4-7-8 Atemübung</Text>
<Text style={st.breathSub}>3 Runden · beruhigt dein Nervensystem</Text>
<TouchableOpacity activeOpacity={0.7} style={[st.breathStartBtn, { backgroundColor: colors.brandOrange }]} onPress={() => { setCountdown(3); setBreathState('countdown'); }}>
<Text style={st.breathStartTxt}>Starten</Text>
</TouchableOpacity>
</View>
) : breathState === 'countdown' ? (
<View style={{ alignItems: 'center', gap: 20 }}>
<Text style={st.breathSub}>Gleich geht's los...</Text>
<View style={[st.breathCircleLg, { borderColor: '#6366f1', backgroundColor: '#6366f118' }]}>
<Text style={st.breathCountLg}>{countdown > 0 ? countdown : ''}</Text>
</View>
</View>
) : (
<View style={{ alignItems: 'center', gap: 20 }}>
<Text style={st.breathRound}>Runde {round} / {TOTAL_ROUNDS}</Text>
<Animated.View style={{ transform: [{ scale: pulse }] }}>
<View style={[st.breathCircleLg, { borderColor: currentPhase.color, backgroundColor: currentPhase.color + '22' }]}>
<Text style={st.breathCountLg}>{count}</Text>
<Text style={[st.breathPhaseLabel, { color: currentPhase.color, fontSize: 18, marginTop: 4 }]}>{currentPhase.label}</Text>
</View>
</Animated.View>
</View>
)}
</View>
);
}
// ── BreathingDrawer (bottom sheet, covers input, slides up) ───────────────────
export function BreathingDrawer({ onDone, onSpeak }: Props) {
const colors = useColors();
const slideAnim = useRef(new Animated.Value(500)).current;
useEffect(() => {
Animated.spring(slideAnim, { toValue: 0, useNativeDriver: true, damping: 22, mass: 1, stiffness: 200 }).start();
}, []);
return (
<>
<View style={st.breathBackdrop} pointerEvents="none" />
<Animated.View style={[st.breathDrawerContainer, { transform: [{ translateY: slideAnim }], backgroundColor: colors.bg }]}>
<View style={st.breathDrawerHandle} />
<BreathingCard onDone={onDone} onSpeak={onSpeak} />
</Animated.View>
</>
);
}
const st = StyleSheet.create({
breathBackdrop: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: 'rgba(0,0,0,0.28)', zIndex: 20 },
breathDrawerContainer: { position: 'absolute', bottom: 0, left: 0, right: 0, zIndex: 21, borderTopLeftRadius: 28, borderTopRightRadius: 28, paddingBottom: 36, shadowColor: '#000', shadowOffset: { width: 0, height: -4 }, shadowOpacity: 0.18, shadowRadius: 20, elevation: 24 },
breathDrawerHandle: { width: 40, height: 4, borderRadius: 2, backgroundColor: '#d1d5db', alignSelf: 'center', marginTop: 14, marginBottom: 4 },
breathCardInner: { paddingHorizontal: 24, paddingTop: 20, paddingBottom: 8, alignItems: 'center', gap: 16 },
breathCircleLg: { width: 190, height: 190, borderRadius: 95, alignItems: 'center', justifyContent: 'center', borderWidth: 5 },
breathCountLg: { fontFamily: 'Nunito_800ExtraBold', fontSize: 60, color: '#111827', lineHeight: 68 },
breathTitle: { fontFamily: 'Nunito_700Bold', fontSize: 15, color: '#111827' },
breathSub: { fontFamily: 'Nunito_400Regular', fontSize: 13, color: '#6b7280', textAlign: 'center' },
breathStartBtn: { borderRadius: 12, paddingHorizontal: 28, paddingVertical: 10, marginTop: 4 },
breathStartTxt: { color: '#fff', fontFamily: 'Nunito_700Bold', fontSize: 14 },
breathRound: { fontFamily: 'Nunito_600SemiBold', fontSize: 12, color: '#9ca3af' },
breathPhaseLabel: { fontFamily: 'Nunito_700Bold', fontSize: 13 },
});