Wave 2 = ALLE app-files die in Wave 1 noch hardcoded waren. Komplette App-weit
theme-aware-Migration jetzt durch. Legacy `import { colors }` flat export
vollständig eliminiert.
Migrated this wave:
Top-level Screens:
- app/urge.tsx (makeStyles factory mit ~20 colors)
- app/room.tsx + dm.tsx + games.tsx
- app/(app)/chat.tsx + mail.tsx + coach.tsx + notifications.tsx
- app/profile/[userId].tsx + profile/edit.tsx (INPUT_STYLE in body moved)
- app/debug.tsx + auth/callback.tsx
Blocker (7):
- AddDomainSheet, CooldownBanner, DeactivationExplainerSheet, DomainGrid,
ProtectionCard, ProtectionDetailsSheet, ProtectionLockedCard
Mail (3):
- ConnectMailSheet, EditMailAccountSheet, MailEmptyState
Chat (1):
- ChatBubble, ChatInput
Community/Posts/Notifications:
- PostCard, PostCardSkeleton, ComposeCard, PostCommentsSheet
- NotificationsDropdown
- StreakBadge (Nativewind classes durch inline dynamic styles ersetzt)
Reusable Sheets:
- WheelPickerModal, OptionsBottomSheet, DeviceLimitReachedSheet
Urge subsystem (5):
- InlineRatingDrawer, ShareSuccessDrawer, UrgeStats, SosFeedbackModal,
Breathing
Profile components:
- DigaMissionBanner
Pattern: useColors() hook in component body, makeStyles(colors) factory wo
StyleSheet.create vorher hardcoded war. 11 base-tokens (bg/surface/
surfaceElevated/border/text/textMuted/brandOrange/brandBlue/success/error/
warning) nutzen colors.light vs colors.dark scheme.
Bewusst NICHT migriert (semantic colors):
- DigaMissionBanner amber (#fffbeb, #854d0e) — DiGA-brand, nicht neutral
- Lyra-thinking #3b82f6 in urge.tsx — Lyra-brand-color
- scrollDownBtn #374151 — intentional dark floating-button
TS clean. Test: Settings → Theme → Dark — alle screens sollen jetzt dunkel
werden ohne white-flashes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
148 lines
7.0 KiB
TypeScript
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, Pressable, 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>
|
|
<Pressable style={[st.breathStartBtn, { backgroundColor: colors.brandOrange }]} onPress={() => { setCountdown(3); setBreathState('countdown'); }}>
|
|
<Text style={st.breathStartTxt}>Starten</Text>
|
|
</Pressable>
|
|
</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 },
|
|
});
|