chahinebrini 2e409efaf0 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>
2026-05-17 23:48:25 +02:00

463 lines
13 KiB
TypeScript

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';
import { CTABar } from '../CTABar';
import { ScreenshotPointer } from '../ScreenshotPointer';
import { PermissionDeniedSheet } from '../../PermissionDeniedSheet';
import i18n from '../../../lib/i18n';
/**
* Onboarding-Schutz-Step.
*
* Platform.OS-Dispatch:
* iOS → IosProtectionSlide (NEFilter + Family-Controls)
* Android → AndroidProtectionSlide (VpnService + Accessibility-Tamper-Lock)
*
* 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.
*/
export function ProtectionSlide(props: {
onDone: () => void;
current: number;
total: number;
}) {
if (Platform.OS === 'android') {
return <AndroidProtectionSlide {...props} />;
}
return <IosProtectionSlide {...props} />;
}
// ─── iOS ────────────────────────────────────────────────────────────────────
type IosPhase = 'preexplain_url' | 'preexplain_lock' | 'done';
function IosProtectionSlide({
onDone,
current,
total,
}: {
onDone: () => void;
current: number;
total: number;
}) {
const { t } = useTranslation();
const [phase, setPhase] = useState<IosPhase>('preexplain_url');
const [activating, setActivating] = useState(false);
const [permissionDeniedOpen, setPermissionDeniedOpen] = useState(false);
async function activateUrlFilter() {
if (activating) return;
setActivating(true);
try {
const res = await protection.activateUrlFilter();
if (!res.enabled) {
const isCodeFive =
typeof res.error === 'string' &&
/NEFilterErrorDomain:\s*5/i.test(res.error);
if (isCodeFive) {
setPermissionDeniedOpen(true);
return;
}
Alert.alert(
t('onboarding.protection.error_title'),
res.error ?? t('onboarding.protection.error_unknown'),
);
return;
}
setPhase('preexplain_lock');
} finally {
setActivating(false);
}
}
async function activateAppLock() {
if (activating) return;
setActivating(true);
try {
const res = await protection.activateFamilyControls();
if (!res.enabled) {
Alert.alert(
t('onboarding.protection.applock_failed_title'),
res.error ?? t('onboarding.protection.applock_failed_msg'),
[
{
text: t('onboarding.protection.applock_skip'),
style: 'cancel',
onPress: () => finishProtectionStep(),
},
{ text: t('common.retry'), onPress: activateAppLock },
],
);
return;
}
finishProtectionStep();
} finally {
setActivating(false);
}
}
async function finishProtectionStep() {
await apiFetch('/api/profile/me/onboarding-step', {
method: 'PATCH',
body: { step: 'done' },
}).catch(() => {});
invalidateMe();
setPhase('done');
onDone();
}
if (phase === 'preexplain_url') {
return (
<PreExplainer
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}
total={total}
>
<PermissionDeniedSheet
visible={permissionDeniedOpen}
onClose={() => setPermissionDeniedOpen(false)}
onRetry={async () => {
const res = await protection.resetUrlFilter();
if (res.enabled) setPhase('preexplain_lock');
return res;
}}
/>
</PreExplainer>
);
}
if (phase === 'preexplain_lock') {
return (
<PreExplainer
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}
/>
);
}
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) ───────────────────────────────────────────────────
function PreExplainer({
dialog,
lyraBodyKey,
titleKey,
ctaKey,
buttonLabelKey,
markerHintKey,
activating,
onActivate,
current,
total,
children,
}: {
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;
total: number;
children?: React.ReactNode;
}) {
const { t } = useTranslation();
const colors = useColors();
const { height: screenH } = useWindowDimensions();
const lang = i18n.language || 'de';
const screenshot = getPermissionScreenshot(dialog, lang);
// 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
// skaliert es mit. Min 200, Max 320.
const screenshotHeight = Math.min(320, Math.max(200, screenH * 0.32));
return (
<OnboardingShell
current={current}
total={total}
cta={
<CTABar
primaryLabel={t(ctaKey)}
onPrimary={onActivate}
primaryLoading={activating}
/>
}
>
<LyraBubble text={t(lyraBodyKey)} emotion="empathy" />
<Text
style={{
marginTop: 14,
fontFamily: 'Nunito_700Bold',
fontSize: 12,
letterSpacing: 0.6,
color: colors.textMuted,
textTransform: 'uppercase',
textAlign: 'center',
}}
>
{t(titleKey)}
</Text>
<View
style={{
marginTop: 8,
alignSelf: 'center',
height: screenshotHeight,
aspectRatio: 0.9,
}}
>
<Image
source={screenshot}
style={{ width: '100%', height: '100%' }}
resizeMode="contain"
/>
</View>
<ScreenshotPointer buttonLabel={t(buttonLabelKey)} />
<Text
style={{
marginTop: 10,
fontFamily: 'Nunito_400Regular',
fontSize: 12,
lineHeight: 17,
color: colors.textMuted,
textAlign: 'center',
paddingHorizontal: 8,
}}
>
{t(markerHintKey)}
</Text>
{children}
</OnboardingShell>
);
}