Tamper-Lock von Keyword-Scanning auf präzise Einzel-Surfaces umgebaut: blockt nur ReBreaks eigene Screens (Admin-Deaktivierung via DeviceAdminAdd, a11y-Ausschalten, VPN-Trennen/Surface), nie Listen oder fremde Apps. - Deny-Removal = Admin-only: OS graut Uninstall+Force-Stop für aktiven Device-Admin aus; einziger Bypass (Admin deaktivieren) bleibt a11y-gesperrt. Andere Apps verwalten/force-stoppen/deinstallieren bleibt komplett frei. - a11y-Onboarding: passiver Bottom-Overlay-Hinweis + Settings-Reset auf Startseite nach Aktivierung + 1s-Delay vor App-Rückkehr. - VPN-Trennen-Dialog + a11y-Ausschalten neu abgedeckt. - a11y-Service-Icon im Plugin (klar als ReBreak erkennbar). Verifiziert auf A50 per logcat: alle 4 Surfaces blocken, Listen + fremde Apps frei, keine False-Positives. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
213 lines
7.4 KiB
TypeScript
213 lines
7.4 KiB
TypeScript
import { useEffect, useMemo, useState } from 'react';
|
|
import { useRouter } from 'expo-router';
|
|
import { useMe, invalidateMe, type OnboardingStep } from '../../hooks/useMe';
|
|
import { useLyraVoiceStore } from '../../stores/lyraVoice';
|
|
import { apiFetch } from '../../lib/api';
|
|
import { WelcomeSlide } from '../../components/onboarding/slides/WelcomeSlide';
|
|
import { PrivacySlide } from '../../components/onboarding/slides/PrivacySlide';
|
|
import { NicknameSlide } from '../../components/onboarding/slides/NicknameSlide';
|
|
import { DigaChoiceSlide } from '../../components/onboarding/slides/DigaChoiceSlide';
|
|
import { DigaCodeSlide } from '../../components/onboarding/slides/DigaCodeSlide';
|
|
import { PlanSlide } from '../../components/onboarding/slides/PlanSlide';
|
|
import { PaymentSlide } from '../../components/onboarding/slides/PaymentSlide';
|
|
import { ProtectionSlide } from '../../components/onboarding/slides/ProtectionSlide';
|
|
import { DoneSlide } from '../../components/onboarding/slides/DoneSlide';
|
|
import { OnboardingNavProvider } from '../../components/onboarding/OnboardingNavContext';
|
|
|
|
/**
|
|
* Duo-Style Onboarding — single route, state-machine intern.
|
|
*
|
|
* Linear-Flow:
|
|
* [1] welcome Lyra stellt sich vor
|
|
* [2] privacy DSGVO-Versprechen
|
|
* [3] nickname Inline-Input + PATCH /me + step='account'
|
|
* [4] diga_choice "Hast du Rezept-Code?" Ja/Nein
|
|
* │
|
|
* ├─ Ja → [4a] diga_code (Branch)
|
|
* │ redeem → step='pre_protection' → SKIP to [7]
|
|
* │
|
|
* └─ Nein →
|
|
* [5] plan Pro/Legend selection → step='plan'
|
|
* [6] payment DEV-Stub (RevenueCat-Phase pending) → step='pre_protection'
|
|
* [7] protection activateUrlFilter() → step='done'
|
|
* [8] done Geschafft-Screen → /(app)
|
|
*
|
|
* Resume: routing-gate (app/(app)/_layout.tsx) routet zu /onboarding wenn
|
|
* step != 'done'. Hier mappt slideFromStep den persistierten step zur Slide.
|
|
*/
|
|
type Slide =
|
|
| 'welcome'
|
|
| 'privacy'
|
|
| 'nickname'
|
|
| 'diga_choice'
|
|
| 'diga_code'
|
|
| 'plan'
|
|
| 'payment'
|
|
| 'protection'
|
|
| 'done';
|
|
|
|
// Linear-Order für Progress-Indicator + Default-Next-Navigation.
|
|
// diga_code ist NICHT in der Linear-Liste — wird via Branch erreicht.
|
|
const LINEAR_ORDER: Slide[] = [
|
|
'welcome',
|
|
'privacy',
|
|
'nickname',
|
|
'diga_choice',
|
|
'plan',
|
|
'payment',
|
|
'protection',
|
|
'done',
|
|
];
|
|
|
|
function slideFromStep(step: OnboardingStep): Slide {
|
|
switch (step) {
|
|
case 'welcome':
|
|
return 'welcome';
|
|
case 'account':
|
|
return 'diga_choice';
|
|
case 'plan':
|
|
return 'payment';
|
|
case 'pre_protection':
|
|
return 'protection';
|
|
case 'done':
|
|
return 'done';
|
|
case 'nickname': // legacy
|
|
return 'nickname';
|
|
case 'block': // legacy
|
|
return 'protection';
|
|
default:
|
|
return 'welcome';
|
|
}
|
|
}
|
|
|
|
export default function OnboardingScreen() {
|
|
const router = useRouter();
|
|
const { me } = useMe();
|
|
const initialSlide = useMemo<Slide>(
|
|
() => (me ? slideFromStep(me.onboardingStep) : 'welcome'),
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
[me?.id],
|
|
);
|
|
const [slide, setSlide] = useState<Slide>(initialSlide);
|
|
|
|
// Lyra-Voice fürs Onboarding automatisch an — sie begleitet/spricht jede Slide
|
|
// vor (User kann per Volume-Button stummschalten). Beim Verlassen den vorigen
|
|
// Wert wiederherstellen, damit der App-weite Default unangetastet bleibt.
|
|
const voiceReady = useLyraVoiceStore((s) => s.ready);
|
|
useEffect(() => {
|
|
if (!voiceReady) return;
|
|
const prev = useLyraVoiceStore.getState().enabled;
|
|
void useLyraVoiceStore.getState().setEnabled(true);
|
|
return () => {
|
|
void useLyraVoiceStore.getState().setEnabled(prev);
|
|
};
|
|
}, [voiceReady]);
|
|
|
|
function goToLinearNext() {
|
|
const idx = LINEAR_ORDER.indexOf(slide);
|
|
if (idx < 0 || idx === LINEAR_ORDER.length - 1) {
|
|
router.replace('/(app)');
|
|
return;
|
|
}
|
|
setSlide(LINEAR_ORDER[idx + 1]);
|
|
}
|
|
|
|
function goToLinearPrevious() {
|
|
const idx = LINEAR_ORDER.indexOf(slide);
|
|
if (idx <= 0) return;
|
|
setSlide(LINEAR_ORDER[idx - 1]);
|
|
}
|
|
|
|
// Back erlaubt auf Info-/Auswahl-Slides + protection. NICHT auf:
|
|
// welcome (erste), done (final), diga_code (hat eigenen onBack).
|
|
// protection: seit dem Card-Flow keine internen Phasen mehr → Back zur vorigen
|
|
// Slide ist unkritisch (aktivierter Schutz bleibt via Layer-State erhalten).
|
|
const BACK_ALLOWED: Slide[] = ['privacy', 'nickname', 'diga_choice', 'plan', 'payment', 'protection'];
|
|
const canGoBack = BACK_ALLOWED.includes(slide);
|
|
|
|
function exitToApp() {
|
|
router.replace('/(app)');
|
|
}
|
|
|
|
// Branch-Handler ─────────────────────────────────────────────────────────────
|
|
|
|
function onDigaYes() {
|
|
setSlide('diga_code');
|
|
}
|
|
function onDigaNo() {
|
|
setSlide('plan');
|
|
}
|
|
function onDigaCodeSuccess() {
|
|
// Backend hat step='pre_protection' gesetzt → skip plan + payment
|
|
setSlide('protection');
|
|
}
|
|
async function onPlanChosen(_tier: 'pro' | 'legend', _billing: 'monthly' | 'yearly') {
|
|
// TODO: tier + billing an PaymentSlide weiterreichen sobald RevenueCat
|
|
// wired ist (Phase 0). Bis dahin nur step persistieren.
|
|
await apiFetch('/api/profile/me/onboarding-step', {
|
|
method: 'PATCH',
|
|
body: { step: 'plan' },
|
|
}).catch(() => {});
|
|
invalidateMe();
|
|
setSlide('payment');
|
|
}
|
|
|
|
// Linear-Indizes für Progress (diga_code wird wie diga_choice gezählt) ──────
|
|
|
|
const linearIdx = (() => {
|
|
if (slide === 'diga_code') return LINEAR_ORDER.indexOf('diga_choice');
|
|
return LINEAR_ORDER.indexOf(slide);
|
|
})();
|
|
const current = Math.max(1, linearIdx + 1);
|
|
const total = LINEAR_ORDER.length;
|
|
|
|
// Slide-Dispatch ────────────────────────────────────────────────────────────
|
|
|
|
function renderSlide() {
|
|
switch (slide) {
|
|
case 'welcome':
|
|
return <WelcomeSlide onNext={goToLinearNext} current={current} total={total} />;
|
|
case 'privacy':
|
|
return <PrivacySlide onNext={goToLinearNext} current={current} total={total} />;
|
|
case 'nickname':
|
|
return <NicknameSlide onNext={goToLinearNext} current={current} total={total} />;
|
|
case 'diga_choice':
|
|
return (
|
|
<DigaChoiceSlide
|
|
onYes={onDigaYes}
|
|
onNo={onDigaNo}
|
|
current={current}
|
|
total={total}
|
|
/>
|
|
);
|
|
case 'diga_code':
|
|
return (
|
|
<DigaCodeSlide
|
|
onSuccess={onDigaCodeSuccess}
|
|
onBack={() => setSlide('diga_choice')}
|
|
current={current}
|
|
total={total}
|
|
/>
|
|
);
|
|
case 'plan':
|
|
return <PlanSlide onChosen={onPlanChosen} current={current} total={total} />;
|
|
case 'payment':
|
|
return (
|
|
<PaymentSlide onCompleted={goToLinearNext} current={current} total={total} />
|
|
);
|
|
case 'protection':
|
|
return (
|
|
<ProtectionSlide onDone={goToLinearNext} current={current} total={total} />
|
|
);
|
|
case 'done':
|
|
return <DoneSlide onEnter={exitToApp} current={current} total={total} />;
|
|
}
|
|
}
|
|
|
|
return (
|
|
<OnboardingNavProvider goBack={canGoBack ? goToLinearPrevious : null}>
|
|
{renderSlide()}
|
|
</OnboardingNavProvider>
|
|
);
|
|
}
|