feat(rebreak-native): Nuxt-style splash, domain normalization on blur, app-wide keyboard fix
- app/index.tsx: replaced the placeholder landing with the BrandSplash look (#0f172a bg, SVG radial glows, breathing animation, staggered fade/bounce-ins for app name / logo / tagline / CTAs, "Made in Germany" footer). Dropped the "v0.1.0 RN Migration Phase 1 Skeleton" line; landing.version removed from locales. - AddDomainSheet: onBlur runs normalizeDomain() (strips scheme/www./path/query and email local-part) so the user sees the cleaned registrable domain before adding; also swapped the two leftover Pressables → TouchableOpacity (no-Pressable rule). - KeyboardAwareSheet: clamp the sheet height to (screenHeight - insets.top - 20) while the keyboard is up, so tall sheets (e.g. AddDomainSheet's 600px) don't grow off-screen and clip the inputs at the top. - ConnectMailSheet: automaticallyAdjustKeyboardInsets on iOS so focused inputs scroll into view. Covered sheets: AddDomainSheet, ConnectMailSheet, EditMailAccountSheet, AddMacSheet, AddWindowsSheet. JS-only (hot-reloadable). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
0bad2185ec
commit
5b12f14a90
@ -1,32 +1,241 @@
|
|||||||
import { View, Text, TouchableOpacity } from 'react-native';
|
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 { useRouter } from 'expo-router';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
export default function HomeScreen() {
|
const { width: SW, height: SH } = Dimensions.get('window');
|
||||||
|
|
||||||
|
export default function LandingScreen() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
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,
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView className="flex-1 bg-white">
|
<View style={{ flex: 1, backgroundColor: '#0f172a', overflow: 'hidden' }}>
|
||||||
<View className="flex-1 items-center justify-center px-6">
|
{/* Top breathing glow */}
|
||||||
<Text className="text-4xl text-neutral-900 mb-3" style={{ fontFamily: 'Nunito_700Bold' }}>{t('landing.appName')}</Text>
|
<Animated.View
|
||||||
<Text className="text-base text-neutral-500 text-center mb-12" style={{ fontFamily: 'Nunito_400Regular' }}>
|
pointerEvents="none"
|
||||||
{t('landing.tagline')}
|
style={{ position: 'absolute', top: 0, left: 0, right: 0, height: SH * 0.5, opacity: glowTopOpacity }}
|
||||||
</Text>
|
>
|
||||||
|
<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>
|
||||||
|
|
||||||
<TouchableOpacity
|
{/* Center indigo halo */}
|
||||||
onPress={() => router.push('/signin')}
|
<Animated.View
|
||||||
activeOpacity={0.8}
|
pointerEvents="none"
|
||||||
className="bg-rebreak-500 px-8 py-4 rounded-full"
|
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 }],
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Text className="text-white text-base" style={{ fontFamily: 'Nunito_600SemiBold' }}>{t('landing.start')}</Text>
|
{t('appHeader.appName')}
|
||||||
</TouchableOpacity>
|
</Animated.Text>
|
||||||
|
|
||||||
<Text className="text-xs text-neutral-400 mt-8" style={{ fontFamily: 'Nunito_400Regular' }}>
|
<Animated.View
|
||||||
{t('landing.version')}
|
style={{
|
||||||
</Text>
|
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>
|
</View>
|
||||||
</SafeAreaView>
|
|
||||||
|
{/* 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { ReactNode, useEffect, useRef, useState } from 'react';
|
import { ReactNode, useEffect, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Animated,
|
Animated,
|
||||||
|
Dimensions,
|
||||||
Easing,
|
Easing,
|
||||||
Keyboard,
|
Keyboard,
|
||||||
Modal,
|
Modal,
|
||||||
@ -121,13 +122,14 @@ export function KeyboardAwareSheet({
|
|||||||
|
|
||||||
// Sheet-Höhe wächst/schrumpft mit Tastatur
|
// Sheet-Höhe wächst/schrumpft mit Tastatur
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const maxHeight = Dimensions.get('window').height - insets.top - 20;
|
||||||
const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow';
|
const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow';
|
||||||
const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide';
|
const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide';
|
||||||
const showSub = Keyboard.addListener(showEvent, (e) => {
|
const showSub = Keyboard.addListener(showEvent, (e) => {
|
||||||
const h = e.endCoordinates.height;
|
const h = e.endCoordinates.height;
|
||||||
setKeyboardHeight(h);
|
setKeyboardHeight(h);
|
||||||
Animated.timing(sheetHeight, {
|
Animated.timing(sheetHeight, {
|
||||||
toValue: collapsedHeight + h,
|
toValue: Math.min(collapsedHeight + h, maxHeight),
|
||||||
duration: Platform.OS === 'ios' ? (e.duration ?? 250) : 220,
|
duration: Platform.OS === 'ios' ? (e.duration ?? 250) : 220,
|
||||||
easing: Easing.out(Easing.cubic),
|
easing: Easing.out(Easing.cubic),
|
||||||
useNativeDriver: false,
|
useNativeDriver: false,
|
||||||
@ -146,7 +148,7 @@ export function KeyboardAwareSheet({
|
|||||||
showSub.remove();
|
showSub.remove();
|
||||||
hideSub.remove();
|
hideSub.remove();
|
||||||
};
|
};
|
||||||
}, [sheetHeight, collapsedHeight]);
|
}, [sheetHeight, collapsedHeight, insets.top]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal visible={visible} transparent animationType="none" onRequestClose={onClose}>
|
<Modal visible={visible} transparent animationType="none" onRequestClose={onClose}>
|
||||||
|
|||||||
@ -3,7 +3,6 @@ import {
|
|||||||
View,
|
View,
|
||||||
Text,
|
Text,
|
||||||
TextInput,
|
TextInput,
|
||||||
Pressable,
|
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
Image,
|
Image,
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
@ -82,11 +81,11 @@ export function AddDomainSheet({ visible, tier, onClose, onAdd }: Props) {
|
|||||||
borderBottomColor: colors.border,
|
borderBottomColor: colors.border,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Pressable onPress={close} hitSlop={10}>
|
<TouchableOpacity onPress={close} hitSlop={10} activeOpacity={0.6}>
|
||||||
<Text style={{ fontSize: 16, fontFamily: 'Nunito_400Regular', color: colors.textMuted }}>
|
<Text style={{ fontSize: 16, fontFamily: 'Nunito_400Regular', color: colors.textMuted }}>
|
||||||
{t('common.cancel')}
|
{t('common.cancel')}
|
||||||
</Text>
|
</Text>
|
||||||
</Pressable>
|
</TouchableOpacity>
|
||||||
<Text style={{ fontSize: 16, fontFamily: 'Nunito_700Bold', color: colors.text }}>
|
<Text style={{ fontSize: 16, fontFamily: 'Nunito_700Bold', color: colors.text }}>
|
||||||
{t('blocker.add_sheet_title')}
|
{t('blocker.add_sheet_title')}
|
||||||
</Text>
|
</Text>
|
||||||
@ -121,6 +120,10 @@ export function AddDomainSheet({ visible, tier, onClose, onAdd }: Props) {
|
|||||||
setInput(v);
|
setInput(v);
|
||||||
setError(null);
|
setError(null);
|
||||||
}}
|
}}
|
||||||
|
onBlur={() => {
|
||||||
|
const n = normalizeDomain(input);
|
||||||
|
if (n !== input) setInput(n);
|
||||||
|
}}
|
||||||
placeholder={t('blocker.add_sheet_placeholder')}
|
placeholder={t('blocker.add_sheet_placeholder')}
|
||||||
placeholderTextColor={colors.textMuted}
|
placeholderTextColor={colors.textMuted}
|
||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
@ -215,8 +218,9 @@ export function AddDomainSheet({ visible, tier, onClose, onAdd }: Props) {
|
|||||||
|
|
||||||
{/* Confirm-Checkbox */}
|
{/* Confirm-Checkbox */}
|
||||||
{valid && (
|
{valid && (
|
||||||
<Pressable
|
<TouchableOpacity
|
||||||
onPress={() => setConfirmPermanent((v) => !v)}
|
onPress={() => setConfirmPermanent((v) => !v)}
|
||||||
|
activeOpacity={0.7}
|
||||||
style={{
|
style={{
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'flex-start',
|
alignItems: 'flex-start',
|
||||||
@ -250,7 +254,7 @@ export function AddDomainSheet({ visible, tier, onClose, onAdd }: Props) {
|
|||||||
>
|
>
|
||||||
{t('blocker.add_sheet_confirm_permanent')}
|
{t('blocker.add_sheet_confirm_permanent')}
|
||||||
</Text>
|
</Text>
|
||||||
</Pressable>
|
</TouchableOpacity>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Error */}
|
{/* Error */}
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { useState } from 'react';
|
|||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
Linking,
|
Linking,
|
||||||
|
Platform,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
Text,
|
Text,
|
||||||
@ -337,6 +338,7 @@ function FormView({
|
|||||||
style={{ flex: 1 }}
|
style={{ flex: 1 }}
|
||||||
contentContainerStyle={{ padding: 20, gap: 14 }}
|
contentContainerStyle={{ padding: 20, gap: 14 }}
|
||||||
keyboardShouldPersistTaps="handled"
|
keyboardShouldPersistTaps="handled"
|
||||||
|
automaticallyAdjustKeyboardInsets={Platform.OS === 'ios'}
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
>
|
>
|
||||||
{/* App-Password-Guide-Hinweis */}
|
{/* App-Password-Guide-Hinweis */}
|
||||||
|
|||||||
@ -81,8 +81,7 @@
|
|||||||
"landing": {
|
"landing": {
|
||||||
"appName": "Rebreak",
|
"appName": "Rebreak",
|
||||||
"tagline": "Du gehst nicht allein.",
|
"tagline": "Du gehst nicht allein.",
|
||||||
"start": "Loslegen",
|
"start": "Registrieren"
|
||||||
"version": "v0.1.0 — RN Migration Phase 1 Skeleton"
|
|
||||||
},
|
},
|
||||||
"splash": {
|
"splash": {
|
||||||
"tagline": "You will never walk alone!",
|
"tagline": "You will never walk alone!",
|
||||||
|
|||||||
@ -81,8 +81,7 @@
|
|||||||
"landing": {
|
"landing": {
|
||||||
"appName": "Rebreak",
|
"appName": "Rebreak",
|
||||||
"tagline": "You're not walking alone.",
|
"tagline": "You're not walking alone.",
|
||||||
"start": "Get started",
|
"start": "Sign up"
|
||||||
"version": "v0.1.0 — RN Migration Phase 1 Skeleton"
|
|
||||||
},
|
},
|
||||||
"splash": {
|
"splash": {
|
||||||
"tagline": "You will never walk alone!",
|
"tagline": "You will never walk alone!",
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user