diff --git a/apps/rebreak-native/app/_layout.tsx b/apps/rebreak-native/app/_layout.tsx index 951f71c..6768932 100644 --- a/apps/rebreak-native/app/_layout.tsx +++ b/apps/rebreak-native/app/_layout.tsx @@ -25,6 +25,7 @@ import { useRealtimeDebugStore } from '../stores/realtimeDebug'; import { useColors } from '../lib/theme'; import { useLanguageStore } from '../stores/language'; import { useAppLockStore } from '../stores/appLock'; +import { useLyraVoiceStore } from '../stores/lyraVoice'; import { BrandSplash } from '../components/BrandSplash'; import { AppLockGate } from '../components/AppLockGate'; import { DeviceLimitReachedSheet } from '../components/DeviceLimitReachedSheet'; @@ -57,6 +58,7 @@ function RootLayoutInner() { const colorScheme = useThemeStore((s) => s.colorScheme); const initLanguage = useLanguageStore((s) => s.init); const initAppLock = useAppLockStore((s) => s.init); + const initLyraVoice = useLyraVoiceStore((s) => s.init); const appLockReady = useAppLockStore((s) => s.ready); const initRealtimeDebug = useRealtimeDebugStore((s) => s.init); const colors = useColors(); @@ -72,6 +74,7 @@ function RootLayoutInner() { initTheme(); initLanguage(); initAppLock(); + initLyraVoice(); if (__DEV__) initRealtimeDebug(); }, []); diff --git a/apps/rebreak-native/components/onboarding/LyraBubble.tsx b/apps/rebreak-native/components/onboarding/LyraBubble.tsx index 2de8cb1..d4cb6aa 100644 --- a/apps/rebreak-native/components/onboarding/LyraBubble.tsx +++ b/apps/rebreak-native/components/onboarding/LyraBubble.tsx @@ -10,15 +10,20 @@ import { stopLyraSpeech, type LyraSpeechStatus, } from '../../lib/lyraSpeech'; +import { useLyraVoiceStore } from '../../stores/lyraVoice'; /** - * Lyra-Mascot (animiertes Rive-Avatar) links + Speech-Bubble rechts. + * Lyra-Mascot (animiertes Rive-Avatar) + Speech-Bubble + TTS-Audio-Button. * - * Plus: kleiner Audio-Button rechts oben in der Bubble — User kann den Lyra- - * Text via TTS hören lassen. DiGA-Accessibility-relevant (Screen-Reader- - * Alternative + Lese-Hürden-Mitigation). + * Auto-Play-Pattern (User-Preference, persistent via useLyraVoiceStore): + * - Voice OFF (default): kein Auto-Play, Audio-Button-Tap aktiviert Voice + * + spielt aktuellen Text. + * - Voice ON: Bubble spielt sich auf jeder Slide automatisch ab beim + * Text-Change. Audio-Button-Tap stoppt aktuelles Playback + disabled + * die Preference (= kein Auto-Play mehr). * - * Fade+slide-in beim Mount und bei text-change (key-prop verwenden für Re-Animate). + * Cleanup beim Unmount + bei text-change (Slide-Switch → vorheriger Sound + * wird abgebrochen). */ export function LyraBubble({ text, @@ -32,7 +37,11 @@ export function LyraBubble({ const opacity = useRef(new Animated.Value(0)).current; const translateX = useRef(new Animated.Value(-12)).current; const [speech, setSpeech] = useState('idle'); + const voiceEnabled = useLyraVoiceStore((s) => s.enabled); + const voiceReady = useLyraVoiceStore((s) => s.ready); + const toggleVoice = useLyraVoiceStore((s) => s.toggle); + // Fade-in beim Mount + text-change useEffect(() => { opacity.setValue(0); translateX.setValue(-12); @@ -52,34 +61,50 @@ export function LyraBubble({ ]).start(); }, [text, opacity, translateX]); - // Cleanup beim Unmount — sonst spielt der Sound weiter wenn User Slide wechselt + // Auto-Play wenn voice enabled + Bubble-Text wechselt (= neue Slide). + // Stoppt vorheriges Playback automatisch via stopLyraSpeech() in playLyraSpeech. + useEffect(() => { + if (!voiceReady) return; // warte bis AsyncStorage geladen + if (!voiceEnabled) { + // Falls voice grad disabled wurde während etwas spielte: stoppen. + setSpeech('idle'); + void stopLyraSpeech(); + return; + } + void playLyraSpeech(text, i18n.language || 'de', setSpeech); + }, [text, voiceEnabled, voiceReady]); + + // Cleanup beim Unmount — sonst spielt der Sound weiter wenn /onboarding entladen wird useEffect(() => { return () => { void stopLyraSpeech(); }; }, []); - // Stoppt auch wenn der Bubble-Text wechselt (= neuer Slide angezeigt) - useEffect(() => { - setSpeech('idle'); - void stopLyraSpeech(); - }, [text]); - async function togglePlayback() { - if (speech === 'playing' || speech === 'loading') { - await stopLyraSpeech(); + // Wenn voice OFF: enable (auto-trigger Auto-Play-Effect oben) + // Wenn voice ON: disable + stop current + if (voiceEnabled) { setSpeech('idle'); - return; + await stopLyraSpeech(); } - await playLyraSpeech(text, i18n.language || 'de', setSpeech); + await toggleVoice(); + // Wenn jetzt enabled: useEffect oben startet das playback automatisch } - const a11yLabel = - speech === 'playing' + const a11yLabel = voiceEnabled + ? speech === 'playing' ? t('onboarding.lyra.audio_stop') + : t('onboarding.lyra.audio_disable') + : t('onboarding.lyra.audio_play'); + + const iconName: keyof typeof Ionicons.glyphMap = !voiceEnabled + ? 'volume-mute-outline' + : speech === 'playing' + ? 'stop' : speech === 'loading' - ? t('onboarding.lyra.audio_loading') - : t('onboarding.lyra.audio_play'); + ? 'hourglass' + : 'volume-medium'; return ( @@ -100,7 +125,7 @@ export function LyraBubble({ borderRadius: 16, paddingVertical: 14, paddingLeft: 16, - paddingRight: 50, // Platz für den Audio-Button rechts + paddingRight: 50, // Platz für Audio-Button rechts position: 'relative', }} > @@ -115,7 +140,7 @@ export function LyraBubble({ {text} - {/* Audio-Button rechts oben — kompakt, dezent */} + {/* Audio-Toggle rechts oben — bestimmt globale Voice-Preference */} diff --git a/apps/rebreak-native/locales/ar.json b/apps/rebreak-native/locales/ar.json index e942ef7..77173e5 100644 --- a/apps/rebreak-native/locales/ar.json +++ b/apps/rebreak-native/locales/ar.json @@ -371,9 +371,10 @@ "protection_url": { "body": "ستظهر نافذة iOS. اضغط «السماح» — الزر السفلي (وليس الأزرق الكبير في الأعلى — هذا هو الفخ)." }, "protection_lock": { "body": "الآن قفل التطبيق. iOS يطلب الوصول إلى مدة استخدام الجهاز — اضغط «متابعة»، مرة أخرى الزر السفلي (وليس الأزرق)." }, "done": { "body": "تم. اليوم الأول من سلسلتك — ولست وحدك." }, - "audio_play": "اقرأ النص بصوت عالٍ", + "audio_play": "تفعيل الصوت", "audio_loading": "جاري تحميل الصوت...", - "audio_stop": "إيقاف التشغيل" + "audio_stop": "إيقاف التشغيل", + "audio_disable": "إيقاف الصوت" }, "welcome": { "cta_primary": "هيا نبدأ", diff --git a/apps/rebreak-native/locales/de.json b/apps/rebreak-native/locales/de.json index 006e2d0..59864b8 100644 --- a/apps/rebreak-native/locales/de.json +++ b/apps/rebreak-native/locales/de.json @@ -371,9 +371,10 @@ "protection_url": { "body": "Gleich kommt ein iOS-Dialog. Tippe \"Erlauben\" — den unteren Button (nicht den großen blauen oben — das ist die Falle)." }, "protection_lock": { "body": "Jetzt der App-Schutz. iOS fragt nach Bildschirmzeit-Zugriff — tippe \"Fortfahren\", wieder den unteren Button (nicht den blauen)." }, "done": { "body": "Geschafft. Tag 1 deiner neuen Streak — und du gehst nicht allein." }, - "audio_play": "Text vorlesen", + "audio_play": "Stimme einschalten", "audio_loading": "Lade Stimme...", - "audio_stop": "Wiedergabe stoppen" + "audio_stop": "Wiedergabe stoppen", + "audio_disable": "Stimme ausschalten" }, "welcome": { "cta_primary": "Los geht's", diff --git a/apps/rebreak-native/locales/en.json b/apps/rebreak-native/locales/en.json index 3ed9355..ce3b78d 100644 --- a/apps/rebreak-native/locales/en.json +++ b/apps/rebreak-native/locales/en.json @@ -371,9 +371,10 @@ "protection_url": { "body": "An iOS dialog is coming. Tap \"Allow\" — the bottom button (not the big blue one on top — that's the trap)." }, "protection_lock": { "body": "Now the app lock. iOS asks for Screen Time access — tap \"Continue\", again the bottom button (not the blue one)." }, "done": { "body": "Done. Day 1 of your new streak — and you're not walking alone." }, - "audio_play": "Read text aloud", + "audio_play": "Enable voice", "audio_loading": "Loading voice...", - "audio_stop": "Stop playback" + "audio_stop": "Stop playback", + "audio_disable": "Disable voice" }, "welcome": { "cta_primary": "Let's go", diff --git a/apps/rebreak-native/locales/fr.json b/apps/rebreak-native/locales/fr.json index 8f13cde..093602a 100644 --- a/apps/rebreak-native/locales/fr.json +++ b/apps/rebreak-native/locales/fr.json @@ -369,9 +369,10 @@ "protection_url": { "body": "Une fenêtre iOS va apparaître. Touche « Autoriser » — le bouton du bas (pas le grand bleu en haut — c'est le piège)." }, "protection_lock": { "body": "Maintenant le verrou d'app. iOS demande l'accès à Temps d'écran — touche « Continuer », encore le bouton du bas (pas le bleu)." }, "done": { "body": "Voilà. Jour 1 de ta nouvelle série — et tu n'es pas seul." }, - "audio_play": "Lire le texte", + "audio_play": "Activer la voix", "audio_loading": "Chargement de la voix...", - "audio_stop": "Arrêter la lecture" + "audio_stop": "Arrêter la lecture", + "audio_disable": "Désactiver la voix" }, "welcome": { "cta_primary": "On y va", diff --git a/apps/rebreak-native/stores/lyraVoice.ts b/apps/rebreak-native/stores/lyraVoice.ts new file mode 100644 index 0000000..af92bd9 --- /dev/null +++ b/apps/rebreak-native/stores/lyraVoice.ts @@ -0,0 +1,51 @@ +import { create } from 'zustand'; +import AsyncStorage from '@react-native-async-storage/async-storage'; + +/** + * Persistente User-Preference für Lyra-TTS-Auto-Play. + * + * Default OFF — User aktiviert einmal in der Welcome-Slide via Audio-Button, + * danach playt Lyra auf jeder Slide automatisch. Erneutes Tappen disabled + * + stoppt aktuelles Playback. + * + * Persistence: AsyncStorage @rebreak/lyraVoiceEnabled. Init beim App-Start + * via `init()` (analog zu language/theme/appLock-Stores). + */ + +const STORAGE_KEY = '@rebreak/lyraVoiceEnabled'; + +type LyraVoiceState = { + enabled: boolean; + ready: boolean; + init: () => Promise; + setEnabled: (enabled: boolean) => Promise; + toggle: () => Promise; +}; + +export const useLyraVoiceStore = create((set, get) => ({ + enabled: false, + ready: false, + + init: async () => { + try { + const stored = await AsyncStorage.getItem(STORAGE_KEY); + set({ enabled: stored === '1', ready: true }); + } catch { + set({ enabled: false, ready: true }); + } + }, + + setEnabled: async (enabled: boolean) => { + set({ enabled }); + try { + await AsyncStorage.setItem(STORAGE_KEY, enabled ? '1' : '0'); + } catch { + // ignore — in-memory state ist wichtiger + } + }, + + toggle: async () => { + const next = !get().enabled; + await get().setEnabled(next); + }, +})); diff --git a/backend/prisma/migrations/20260518_extend_diga_test_codes/migration.sql b/backend/prisma/migrations/20260518_extend_diga_test_codes/migration.sql new file mode 100644 index 0000000..718d73b --- /dev/null +++ b/backend/prisma/migrations/20260518_extend_diga_test_codes/migration.sql @@ -0,0 +1,16 @@ +-- Extend DiGA-Test-Codes from 10 → 100 für 100 Android-Tester-Onboarding. +-- +-- REBREAK-TEST-011 bis REBREAK-TEST-100 — gleicher Pattern wie 001-010 +-- aus Migration 20260517_add_diga_codes. +-- +-- ON CONFLICT DO NOTHING — idempotent, re-Run safe. Bestehende 001-010 +-- bleiben unverändert. + +INSERT INTO "rebreak"."diga_codes" ("code", "label", "grants_plan", "notes") +SELECT + 'REBREAK-TEST-' || LPAD(n::text, 3, '0'), + 'test_batch_2026-05-android', + 'legend', + 'Android-Tester-Cohort 2026-05 (single-use, reset via SQL)' +FROM generate_series(11, 100) AS n +ON CONFLICT ("code") DO NOTHING;