feat(onboarding/android + backend/lyra-i18n): platform-dispatch + post-catalog scaffold

Android-Onboarding (Platform.OS dispatch in ProtectionSlide):
- Neue Phasen für Android: preexplain_vpn → preexplain_a11y → a11y_pending
- AppState-Listener: nach Settings-Rückkehr auto-poll isAccessibilityEnabled
  → wenn live, armTamperLock + finish (kein Fokus-Klick nötig)
- onboardingAssets: 8 neue Mappings (android_vpn + android_a11y × 4 Locales)
- Screenshots: vpn-permission + a11y-rebreak-row pro Locale
- Locale-Keys: protection_url_android, protection_lock_android, cta_open_a11y,
  cta_check_a11y, dialog_button_vpn_ok, dialog_button_a11y_toggle, tap_marker_hint_*

Lyra-Post i18n Phase 1 (Scaffold, feature-flag OFF by default):
- schema.prisma: CommunityPost.i18nKey String? (nullable)
- migration 20260517_add_lyra_post_i18n_key: ALTER TABLE ADD COLUMN i18n_key
  (NICHT auto-deployed — `prisma migrate deploy` als separater Step)
- server/lib/lyraPostCatalog.ts: 15 Templates skelettiert + pickRandomTemplate
- cron/lyra-post: USE_TEMPLATE_CATALOG=true Branch → speichert i18nKey;
  default false → LLM-Path unverändert (zero-risk-deployment)
- community.createPost: optionaler i18nKey-Parameter
- posts.get: i18nKey in API-Response
- PostCard: 3-Zeilen-Branch — i18nKey ? t('lyra_posts.'+id) : content
- stores/community: i18nKey?: string|null im Interface
- de.json: lyra_posts-Block mit 15 IDs + DE-Texten

Single-Banner-Verhalten auf Android verifiziert:
lockedIn=urlFilter && appDeletionLock funktioniert weiter — auf Android
alias appDeletionLock ← tamperLock; onboarding arms tamperLock, also
nach onboarding-done direkt ProtectionLockedCard sichtbar.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
chahinebrini 2026-05-17 23:48:25 +02:00
parent ac605dce33
commit 2e409efaf0
35 changed files with 587 additions and 85 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 259 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 237 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 230 KiB

View File

@ -75,7 +75,11 @@ function PostCardImpl({ post, onCommentPress }: Props) {
}, [heartScale]);
const displayAuthor = post.repostOf ? post.repostOf.author : post.author;
const displayContent = post.repostOf ? post.repostOf.content : post.content;
const rawContent = post.repostOf ? post.repostOf.content : post.content;
// i18n-aware content: wenn i18nKey gesetzt → übersetzten Text nehmen,
// sonst rawContent (Legacy-Verhalten unverändert).
const i18nKey = post.repostOf ? undefined : post.i18nKey;
const displayContent = i18nKey ? t(`lyra_posts.${i18nKey}`) : rawContent;
const displayImage = post.repostOf ? post.repostOf.imageUrl : post.imageUrl;
// Image aspect-ratio: ermittelt aus onLoad event.source.{width,height}.

View File

@ -1,10 +1,11 @@
import { useState } from 'react';
import { Alert, Image, Platform, Text, useWindowDimensions, View } from 'react-native';
import { useEffect, useRef, useState } from 'react';
import { Alert, AppState, Image, Platform, Text, useWindowDimensions, View } from 'react-native';
import { useTranslation } from 'react-i18next';
import { useColors } from '../../../lib/theme';
import { apiFetch } from '../../../lib/api';
import { invalidateMe } from '../../../hooks/useMe';
import { protection } from '../../../lib/protection';
import RebreakProtection from '../../../modules/rebreak-protection';
import { getPermissionScreenshot } from '../../../lib/onboardingAssets';
import { OnboardingShell } from '../OnboardingShell';
import { LyraBubble } from '../LyraBubble';
@ -14,32 +15,34 @@ import { PermissionDeniedSheet } from '../../PermissionDeniedSheet';
import i18n from '../../../lib/i18n';
/**
* Onboarding Step Protection 2 Phasen, beide mit Pre-Explainer-Modal das
* den iOS-Permission-Dialog vorzeigt + Pulse-Marker auf den "Erlauben"-Button.
* Onboarding-Schutz-Step.
*
*
* Phase A: preexplain_url
* Lyra: "Gleich kommt iOS-Dialog. Tippe ERLAUBEN."
* Screenshot vom NEFilter-Dialog + roter Pulse auf "Erlauben"
* CTA "Aktivieren" triggers protection.activateUrlFilter()
*
* Phase B: preexplain_lock
* Lyra: "Jetzt App-Schutz. Tippe FORTFAHREN."
* Screenshot vom Screen-Time-Dialog + roter Pulse
* CTA "Aktivieren" triggers protection.activateFamilyControls
*
* Phase C: done onDone()
*
* Platform.OS-Dispatch:
* iOS IosProtectionSlide (NEFilter + Family-Controls)
* Android AndroidProtectionSlide (VpnService + Accessibility-Tamper-Lock)
*
* Wenn URL-Filter fehlschlägt mit code 5 PermissionDeniedSheet öffnet sich
* (Retry-Pfad via resetUrlFilter()). Family-Controls hat keinen analogen
* Recovery-Sheet User muss in Settings Bildschirmzeit den App-Zugriff
* gewähren.
* Beide haben den gleichen Eltern-Vertrag (current/total/onDone) und nutzen
* den gleichen Pre-Explainer + Lyra-Bubble + CTA-Pattern die Innereien
* unterscheiden sich nur in (a) welche Permission-Dialoge geöffnet werden
* und (b) welche Screenshots gezeigt werden.
*/
type Phase = 'preexplain_url' | 'preexplain_lock' | 'done';
export function ProtectionSlide(props: {
onDone: () => void;
current: number;
total: number;
}) {
if (Platform.OS === 'android') {
return <AndroidProtectionSlide {...props} />;
}
return <IosProtectionSlide {...props} />;
}
export function ProtectionSlide({
// ─── iOS ────────────────────────────────────────────────────────────────────
type IosPhase = 'preexplain_url' | 'preexplain_lock' | 'done';
function IosProtectionSlide({
onDone,
current,
total,
@ -49,8 +52,7 @@ export function ProtectionSlide({
total: number;
}) {
const { t } = useTranslation();
const colors = useColors();
const [phase, setPhase] = useState<Phase>('preexplain_url');
const [phase, setPhase] = useState<IosPhase>('preexplain_url');
const [activating, setActivating] = useState(false);
const [permissionDeniedOpen, setPermissionDeniedOpen] = useState(false);
@ -61,7 +63,6 @@ export function ProtectionSlide({
const res = await protection.activateUrlFilter();
if (!res.enabled) {
const isCodeFive =
Platform.OS === 'ios' &&
typeof res.error === 'string' &&
/NEFilterErrorDomain:\s*5/i.test(res.error);
if (isCodeFive) {
@ -74,7 +75,6 @@ export function ProtectionSlide({
);
return;
}
// Filter live → weiter zur Phase B (App-Lock)
setPhase('preexplain_lock');
} finally {
setActivating(false);
@ -87,8 +87,6 @@ export function ProtectionSlide({
try {
const res = await protection.activateFamilyControls();
if (!res.enabled) {
// Family Controls fehlgeschlagen → User Info aber Tour-Done (URL-Filter
// läuft schon, das ist der Hauptschutz; App-Lock ist optional)
Alert.alert(
t('onboarding.protection.applock_failed_title'),
res.error ?? t('onboarding.protection.applock_failed_msg'),
@ -119,13 +117,16 @@ export function ProtectionSlide({
onDone();
}
return phase === 'preexplain_url' ? (
if (phase === 'preexplain_url') {
return (
<PreExplainer
key="url"
key="ios-url"
dialog="url_filter"
lyraBodyKey="onboarding.lyra.protection_url.body"
titleKey="onboarding.protection.url_title"
ctaKey="onboarding.protection.cta_primary"
buttonLabelKey="onboarding.protection.dialog_button_allow"
markerHintKey="onboarding.protection.tap_marker_hint"
activating={activating}
onActivate={activateUrlFilter}
current={current}
@ -141,19 +142,222 @@ export function ProtectionSlide({
}}
/>
</PreExplainer>
) : phase === 'preexplain_lock' ? (
);
}
if (phase === 'preexplain_lock') {
return (
<PreExplainer
key="lock"
key="ios-lock"
dialog="screen_time"
lyraBodyKey="onboarding.lyra.protection_lock.body"
titleKey="onboarding.protection.lock_title"
ctaKey="onboarding.protection.cta_primary"
buttonLabelKey="onboarding.protection.dialog_button_continue"
markerHintKey="onboarding.protection.tap_marker_hint"
activating={activating}
onActivate={activateAppLock}
current={current}
total={total}
/>
) : null;
);
}
return null;
}
// ─── Android ────────────────────────────────────────────────────────────────
type AndroidPhase =
| 'preexplain_vpn'
| 'preexplain_a11y'
| 'a11y_pending'
| 'done';
function AndroidProtectionSlide({
onDone,
current,
total,
}: {
onDone: () => void;
current: number;
total: number;
}) {
const { t } = useTranslation();
const [phase, setPhase] = useState<AndroidPhase>('preexplain_vpn');
const [activating, setActivating] = useState(false);
// True wenn wir auf Settings-Rückkehr warten. AppState-Listener pollt dann
// a11y-State + advanced automatisch wenn ReBreak-Schalter live ist.
const awaitingReturnRef = useRef(false);
const appStateRef = useRef(AppState.currentState);
async function finishProtectionStep() {
await apiFetch('/api/profile/me/onboarding-step', {
method: 'PATCH',
body: { step: 'done' },
}).catch(() => {});
invalidateMe();
setPhase('done');
onDone();
}
async function activateVpn() {
if (activating) return;
setActivating(true);
try {
const res = await protection.activateUrlFilter();
if (!res.enabled) {
Alert.alert(
t('onboarding.protection.error_title'),
res.error ?? t('onboarding.protection.error_unknown'),
);
return;
}
setPhase('preexplain_a11y');
} finally {
setActivating(false);
}
}
async function activateA11y() {
if (activating) return;
setActivating(true);
try {
const res = await protection.activateFamilyControls();
if (res.enabled) {
// Selten: User hatte a11y schon manuell aktiviert → Lock direkt armed.
finishProtectionStep();
return;
}
if (res.error === 'accessibility_pending') {
// Native hat Settings geöffnet; warte auf Rückkehr + poll.
awaitingReturnRef.current = true;
setPhase('a11y_pending');
return;
}
Alert.alert(
t('onboarding.protection.error_title'),
res.error ?? t('onboarding.protection.error_unknown'),
);
} finally {
setActivating(false);
}
}
// Auto-Check beim Foreground-Return: wenn a11y jetzt aktiv → Lock armen + done.
useEffect(() => {
const sub = AppState.addEventListener('change', async (next) => {
const prev = appStateRef.current;
appStateRef.current = next;
if (!awaitingReturnRef.current) return;
if (prev.match(/inactive|background/) && next === 'active') {
try {
const a11y = await RebreakProtection.isAccessibilityEnabled();
if (a11y.enabled) {
// ReBreak-Service ist live → Tamper-Lock armen + finish.
const res = await protection.activateFamilyControls();
if (res.enabled) {
awaitingReturnRef.current = false;
finishProtectionStep();
}
}
} catch {
// Ignorieren — User kann manuell auf "Ich habe ReBreak aktiviert" tippen.
}
}
});
return () => sub.remove();
}, []);
if (phase === 'preexplain_vpn') {
return (
<PreExplainer
key="android-vpn"
dialog="android_vpn"
lyraBodyKey="onboarding.lyra.protection_url_android.body"
titleKey="onboarding.protection.url_title_android"
ctaKey="onboarding.protection.cta_primary"
buttonLabelKey="onboarding.protection.dialog_button_vpn_ok"
markerHintKey="onboarding.protection.tap_marker_hint_android_vpn"
activating={activating}
onActivate={activateVpn}
current={current}
total={total}
/>
);
}
if (phase === 'preexplain_a11y') {
return (
<PreExplainer
key="android-a11y"
dialog="android_a11y"
lyraBodyKey="onboarding.lyra.protection_lock_android.body"
titleKey="onboarding.protection.lock_title_android"
ctaKey="onboarding.protection.cta_open_a11y"
buttonLabelKey="onboarding.protection.dialog_button_a11y_toggle"
markerHintKey="onboarding.protection.tap_marker_hint_android_a11y"
activating={activating}
onActivate={activateA11y}
current={current}
total={total}
/>
);
}
if (phase === 'a11y_pending') {
return (
<A11yPendingView
current={current}
total={total}
activating={activating}
onRetry={activateA11y}
/>
);
}
return null;
}
function A11yPendingView({
current,
total,
activating,
onRetry,
}: {
current: number;
total: number;
activating: boolean;
onRetry: () => void;
}) {
const { t } = useTranslation();
const colors = useColors();
return (
<OnboardingShell
current={current}
total={total}
cta={
<CTABar
primaryLabel={t('onboarding.protection.cta_check_a11y')}
onPrimary={onRetry}
primaryLoading={activating}
/>
}
>
<LyraBubble
text={t('onboarding.protection.android_a11y_pending_body')}
emotion="empathy"
/>
<Text
style={{
marginTop: 14,
fontFamily: 'Nunito_700Bold',
fontSize: 12,
letterSpacing: 0.6,
color: colors.textMuted,
textTransform: 'uppercase',
textAlign: 'center',
}}
>
{t('onboarding.protection.android_a11y_pending_title')}
</Text>
</OnboardingShell>
);
}
// ─── PreExplainer (shared) ───────────────────────────────────────────────────
@ -163,16 +367,20 @@ function PreExplainer({
lyraBodyKey,
titleKey,
ctaKey,
buttonLabelKey,
markerHintKey,
activating,
onActivate,
current,
total,
children,
}: {
dialog: 'url_filter' | 'screen_time';
dialog: 'url_filter' | 'screen_time' | 'android_vpn' | 'android_a11y';
lyraBodyKey: string;
titleKey: string;
ctaKey: string;
buttonLabelKey: string;
markerHintKey: string;
activating: boolean;
onActivate: () => void;
current: number;
@ -184,10 +392,6 @@ function PreExplainer({
const { height: screenH } = useWindowDimensions();
const lang = i18n.language || 'de';
const screenshot = getPermissionScreenshot(dialog, lang);
const buttonLabelKey =
dialog === 'url_filter'
? 'onboarding.protection.dialog_button_allow'
: 'onboarding.protection.dialog_button_continue';
// Dynamische Screenshot-Höhe: Auf kleinen Phones (SE/mini ~667-844 pt)
// capped damit alles + CTA-Bar ohne Scroll passt. Auf großen Phones/iPad
@ -222,8 +426,6 @@ function PreExplainer({
{t(titleKey)}
</Text>
{/* Screenshot Modal hat eigene runde Ecken im Bild, kein extra Container-
Radius (sonst Double-Round-Look). Nur padding für Atmungsraum. */}
<View
style={{
marginTop: 8,
@ -239,7 +441,6 @@ function PreExplainer({
/>
</View>
{/* Animierter Pointer UNTER dem Screenshot — Dimensions-agnostic. */}
<ScreenshotPointer buttonLabel={t(buttonLabelKey)} />
<Text
@ -253,7 +454,7 @@ function PreExplainer({
paddingHorizontal: 8,
}}
>
{t('onboarding.protection.tap_marker_hint')}
{t(markerHintKey)}
</Text>
{children}
</OnboardingShell>

View File

@ -1,15 +1,23 @@
/**
* iOS Permission-Dialog-Screenshots für den Onboarding-Pre-Explainer.
* Permission-Dialog-Screenshots für den Onboarding-Pre-Explainer.
*
* Pro Sprache liegen die Screenshots in `assets/onboarding/<lang>/`. Falls
* eine Sprache nicht (oder noch nicht) verfügbar ist, fällt der Resolver auf
* `de` zurück.
*
* Dialog-Typen:
* iOS:
* - `url_filter` NEFilter-System-Dialog ("Erlauben"-Button)
* - `screen_time` Family-Controls-/Screen-Time-Dialog ("Fortfahren")
* Android:
* - `android_vpn` VpnService-System-Dialog ("OK"-Button)
* - `android_a11y` Bedienungshilfen-Settings mit ReBreak-Eintrag
*
* Diese Maps explizit mit `require(...)` deklarieren RN/Metro kann keine
* dynamischen Pfade auflösen.
*/
type Dialog = 'url_filter' | 'screen_time';
type Dialog = 'url_filter' | 'screen_time' | 'android_vpn' | 'android_a11y';
type Lang = 'de' | 'en' | 'fr' | 'ar';
/* eslint-disable @typescript-eslint/no-require-imports */
@ -22,6 +30,18 @@ const SCREEN_TIME_DE = require('../assets/onboarding/de/screen_time_permission.j
const SCREEN_TIME_EN = require('../assets/onboarding/en/screen_time_permission.jpeg');
const SCREEN_TIME_FR = require('../assets/onboarding/fr/screen_time_permission.jpeg');
const SCREEN_TIME_AR = require('../assets/onboarding/ar/screen_time_permission.jpeg');
// Android — VpnService-Permission-Dialog ("Verbindungsanforderung")
const ANDROID_VPN_DE = require('../assets/onboarding/de/android-vpn-permission-001.png');
const ANDROID_VPN_EN = require('../assets/onboarding/en/android-vpn-permission-001.png');
const ANDROID_VPN_FR = require('../assets/onboarding/fr/android-vpn-permission-001.png');
const ANDROID_VPN_AR = require('../assets/onboarding/ar/android-vpn-permission-001.png');
// Android — Accessibility-Settings, ReBreak-Row sichtbar
const ANDROID_A11Y_DE = require('../assets/onboarding/de/android-a11y-rebreak-row-001.png');
const ANDROID_A11Y_EN = require('../assets/onboarding/en/android-a11y-rebreak-row-001.png');
const ANDROID_A11Y_FR = require('../assets/onboarding/fr/android-a11y-rebreak-row-001.png');
const ANDROID_A11Y_AR = require('../assets/onboarding/ar/android-a11y-rebreak-row-001.png');
/* eslint-enable @typescript-eslint/no-require-imports */
const SCREENSHOTS: Record<Dialog, Partial<Record<Lang, number>>> = {
@ -37,6 +57,18 @@ const SCREENSHOTS: Record<Dialog, Partial<Record<Lang, number>>> = {
fr: SCREEN_TIME_FR,
ar: SCREEN_TIME_AR,
},
android_vpn: {
de: ANDROID_VPN_DE,
en: ANDROID_VPN_EN,
fr: ANDROID_VPN_FR,
ar: ANDROID_VPN_AR,
},
android_a11y: {
de: ANDROID_A11Y_DE,
en: ANDROID_A11Y_EN,
fr: ANDROID_A11Y_FR,
ar: ANDROID_A11Y_AR,
},
};
/**
@ -50,8 +82,3 @@ export function getPermissionScreenshot(dialog: Dialog, lang: string): number {
const map = SCREENSHOTS[dialog];
return map[normalized] ?? map.de!;
}
// (Deprecated) getPointerPosition entfernt — der Pointer wird jetzt extern
// UNTER dem Screenshot gerendert (ScreenshotPointer-Komponente), nicht mehr
// per-percent overlayed. Damit entfällt die Notwendigkeit pixel-genaue
// Positionen pro Locale + Dialog zu pflegen — siehe ScreenshotPointer.tsx.

View File

@ -370,6 +370,8 @@
"protection": { "body": "الآن الجزء الأهم — الحماية على جهازك. هل أنت مستعد؟" },
"protection_url": { "body": "ستظهر نافذة iOS. اضغط «السماح» — الزر السفلي (وليس الأزرق الكبير في الأعلى — هذا هو الفخ)." },
"protection_lock": { "body": "الآن قفل التطبيق. iOS يطلب الوصول إلى مدة استخدام الجهاز — اضغط «متابعة»، مرة أخرى الزر السفلي (وليس الأزرق)." },
"protection_url_android": { "body": "سيطلب Android إذن VPN. اضغط «موافق» — هذا ليس VPN حقيقي، الفلتر يعمل محلياً على جهازك." },
"protection_lock_android": { "body": "الخطوة الأخيرة: سأفتح إعدادات إمكانية الوصول. ابحث عن «ReBreak» وفعّل المفتاح — ثم ارجع إلى التطبيق." },
"done": { "body": "تم. اليوم الأول من سلسلتك — ولست وحدك." },
"audio_play": "تفعيل الصوت",
"audio_loading": "جاري تحميل الصوت...",
@ -450,11 +452,21 @@
},
"protection": {
"cta_primary": "فعّل الحماية",
"cta_open_a11y": "افتح إعدادات إمكانية الوصول",
"cta_check_a11y": "لقد فعّلت ReBreak",
"url_title": "الخطوة 1 من 2 — فلتر المحتوى",
"lock_title": "الخطوة 2 من 2 — قفل التطبيق",
"url_title_android": "الخطوة 1 من 2 — فلتر DNS",
"lock_title_android": "الخطوة 2 من 2 — حماية من التلاعب",
"tap_marker_hint": "Apple يضع الزر الأزرق الكبير في الأعلى («عدم السماح») — اضغط الزر السفلي، وليس العلوي.",
"tap_marker_hint_android_vpn": "سيعرض Android طلب إذن VPN. اضغط «موافق» — نحن نستخدم واجهة VPN محلياً فقط كفلتر DNS، ولا تغادر أي بيانات جهازك.",
"tap_marker_hint_android_a11y": "بمجرد تفعيل مفتاح ReBreak، تحمي الخدمة إعداداتك من التعطيل غير المقصود. ثم ارجع إلى التطبيق.",
"android_a11y_pending_title": "بانتظار تفعيل إمكانية الوصول",
"android_a11y_pending_body": "إذا لم تفعّل إمكانية الوصول، افتحها مجدداً وفعّل ReBreak.",
"dialog_button_allow": "اضغط «السماح»",
"dialog_button_continue": "اضغط «متابعة»",
"dialog_button_vpn_ok": "اضغط «موافق»",
"dialog_button_a11y_toggle": "تفعيل المفتاح",
"applock_failed_title": "فشل قفل التطبيق",
"applock_failed_msg": "يمكنك المحاولة مرة أخرى أو تخطي هذه الخطوة — فلتر URL يعمل بالفعل.",
"applock_skip": "تخطّي",

View File

@ -370,6 +370,8 @@
"protection": { "body": "Jetzt der wichtigste Teil — der Schutz auf deinem Gerät. Bereit?" },
"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)." },
"protection_url_android": { "body": "Gleich fragt Android nach VPN-Erlaubnis. Tippe \"OK\" — das ist kein echtes VPN, der Filter läuft lokal auf deinem Gerät." },
"protection_lock_android": { "body": "Letzter Schritt: Ich öffne gleich die Bedienungshilfen. Such dort \"ReBreak\" und schalte den Schalter an — komm dann einfach wieder zurück." },
"done": { "body": "Geschafft. Tag 1 deiner neuen Streak — und du gehst nicht allein." },
"audio_play": "Stimme einschalten",
"audio_loading": "Lade Stimme...",
@ -450,11 +452,21 @@
},
"protection": {
"cta_primary": "Schutz aktivieren",
"cta_open_a11y": "Bedienungshilfen öffnen",
"cta_check_a11y": "Ich habe ReBreak aktiviert",
"url_title": "Schritt 1 von 2 — Inhaltsfilter",
"lock_title": "Schritt 2 von 2 — App-Schutz",
"url_title_android": "Schritt 1 von 2 — DNS-Filter",
"lock_title_android": "Schritt 2 von 2 — Tamper-Schutz",
"tap_marker_hint": "Apple platziert den großen blauen Button oben (\"Nicht erlauben\") — bitte den UNTEREN Button tippen, nicht den oberen.",
"tap_marker_hint_android_vpn": "Android zeigt gleich eine VPN-Erlaubnis-Anfrage. Tippe \"OK\" — wir nutzen die VPN-API nur lokal als DNS-Filter, kein Traffic verlässt dein Gerät.",
"tap_marker_hint_android_a11y": "Sobald du den ReBreak-Schalter anschaltest, schützt der Service deine Einstellungen vor versehentlicher Deaktivierung. Komm dann zurück zur App.",
"android_a11y_pending_title": "Warte auf Bedienungshilfen-Aktivierung",
"android_a11y_pending_body": "Falls du die Bedienungshilfen nicht aktiviert hast, öffne sie nochmal und schalte ReBreak an.",
"dialog_button_allow": "Tippe \"Erlauben\"",
"dialog_button_continue": "Tippe \"Fortfahren\"",
"dialog_button_vpn_ok": "Tippe \"OK\"",
"dialog_button_a11y_toggle": "Schalter aktivieren",
"applock_failed_title": "App-Schutz fehlgeschlagen",
"applock_failed_msg": "Du kannst es nochmal versuchen oder den Schritt überspringen — der URL-Filter läuft schon.",
"applock_skip": "Überspringen",
@ -1251,5 +1263,22 @@
"crisis_emergency_desc": "Wenn du oder jemand in deiner Nähe in akuter Gefahr ist, ruf sofort den Notruf an.",
"crisis_emergency_cta": "112 — Notruf",
"crisis_disclaimer": "Diese Stellen sind unabhängig von Rebreak. Wir verweisen weiter, beraten aber nicht selbst."
},
"lyra_posts": {
"motivation_quiet_01": "Manchmal ist ein Tag, an dem man einfach nicht gespielt hat, schon ein stiller Sieg. Kein Applaus nötig — du weißt, was du heute getan hast.",
"motivation_quiet_02": "Der Drang kommt in Wellen. Er fühlt sich endlos an — ist er aber nicht. Die meisten Wellen dauern unter 20 Minuten. Einfach warten.",
"motivation_quiet_03": "Niemand erwartet, dass du jeden Tag stark bist. Manche Tage reicht es, einfach da zu sein und nicht nachzugeben.",
"motivation_distance_01": "Zwischen dem Impuls und der Entscheidung liegt ein Moment. In diesem Moment bist du frei. Das ist kein Zufall — das ist Übung.",
"motivation_distance_02": "Fortschritt sieht selten dramatisch aus. Meistens ist es ein Tag ohne Rückfall, still und unbemerkt von allen außer dir.",
"tipp_breath_01": "Wenn der Drang stark ist: 4 Sekunden einatmen, 7 halten, 8 ausatmen. Das aktiviert dein parasympathisches Nervensystem und bremst den Impuls messbar.",
"tipp_urge_surf_01": "Urge Surfing: Beobachte den Drang wie eine Welle — ohne ihn zu bekämpfen. Benenne ihn laut: 'Ich spüre gerade Verlangen.' Das schafft Distanz zwischen dir und dem Impuls.",
"tipp_habit_replace_01": "Das Gehirn hasst Lücken. Wenn du ein Verhalten stoppst, brauchst du ein Ersatzverhalten für denselben Auslöser. Langweilig auf der Couch? Genau dann ist der SOS-Atem nützlich.",
"tipp_sos_reminder_01": "ReBreak hat eine SOS-Funktion für akute Momente — Atemübungen, Ablenkungsspiele, direkter Lyra-Chat. Sie ist genau für diesen Moment gebaut.",
"zitat_stoic_01": "\"Zwischen Reiz und Reaktion liegt ein Raum. In diesem Raum liegt unsere Freiheit.\" — Viktor Frankl. Kein großer Satz. Nur ein kleiner Raum, jeden Tag ein bisschen breiter.",
"zitat_psychology_01": "\"Gewohnheiten sind nie wirklich gelöscht, nur überschrieben.\" Das klingt erst entmutigend. Bedeutet aber: Jede neue Entscheidung schreibt mit.",
"witzig_impulse_01": "Mein Gehirn um 23 Uhr: 'Nur mal kurz schauen.' Mein Präfrontaler Kortex: 'Ich bin schon schlafen gegangen, macht euch selbst.' — Genau für diesen Moment gibt es Blocker.",
"witzig_distraction_01": "Impulskontrolle ist eigentlich nur eine fancy Art zu sagen: du hast dein Zukunfts-Ich angerufen, bevor dein Jetzt-Ich Mist bauen konnte.",
"news_push_tactics_01": "Casinos verschicken Push-Nachrichten bevorzugt freitags ab 18 Uhr und sonntags morgens — gezielt wenn Struktur wegfällt. Der ReBreak-Mailfilter fängt auch die digitale Variante ab.",
"feature_sos_01": "Übrigens: Der SOS-Bereich hat jetzt Minispiele als Ablenkung — Memory, Snake, Tetris. Nicht als Spaß-Feature, sondern weil kurze kognitive Aufgaben den Drang-Loop unterbrechen."
}
}

View File

@ -370,6 +370,8 @@
"protection": { "body": "Now the important part — the protection on your device. Ready?" },
"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)." },
"protection_url_android": { "body": "Android will ask for VPN permission. Tap \"OK\" — this isn't a real VPN; the filter runs locally on your device." },
"protection_lock_android": { "body": "Last step: I'll open Accessibility settings now. Find \"ReBreak\" there and flip the switch on — then come right back." },
"done": { "body": "Done. Day 1 of your new streak — and you're not walking alone." },
"audio_play": "Enable voice",
"audio_loading": "Loading voice...",
@ -450,11 +452,21 @@
},
"protection": {
"cta_primary": "Activate protection",
"cta_open_a11y": "Open Accessibility settings",
"cta_check_a11y": "I've enabled ReBreak",
"url_title": "Step 1 of 2 — Content filter",
"lock_title": "Step 2 of 2 — App lock",
"url_title_android": "Step 1 of 2 — DNS filter",
"lock_title_android": "Step 2 of 2 — Tamper protection",
"tap_marker_hint": "Apple puts the big blue button on top (\"Don't Allow\") — please tap the BOTTOM button, not the top one.",
"tap_marker_hint_android_vpn": "Android will show a VPN permission request. Tap \"OK\" — we only use the VPN API locally as a DNS filter; no traffic leaves your device.",
"tap_marker_hint_android_a11y": "Once you flip the ReBreak switch on, the service protects your settings from accidental disabling. Then come back to the app.",
"android_a11y_pending_title": "Waiting for Accessibility activation",
"android_a11y_pending_body": "If you didn't enable Accessibility, open it again and turn ReBreak on.",
"dialog_button_allow": "Tap \"Allow\"",
"dialog_button_continue": "Tap \"Continue\"",
"dialog_button_vpn_ok": "Tap \"OK\"",
"dialog_button_a11y_toggle": "Toggle the switch",
"applock_failed_title": "App lock failed",
"applock_failed_msg": "You can try again or skip this step — the URL filter is already running.",
"applock_skip": "Skip",

View File

@ -368,6 +368,8 @@
"protection": { "body": "Maintenant la partie importante — la protection sur ton appareil. Prêt ?" },
"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)." },
"protection_url_android": { "body": "Android va demander la permission VPN. Touche « OK » — ce n'est pas un vrai VPN, le filtre tourne localement sur ton téléphone." },
"protection_lock_android": { "body": "Dernière étape : j'ouvre les paramètres d'Accessibilité. Trouve « ReBreak » et active l'interrupteur — puis reviens dans l'app." },
"done": { "body": "Voilà. Jour 1 de ta nouvelle série — et tu n'es pas seul." },
"audio_play": "Activer la voix",
"audio_loading": "Chargement de la voix...",
@ -448,11 +450,21 @@
},
"protection": {
"cta_primary": "Activer la protection",
"cta_open_a11y": "Ouvrir les paramètres d'Accessibilité",
"cta_check_a11y": "J'ai activé ReBreak",
"url_title": "Étape 1 sur 2 — Filtre de contenu",
"lock_title": "Étape 2 sur 2 — Verrou d'app",
"url_title_android": "Étape 1 sur 2 — Filtre DNS",
"lock_title_android": "Étape 2 sur 2 — Protection anti-altération",
"tap_marker_hint": "Apple place le grand bouton bleu en haut (« Refuser ») — touche le bouton du BAS, pas celui du haut.",
"tap_marker_hint_android_vpn": "Android va afficher une demande de permission VPN. Touche « OK » — nous utilisons l'API VPN uniquement localement comme filtre DNS, aucun trafic ne quitte ton appareil.",
"tap_marker_hint_android_a11y": "Une fois l'interrupteur ReBreak activé, le service protège tes paramètres contre toute désactivation accidentelle. Puis reviens dans l'app.",
"android_a11y_pending_title": "En attente de l'activation Accessibilité",
"android_a11y_pending_body": "Si tu n'as pas activé l'Accessibilité, rouvre-la et active ReBreak.",
"dialog_button_allow": "Touche « Autoriser »",
"dialog_button_continue": "Touche « Continuer »",
"dialog_button_vpn_ok": "Touche « OK »",
"dialog_button_a11y_toggle": "Activer l'interrupteur",
"applock_failed_title": "Échec du verrou d'app",
"applock_failed_msg": "Tu peux réessayer ou ignorer cette étape — le filtre URL est déjà actif.",
"applock_skip": "Ignorer",

View File

@ -0,0 +1,78 @@
#!/usr/bin/env bash
# grab-onboarding-screenshot.sh
#
# Zieht Android-Screenshots via adb + speichert mit Auto-Numbering damit
# nichts überschrieben wird. Pro <lang>/<dialog>-Combo wird die nächste
# freie Nummer XYZ gewählt → <dialog>-001.png, <dialog>-002.png, ...
#
# So kann man mehrere Steps der gleichen Flow capturen (z.B. VPN-Dialog
# vorher / nachher, A11y-Settings-Liste / -Toggle / -Confirm) und später
# in Ruhe entscheiden welche Files wir tatsächlich im Pre-Explainer zeigen.
#
# Usage:
# ./grab-onboarding-screenshot.sh <lang> <dialog>
# <lang> = de | en | fr | ar (oder beliebig)
# <dialog> = freier Name (z.B. android-vpn-permission, android-a11y-step1,
# android-a11y-toggle, ios-permission, etc.)
#
# Beispiele:
# ./grab-onboarding-screenshot.sh de android-vpn-permission
# ./grab-onboarding-screenshot.sh de android-vpn-permission # → -002.png
# ./grab-onboarding-screenshot.sh de android-a11y-overview
# ./grab-onboarding-screenshot.sh en android-a11y-rebreak-toggle
set -euo pipefail
LANG="${1:-}"
DIALOG="${2:-}"
if [[ -z "$LANG" || -z "$DIALOG" ]]; then
echo "Usage: $0 <lang> <dialog>"
echo " lang: de | en | fr | ar (oder anderer Folder-Name)"
echo " dialog: freier Name (z.B. android-vpn-permission, android-a11y-step1)"
exit 1
fi
# Validate lang nicht hart — User kann auch debug/misc/etc anlegen wenn nötig.
# Nur Sanity-Check: alphanum + dash + underscore.
if [[ ! "$LANG" =~ ^[a-zA-Z0-9_-]+$ ]]; then
echo "Invalid lang/folder name: $LANG"; exit 1
fi
if [[ ! "$DIALOG" =~ ^[a-zA-Z0-9_-]+$ ]]; then
echo "Invalid dialog name: $DIALOG"; exit 1
fi
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
TARGET_DIR="$SCRIPT_DIR/../assets/onboarding/$LANG"
mkdir -p "$TARGET_DIR"
# Auto-Numbering: finde nächste freie ${DIALOG}-NNN.png
NEXT_NUM=1
while [[ -f "$TARGET_DIR/${DIALOG}-$(printf "%03d" $NEXT_NUM).png" ]]; do
NEXT_NUM=$((NEXT_NUM + 1))
done
TARGET_FILE="$TARGET_DIR/${DIALOG}-$(printf "%03d" $NEXT_NUM).png"
if ! command -v adb >/dev/null 2>&1; then
echo "adb nicht installiert. Brew: brew install --cask android-platform-tools"
exit 1
fi
DEVICE_COUNT=$(adb devices | grep -c "^[a-zA-Z0-9].*device$" || true)
if [[ "$DEVICE_COUNT" -lt 1 ]]; then
echo "Kein Android-Device verbunden. USB-Debugging an + adb devices checken."
exit 1
fi
echo "→ pull screenshot to $TARGET_FILE"
adb exec-out screencap -p > "$TARGET_FILE"
if [[ -s "$TARGET_FILE" ]]; then
SIZE=$(du -h "$TARGET_FILE" | cut -f1)
COUNT=$(ls -1 "$TARGET_DIR/${DIALOG}-"*.png 2>/dev/null | wc -l | tr -d ' ')
echo "✓ saved $SIZE${DIALOG}-$(printf "%03d" $NEXT_NUM).png ($COUNT total for this dialog)"
else
echo "✗ screenshot empty — adb issue?"
rm -f "$TARGET_FILE"
exit 1
fi

View File

@ -30,6 +30,7 @@ export interface CommunityPost {
challengeStatus?: 'OPEN' | 'ACTIVE' | 'FINISHED' | 'CANCELLED' | null;
opponentName?: string | null;
isLive?: boolean;
i18nKey?: string | null;
userVote?: 'yes' | 'no' | null;
submission?: {
id: string;

View File

@ -0,0 +1,5 @@
-- Migration: add i18n_key column to community_posts
-- Nullable: legacy posts remain untouched (no backfill).
-- New Lyra-posts generated via template catalog will have this set.
ALTER TABLE rebreak.community_posts ADD COLUMN i18n_key TEXT;

View File

@ -223,6 +223,10 @@ model CommunityPost {
gameName String? @map("game_name")
repostOfId String? @map("repost_of_id") @db.Uuid
challengeId String? @map("challenge_id") @db.Uuid
/// Template-ID aus dem Lyra-Post-Catalog (z.B. "motivation_quiet_01").
/// Nullable: Legacy-Posts ohne Template-Key nutzen content direkt.
/// Frontend rendert t('lyra_posts.<i18nKey>') wenn gesetzt.
i18nKey String? @map("i18n_key")
createdAt DateTime @default(now()) @map("created_at")
author Profile? @relation(fields: [userId], references: [id])

View File

@ -88,6 +88,7 @@ export default defineEventHandler(async (event) => {
}
: null,
userVote: userDomainVotes[p.id] ?? null,
i18nKey: (p as any).i18nKey ?? null,
repostOfId: (p as any).repostOfId ?? null,
repostOf: (p as any).repostOf
? {

View File

@ -1,5 +1,9 @@
import { createPost } from "../../db/community";
import { usePrisma } from "../../utils/prisma";
import {
pickRandomTemplate,
LYRA_POST_CATALOG,
} from "../../lib/lyraPostCatalog";
/**
* POST /api/cron/lyra-post
@ -11,10 +15,14 @@ import { usePrisma } from "../../utils/prisma";
* 0 10 * * 1,3,5 curl -X POST https://rebreak.org/api/cron/lyra-post \
* -H "x-cron-secret: $NUXT_CRON_SECRET"
*
* Feature-Flag:
* USE_TEMPLATE_CATALOG=true Template-Catalog (i18n-fähig, kein LLM)
* USE_TEMPLATE_CATALOG=false LLM-Path via OpenRouter (Legacy, Default)
*
* Infisical Secrets:
* NUXT_LYRA_BOT_USER_ID UUID des Lyra-Profils in der DB
* NUXT_CRON_SECRET zufälliger langer Token
* NUXT_OPENROUTER_API_KEY bereits vorhanden
* NUXT_OPENROUTER_API_KEY bereits vorhanden (nur LLM-Path)
*
* Einmalig auf Server einrichten:
* Registriere einen Account mit Username "lyra" in der App,
@ -64,10 +72,6 @@ export default defineEventHandler(async (event) => {
});
}
if (!config.openrouterApiKey) {
throw createError({ statusCode: 500, message: "OpenRouter API Key fehlt" });
}
// Max 3x pro Woche: letzten Lyra-Post prüfen
const db = usePrisma();
const threeDaysAgo = new Date(Date.now() - 3 * 24 * 60 * 60 * 1000);
@ -86,6 +90,56 @@ export default defineEventHandler(async (event) => {
};
}
// Feature-flag: USE_TEMPLATE_CATALOG=true → template-path, false → LLM-path
const useTemplateCatalog =
process.env.USE_TEMPLATE_CATALOG === "true";
if (useTemplateCatalog) {
return await postFromCatalog(db, lyraBotUserId);
} else {
return await postFromLLM(db, lyraBotUserId, config);
}
});
// ── Template-Catalog Path ────────────────────────────────────────────────────
async function postFromCatalog(db: ReturnType<typeof usePrisma>, lyraBotUserId: string) {
// Collect recently used template IDs (last 30 posts) to avoid repeats
const recentPosts = await db.communityPost.findMany({
where: { userId: lyraBotUserId },
orderBy: { createdAt: "desc" },
take: LYRA_POST_CATALOG.length,
select: { i18nKey: true },
});
const usedIds = recentPosts
.map((p) => p.i18nKey)
.filter((k): k is string => !!k);
const template = pickRandomTemplate(usedIds);
// content = DE-fallback text so the DB column is never empty.
// Frontend will prefer the i18nKey translation when available.
// NOTE: DE fallback text is fetched from locale at runtime in the future;
// for now we store the template ID as a sentinel so legacy fallback still
// works. Production should have DE locale populated before enabling flag.
const fallbackContent = `[lyra:${template.id}]`;
const post = await createPost(lyraBotUserId, "community", fallbackContent, undefined, null, template.id);
return { success: true, postId: post.id, topic: template.topic, i18nKey: template.id, path: "catalog" };
}
// ── LLM Path (Legacy) ────────────────────────────────────────────────────────
async function postFromLLM(
_db: ReturnType<typeof usePrisma>,
lyraBotUserId: string,
config: ReturnType<typeof useRuntimeConfig>,
) {
if (!config.openrouterApiKey) {
throw createError({ statusCode: 500, message: "OpenRouter API Key fehlt" });
}
// Zufälliges Thema
const topic = TOPICS[Math.floor(Math.random() * TOPICS.length)];
@ -129,5 +183,5 @@ export default defineEventHandler(async (event) => {
const post = await createPost(lyraBotUserId, "community", content);
return { success: true, postId: post.id, topic };
});
return { success: true, postId: post.id, topic, path: "llm" };
}

View File

@ -257,6 +257,7 @@ export async function createPost(
content: string,
imageUrl?: string,
gameName?: string | null,
i18nKey?: string | null,
) {
const db = usePrisma();
return db.communityPost.create({
@ -266,6 +267,7 @@ export async function createPost(
content,
imageUrl: imageUrl || null,
gameName: gameName ?? null,
i18nKey: i18nKey ?? null,
isAnonymous: false,
isModerated: false,
},

View File

@ -0,0 +1,60 @@
/**
* Lyra Community Post Template Catalog
*
* Each entry has a stable ID that maps to a locale key in:
* apps/rebreak-native/locales/{de,en,fr,ar}.json lyra_posts.<id>
*
* The `topic` field mirrors the existing TOPICS enum in lyra-post.ts and is
* used for throttle-logic and potential future category filtering.
*
* Texts live in locale files this file is locale-agnostic.
* EN/FR/AR texts are curated by lyra-persona agent (pending).
*
* Target: ~60 templates (15 per topic). Currently: 15 scaffolded.
*/
export type LyraPostTopic = 'motivation' | 'tipp' | 'zitat' | 'witzig' | 'news' | 'feature';
export interface LyraPostTemplate {
id: string;
topic: LyraPostTopic;
}
export const LYRA_POST_CATALOG: LyraPostTemplate[] = [
// ── motivation (5) ──────────────────────────────────────────────────────
{ id: 'motivation_quiet_01', topic: 'motivation' },
{ id: 'motivation_quiet_02', topic: 'motivation' },
{ id: 'motivation_quiet_03', topic: 'motivation' },
{ id: 'motivation_distance_01', topic: 'motivation' },
{ id: 'motivation_distance_02', topic: 'motivation' },
// ── tipp (4) ─────────────────────────────────────────────────────────────
{ id: 'tipp_breath_01', topic: 'tipp' },
{ id: 'tipp_urge_surf_01', topic: 'tipp' },
{ id: 'tipp_habit_replace_01', topic: 'tipp' },
{ id: 'tipp_sos_reminder_01', topic: 'tipp' },
// ── zitat (2) ────────────────────────────────────────────────────────────
{ id: 'zitat_stoic_01', topic: 'zitat' },
{ id: 'zitat_psychology_01', topic: 'zitat' },
// ── witzig (2) ───────────────────────────────────────────────────────────
{ id: 'witzig_impulse_01', topic: 'witzig' },
{ id: 'witzig_distraction_01', topic: 'witzig' },
// ── news (1) ─────────────────────────────────────────────────────────────
{ id: 'news_push_tactics_01', topic: 'news' },
// ── feature (1) ──────────────────────────────────────────────────────────
{ id: 'feature_sos_01', topic: 'feature' },
];
/**
* Returns a random template from the catalog.
* Optional: pass `excludeIds` (e.g. recently used) to avoid repeats.
*/
export function pickRandomTemplate(excludeIds: string[] = []): LyraPostTemplate {
const pool = LYRA_POST_CATALOG.filter((t) => !excludeIds.includes(t.id));
const source = pool.length > 0 ? pool : LYRA_POST_CATALOG;
return source[Math.floor(Math.random() * source.length)];
}