chahinebrini d7b15e231a feat(theme): Dark Mode Wave 2 — blocker, mail, chat, community, notifications, all remaining screens
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>
2026-05-09 14:51:02 +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, 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 },
});