Auth / FaceID — eingeloggt bleiben funktioniert jetzt: - AppLock-Init idempotent: late re-init durch router.replace-Re-Mount behält locked-State (fixt Endlosschleife: unlock → re-mount → init reset → lock) - LockScreen-Auto-Prompt nur wenn AppState=active (verhindert silent FaceID- Fail wenn LockScreen während background-Event mountet — User sah dann nur Fallback-Button) - index.tsx: wenn Session schon in AsyncStorage liegt → router.replace zu /(app), Landing wird übersprungen; early-return nach allen Hooks (Rules of Hooks) - WebBrowser.dismissAuthSession vor openAuthSessionAsync (verhindert "Another web browser is already open" nach abgebrochenen OAuth-Flows) UI — iOS-Grouped-Look auf Settings + Profile: - Neue Theme-Tokens groupedBg (#F2F2F7 / #000) + card (#fff / #1c1c1e), identisch zu Apples systemGroupedBackground / secondarySystemGroupedBackground - settings.tsx + profile/index.tsx + profile/[userId].tsx: Page-BG → groupedBg - StreakSection / UrgeStatsCard / DemographicsAccordion / StatsBar / ApprovedDomainsList: Card-BG colors.surface → colors.card Mail-Connect — Outlook-Tile entschärft: - Microsoft hat App-Passwords für consumer-Outlook (.com/hotmail/live/msn) im September 2024 abgeschaltet, der bisherige Guide-Flow ist seit ~8 Monaten wirkungslos → AUTHENTICATIONFAILED - Tile bleibt sichtbar mit opacity 0.45, "Kommt bald"-Sub-Label, disabled=true - Provider-Typ um disabled? + disabledLabelKey? erweitert (wiederverwendbar) - Backend-OAuth-Plan unter backend/docs/mail-outlook-oauth-plan.md (mo) → Generisches AuthMethod-Framework (app_password | oauth) geplant Profile — Cooldown-Verlauf als Sparkline statt Endlos-Liste: - 8 Wochen-Buckets, Bar-Höhe nach Frequenz (cap 5/Woche), leere Wochen als 2px-Flatlines - Sub-Label: "{n} Cooldowns in 8 Wochen · Ø 1 pro {avg} Wochen · zuletzt {date}" - Neutral formuliert (Sucht-/Stigma-Sensibilität: Cooldown = Schutz-Pause, kein Rückfall) - useProfileData.ts liefert rawStartedAt (ISO) zusätzlich zum formatierten Wert - i18n-Keys unter profile.cooldown.* in DE + EN Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
261 lines
8.7 KiB
TypeScript
261 lines
8.7 KiB
TypeScript
import { useEffect, useRef } from 'react';
|
|
import { Animated, Dimensions, Image, Text, TouchableOpacity, View } from 'react-native';
|
|
import Svg, { Defs, RadialGradient, Rect, Stop } from 'react-native-svg';
|
|
import { useRouter } from 'expo-router';
|
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { useAuthStore } from '../stores/auth';
|
|
|
|
const { width: SW, height: SH } = Dimensions.get('window');
|
|
|
|
export default function LandingScreen() {
|
|
const router = useRouter();
|
|
const insets = useSafeAreaInsets();
|
|
const { t } = useTranslation();
|
|
|
|
// Reaktiver Routing-Fix für „eingeloggt bleiben": wenn beim Cold-Start (oder
|
|
// nach einem `router.replace('/')` aus dem LockScreen-Sign-Out) bereits eine
|
|
// gültige Session in AsyncStorage liegt, überspringen wir das Landing und
|
|
// schicken den User direkt in `(app)`.
|
|
const session = useAuthStore((s) => s.session);
|
|
const loading = useAuthStore((s) => s.loading);
|
|
|
|
useEffect(() => {
|
|
if (!loading && session) {
|
|
router.replace('/(app)');
|
|
}
|
|
}, [loading, session, router]);
|
|
|
|
const glowTopOpacity = useRef(new Animated.Value(0.5)).current;
|
|
const glowCenterOpacity = useRef(new Animated.Value(0)).current;
|
|
const glowCenterScale = useRef(new Animated.Value(0.6)).current;
|
|
|
|
const nameOpacity = useRef(new Animated.Value(0)).current;
|
|
const nameTranslateY = useRef(new Animated.Value(12)).current;
|
|
|
|
const logoOpacity = useRef(new Animated.Value(0)).current;
|
|
const logoScale = useRef(new Animated.Value(0.82)).current;
|
|
const logoTranslateY = useRef(new Animated.Value(8)).current;
|
|
const logoPulse = useRef(new Animated.Value(1)).current;
|
|
|
|
const taglineOpacity = useRef(new Animated.Value(0)).current;
|
|
const taglineTranslateY = useRef(new Animated.Value(8)).current;
|
|
|
|
const ctaOpacity = useRef(new Animated.Value(0)).current;
|
|
const ctaTranslateY = useRef(new Animated.Value(10)).current;
|
|
|
|
const footerOpacity = useRef(new Animated.Value(0)).current;
|
|
|
|
useEffect(() => {
|
|
Animated.loop(
|
|
Animated.sequence([
|
|
Animated.timing(glowTopOpacity, { toValue: 0.9, duration: 2000, useNativeDriver: true }),
|
|
Animated.timing(glowTopOpacity, { toValue: 0.5, duration: 2000, useNativeDriver: true }),
|
|
]),
|
|
).start();
|
|
|
|
const ease = (toValue: number, duration: number) => ({ toValue, duration, useNativeDriver: true });
|
|
|
|
Animated.parallel([
|
|
Animated.timing(glowCenterOpacity, ease(1, 900)),
|
|
Animated.timing(glowCenterScale, ease(1, 900)),
|
|
]).start();
|
|
|
|
setTimeout(() => {
|
|
Animated.parallel([
|
|
Animated.timing(nameOpacity, ease(1, 600)),
|
|
Animated.timing(nameTranslateY, ease(0, 600)),
|
|
]).start();
|
|
}, 300);
|
|
|
|
setTimeout(() => {
|
|
Animated.parallel([
|
|
Animated.timing(logoOpacity, ease(1, 650)),
|
|
Animated.spring(logoScale, { toValue: 1, useNativeDriver: true, friction: 6, tension: 80 }),
|
|
Animated.timing(logoTranslateY, ease(0, 650)),
|
|
]).start();
|
|
}, 700);
|
|
|
|
setTimeout(() => {
|
|
Animated.loop(
|
|
Animated.sequence([
|
|
Animated.timing(logoPulse, { toValue: 1.04, duration: 1300, useNativeDriver: true }),
|
|
Animated.timing(logoPulse, { toValue: 1, duration: 1300, useNativeDriver: true }),
|
|
]),
|
|
).start();
|
|
}, 1100);
|
|
|
|
setTimeout(() => {
|
|
Animated.parallel([
|
|
Animated.timing(taglineOpacity, ease(1, 550)),
|
|
Animated.timing(taglineTranslateY, ease(0, 550)),
|
|
]).start();
|
|
}, 1300);
|
|
|
|
setTimeout(() => {
|
|
Animated.parallel([
|
|
Animated.timing(ctaOpacity, ease(1, 500)),
|
|
Animated.timing(ctaTranslateY, ease(0, 500)),
|
|
Animated.timing(footerOpacity, ease(1, 600)),
|
|
]).start();
|
|
}, 1700);
|
|
}, [
|
|
glowTopOpacity, glowCenterOpacity, glowCenterScale,
|
|
nameOpacity, nameTranslateY, logoOpacity, logoScale, logoTranslateY,
|
|
logoPulse, taglineOpacity, taglineTranslateY, ctaOpacity, ctaTranslateY, footerOpacity,
|
|
]);
|
|
|
|
// Early-return MUSS nach allen Hooks stehen (Rules of Hooks) — sonst wirft
|
|
// React "Rendered fewer hooks than expected" wenn sich loading/session zwischen
|
|
// Renders ändert.
|
|
if (loading || session) return null;
|
|
|
|
return (
|
|
<View style={{ flex: 1, backgroundColor: '#0f172a', overflow: 'hidden' }}>
|
|
{/* Top breathing glow */}
|
|
<Animated.View
|
|
pointerEvents="none"
|
|
style={{ position: 'absolute', top: 0, left: 0, right: 0, height: SH * 0.5, opacity: glowTopOpacity }}
|
|
>
|
|
<Svg width="100%" height="100%">
|
|
<Defs>
|
|
<RadialGradient id="topGlowL" cx="50%" cy="0%" rx="70%" ry="100%" fx="50%" fy="0%">
|
|
<Stop offset="0%" stopColor="#1e3a8a" stopOpacity="1" />
|
|
<Stop offset="100%" stopColor="#1e3a8a" stopOpacity="0" />
|
|
</RadialGradient>
|
|
</Defs>
|
|
<Rect width="100%" height="100%" fill="url(#topGlowL)" />
|
|
</Svg>
|
|
</Animated.View>
|
|
|
|
{/* Center indigo halo */}
|
|
<Animated.View
|
|
pointerEvents="none"
|
|
style={{
|
|
position: 'absolute', top: 0, left: 0, right: 0, bottom: 0,
|
|
opacity: glowCenterOpacity, transform: [{ scale: glowCenterScale }],
|
|
}}
|
|
>
|
|
<Svg width="100%" height="100%">
|
|
<Defs>
|
|
<RadialGradient id="centerHaloL" cx="50%" cy="45%" rx="55%" ry="55%">
|
|
<Stop offset="0%" stopColor="#6366f1" stopOpacity="0.22" />
|
|
<Stop offset="100%" stopColor="#6366f1" stopOpacity="0" />
|
|
</RadialGradient>
|
|
</Defs>
|
|
<Rect width="100%" height="100%" fill="url(#centerHaloL)" />
|
|
</Svg>
|
|
</Animated.View>
|
|
|
|
{/* Content */}
|
|
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', gap: 20, paddingHorizontal: 24 }}>
|
|
<Animated.Text
|
|
style={{
|
|
fontFamily: 'Nunito_800ExtraBold',
|
|
fontSize: 48,
|
|
letterSpacing: -1,
|
|
color: '#ffffff',
|
|
textAlign: 'center',
|
|
marginBottom: 8,
|
|
opacity: nameOpacity,
|
|
transform: [{ translateY: nameTranslateY }],
|
|
}}
|
|
>
|
|
{t('appHeader.appName')}
|
|
</Animated.Text>
|
|
|
|
<Animated.View
|
|
style={{
|
|
opacity: logoOpacity,
|
|
transform: [
|
|
{ scale: Animated.multiply(logoScale, logoPulse) as any },
|
|
{ translateY: logoTranslateY },
|
|
],
|
|
}}
|
|
>
|
|
<Image
|
|
source={require('../assets/icon.png')}
|
|
style={{ width: 160, height: 160, borderRadius: 28 }}
|
|
resizeMode="contain"
|
|
/>
|
|
</Animated.View>
|
|
|
|
<Animated.Text
|
|
style={{
|
|
fontFamily: 'Nunito_600SemiBold',
|
|
fontSize: 20,
|
|
letterSpacing: 0.2,
|
|
color: 'rgba(255,255,255,0.90)',
|
|
textAlign: 'center',
|
|
marginTop: 4,
|
|
opacity: taglineOpacity,
|
|
transform: [{ translateY: taglineTranslateY }],
|
|
}}
|
|
>
|
|
{t('splash.tagline')}
|
|
</Animated.Text>
|
|
|
|
<Animated.View
|
|
style={{
|
|
alignSelf: 'stretch',
|
|
gap: 12,
|
|
marginTop: 16,
|
|
opacity: ctaOpacity,
|
|
transform: [{ translateY: ctaTranslateY }],
|
|
}}
|
|
>
|
|
<TouchableOpacity
|
|
onPress={() => router.push('/signin')}
|
|
activeOpacity={0.85}
|
|
style={{
|
|
backgroundColor: '#6366f1',
|
|
borderRadius: 16,
|
|
paddingVertical: 16,
|
|
alignItems: 'center',
|
|
}}
|
|
>
|
|
<Text style={{ fontFamily: 'Nunito_700Bold', fontSize: 16, color: '#ffffff' }}>
|
|
{t('auth.signin')}
|
|
</Text>
|
|
</TouchableOpacity>
|
|
|
|
<TouchableOpacity
|
|
onPress={() => router.push('/signup')}
|
|
activeOpacity={0.85}
|
|
style={{
|
|
borderWidth: 1.5,
|
|
borderColor: 'rgba(255,255,255,0.25)',
|
|
borderRadius: 16,
|
|
paddingVertical: 16,
|
|
alignItems: 'center',
|
|
}}
|
|
>
|
|
<Text style={{ fontFamily: 'Nunito_600SemiBold', fontSize: 16, color: 'rgba(255,255,255,0.85)' }}>
|
|
{t('landing.start')}
|
|
</Text>
|
|
</TouchableOpacity>
|
|
</Animated.View>
|
|
</View>
|
|
|
|
{/* Footer */}
|
|
<Animated.Text
|
|
style={{
|
|
position: 'absolute',
|
|
bottom: insets.bottom + 16,
|
|
left: 0,
|
|
right: 0,
|
|
fontFamily: 'Nunito_400Regular',
|
|
fontSize: 11,
|
|
letterSpacing: 1.5,
|
|
textTransform: 'uppercase',
|
|
color: 'rgba(255,255,255,0.28)',
|
|
textAlign: 'center',
|
|
opacity: footerOpacity,
|
|
}}
|
|
>
|
|
{t('splash.madeInGermany')}
|
|
</Animated.Text>
|
|
</View>
|
|
);
|
|
}
|