Bug fixes

This commit is contained in:
2026-07-05 17:52:43 +02:00
parent fb6108bd0c
commit c40531b6ae
12 changed files with 1196 additions and 946 deletions

View File

@@ -1,255 +1,259 @@
import React, { useEffect, useState } from 'react';
import { Text, View } from 'react-native';
import { Redirect, Stack, usePathname } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { AppProvider, useApp } from '../context/AppContext';
import { CoachMarksProvider } from '../context/CoachMarksContext';
import { CoachMarksOverlay } from '../components/CoachMarksOverlay';
import { useColors } from '../constants/Colors';
import { initDatabase, AppMetaDb } from '../services/database';
import * as SecureStore from 'expo-secure-store';
import * as SplashScreen from 'expo-splash-screen';
import { AuthService } from '../services/authService';
import { AnimatedSplashScreen } from '../components/AnimatedSplashScreen';
// Prevent the splash screen from auto-hiding before asset loading is complete.
SplashScreen.preventAutoHideAsync().catch(() => { });
const SECURE_INSTALL_MARKER = 'greenlens_install_v1';
const SHARE_INTENT_CALLBACK_PATH = '/dataUrl=greenlensShareKey';
const isShareIntentCallbackPath = (path: string | null | undefined) => path === SHARE_INTENT_CALLBACK_PATH;
const toStartupErrorMessage = (error: unknown): string => {
if (!error) return 'Unknown startup error';
if (error instanceof Error) return error.message;
return String(error);
};
const StartupFallback = ({ details }: { details?: string | null }) => (
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', padding: 24, backgroundColor: '#111813' }}>
<Text style={{ color: '#fff', fontSize: 18, fontWeight: '700', marginBottom: 8, textAlign: 'center' }}>
GreenLens could not start.
</Text>
<Text style={{ color: '#D6DED7', fontSize: 14, lineHeight: 20, textAlign: 'center' }}>
Please send this startup error to support.
</Text>
{details ? (
<Text style={{ color: '#FFB4A8', fontSize: 12, lineHeight: 17, marginTop: 16, textAlign: 'center' }}>
{details}
</Text>
) : null}
</View>
);
class RootErrorBoundary extends React.Component<{ children: React.ReactNode }, { hasError: boolean; errorMessage: string | null }> {
state = { hasError: false, errorMessage: null };
static getDerivedStateFromError(error: unknown) {
return { hasError: true, errorMessage: toStartupErrorMessage(error) };
}
componentDidCatch(error: unknown) {
console.error('[RootErrorBoundary]', error);
}
render() {
if (this.state.hasError) {
return <StartupFallback details={this.state.errorMessage} />;
}
return this.props.children;
}
}
const ensureInstallConsistency = async (): Promise<boolean> => {
try {
const sqliteMarker = AppMetaDb.get('install_marker_v2');
const secureMarker = await SecureStore.getItemAsync(SECURE_INSTALL_MARKER).catch(() => null);
if (sqliteMarker === '1' && secureMarker === '1') {
return false; // Alles gut, keine Neuinstallation
}
if (sqliteMarker === '1' || secureMarker === '1') {
// Teilweise vorhanden -> heilen, nicht löschen
AppMetaDb.set('install_marker_v2', '1');
await SecureStore.setItemAsync(SECURE_INSTALL_MARKER, '1');
return false;
}
// Fresh Install: Alles zurücksetzen
await AuthService.logout();
await AsyncStorage.removeItem('greenlens_show_tour');
AppMetaDb.set('install_marker_v2', '1');
await SecureStore.setItemAsync(SECURE_INSTALL_MARKER, '1');
return true;
} catch (error) {
console.error('Failed to initialize install marker', error);
return false;
}
};
function RootLayoutInner() {
const {
isDarkMode,
colorPalette,
signOut,
session,
billingSummary,
isActivatingEntitlement,
isInitializing,
isLoadingPlants,
isLoadingBilling,
} = useApp();
const colors = useColors(isDarkMode, colorPalette);
const pathname = usePathname();
const [installCheckDone, setInstallCheckDone] = useState(false);
const [splashAnimationComplete, setSplashAnimationComplete] = useState(false);
useEffect(() => {
(async () => {
const didResetSessionForFreshInstall = await ensureInstallConsistency();
if (didResetSessionForFreshInstall) {
await signOut();
}
setInstallCheckDone(true);
})();
}, [signOut]);
const isAppReady = installCheckDone && !isInitializing && !isLoadingPlants;
const hasActiveEntitlement = isActivatingEntitlement
|| (billingSummary?.entitlement?.plan === 'pro'
&& billingSummary?.entitlement?.status === 'active');
const isAllowedWithoutSession = pathname.includes('onboarding')
|| pathname.includes('auth/')
|| pathname.includes('scanner')
|| isShareIntentCallbackPath(pathname)
|| pathname.includes('profile/billing');
const isAllowedWithoutEntitlement = pathname.includes('auth/')
|| pathname.includes('onboarding')
|| pathname.includes('scanner')
|| isShareIntentCallbackPath(pathname)
|| pathname.includes('profile/billing');
let content = null;
if (isAppReady) {
if (!session) {
// Only redirect if we are not already on an auth-related page or the scanner
if (!isAllowedWithoutSession) {
content = <Redirect href="/onboarding" />;
} else {
content = (
<Stack
screenOptions={{
headerShown: false,
contentStyle: { backgroundColor: colors.background },
}}
>
<Stack.Screen name="onboarding" options={{ animation: 'none' }} />
<Stack.Screen name="onboarding/source" options={{ animation: 'slide_from_right' }} />
<Stack.Screen name="onboarding/goal" options={{ animation: 'slide_from_right' }} />
<Stack.Screen name="onboarding/experience" options={{ animation: 'slide_from_right' }} />
<Stack.Screen name="onboarding/customize" options={{ animation: 'slide_from_right' }} />
<Stack.Screen name="auth/login" options={{ animation: 'slide_from_right' }} />
<Stack.Screen name="auth/signup" options={{ animation: 'slide_from_right' }} />
<Stack.Screen
name="scanner"
options={{ presentation: 'fullScreenModal', animation: 'slide_from_bottom' }}
/>
<Stack.Screen name="dataUrl=greenlensShareKey" options={{ animation: 'none' }} />
<Stack.Screen
name="profile/billing"
options={{ presentation: 'card', animation: 'slide_from_right' }}
/>
</Stack>
);
}
} else if (!hasActiveEntitlement && !isLoadingBilling && !isAllowedWithoutEntitlement) {
content = <Redirect href="/onboarding" />;
} else {
content = (
<>
<Stack
screenOptions={{
headerShown: false,
contentStyle: { backgroundColor: colors.background },
}}
>
<Stack.Screen name="onboarding" options={{ animation: 'none' }} />
<Stack.Screen name="onboarding/source" options={{ animation: 'slide_from_right' }} />
<Stack.Screen name="onboarding/goal" options={{ animation: 'slide_from_right' }} />
<Stack.Screen name="onboarding/experience" options={{ animation: 'slide_from_right' }} />
<Stack.Screen name="onboarding/customize" options={{ animation: 'slide_from_right' }} />
<Stack.Screen name="auth/login" options={{ animation: 'slide_from_right' }} />
<Stack.Screen name="auth/signup" options={{ animation: 'slide_from_right' }} />
<Stack.Screen name="(tabs)" options={{ animation: 'none' }} />
<Stack.Screen
name="scanner"
options={{ presentation: 'fullScreenModal', animation: 'slide_from_bottom' }}
/>
<Stack.Screen name="dataUrl=greenlensShareKey" options={{ animation: 'none' }} />
<Stack.Screen
name="plant/[id]"
options={{ presentation: 'card', animation: 'slide_from_right' }}
/>
<Stack.Screen
name="lexicon"
options={{ presentation: 'fullScreenModal', animation: 'slide_from_bottom' }}
/>
<Stack.Screen
name="profile/preferences"
options={{ presentation: 'card', animation: 'slide_from_right' }}
/>
<Stack.Screen
name="profile/data"
options={{ presentation: 'card', animation: 'slide_from_right' }}
/>
<Stack.Screen
name="profile/billing"
options={{ presentation: 'card', animation: 'slide_from_right' }}
/>
</Stack>
<CoachMarksOverlay />
</>
);
}
}
return (
<>
<StatusBar style={isDarkMode ? 'light' : 'dark'} />
{content}
{!splashAnimationComplete && (
<AnimatedSplashScreen
isAppReady={isAppReady}
onAnimationComplete={() => setSplashAnimationComplete(true)}
/>
)}
</>
);
}
export default function RootLayout() {
let dbInitError: string | null = null;
try {
initDatabase();
} catch (e) {
dbInitError = String(e);
}
if (dbInitError) {
return <StartupFallback details={`Database init failed: ${dbInitError}`} />;
}
return (
<RootErrorBoundary>
<AppProvider>
<CoachMarksProvider>
<RootLayoutInner />
</CoachMarksProvider>
</AppProvider>
</RootErrorBoundary>
);
}
import React, { useEffect, useState } from 'react';
import { Text, View } from 'react-native';
import { Redirect, Stack, usePathname } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { AppProvider, useApp } from '../context/AppContext';
import { CoachMarksProvider } from '../context/CoachMarksContext';
import { CoachMarksOverlay } from '../components/CoachMarksOverlay';
import { useColors } from '../constants/Colors';
import { initDatabase, AppMetaDb } from '../services/database';
import * as SecureStore from 'expo-secure-store';
import * as SplashScreen from 'expo-splash-screen';
import { AuthService } from '../services/authService';
import { AnimatedSplashScreen } from '../components/AnimatedSplashScreen';
// Prevent the splash screen from auto-hiding before asset loading is complete.
SplashScreen.preventAutoHideAsync().catch(() => { });
const SECURE_INSTALL_MARKER = 'greenlens_install_v1';
const SHARE_INTENT_CALLBACK_PATH = '/dataUrl=greenlensShareKey';
const isShareIntentCallbackPath = (path: string | null | undefined) => path === SHARE_INTENT_CALLBACK_PATH;
const toStartupErrorMessage = (error: unknown): string => {
if (!error) return 'Unknown startup error';
if (error instanceof Error) return error.message;
return String(error);
};
const StartupFallback = ({ details }: { details?: string | null }) => (
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', padding: 24, backgroundColor: '#111813' }}>
<Text style={{ color: '#fff', fontSize: 18, fontWeight: '700', marginBottom: 8, textAlign: 'center' }}>
GreenLens could not start.
</Text>
<Text style={{ color: '#D6DED7', fontSize: 14, lineHeight: 20, textAlign: 'center' }}>
Please send this startup error to support.
</Text>
{details ? (
<Text style={{ color: '#FFB4A8', fontSize: 12, lineHeight: 17, marginTop: 16, textAlign: 'center' }}>
{details}
</Text>
) : null}
</View>
);
class RootErrorBoundary extends React.Component<{ children: React.ReactNode }, { hasError: boolean; errorMessage: string | null }> {
state = { hasError: false, errorMessage: null };
static getDerivedStateFromError(error: unknown) {
return { hasError: true, errorMessage: toStartupErrorMessage(error) };
}
componentDidCatch(error: unknown) {
console.error('[RootErrorBoundary]', error);
}
render() {
if (this.state.hasError) {
return <StartupFallback details={this.state.errorMessage} />;
}
return this.props.children;
}
}
const ensureInstallConsistency = async (): Promise<boolean> => {
try {
const sqliteMarker = AppMetaDb.get('install_marker_v2');
const secureMarker = await SecureStore.getItemAsync(SECURE_INSTALL_MARKER).catch(() => null);
if (sqliteMarker === '1' && secureMarker === '1') {
return false; // Alles gut, keine Neuinstallation
}
if (sqliteMarker === '1' || secureMarker === '1') {
// Teilweise vorhanden -> heilen, nicht löschen
AppMetaDb.set('install_marker_v2', '1');
await SecureStore.setItemAsync(SECURE_INSTALL_MARKER, '1');
return false;
}
// Fresh Install: Alles zurücksetzen
await AuthService.logout();
await AsyncStorage.removeItem('greenlens_show_tour');
AppMetaDb.set('install_marker_v2', '1');
await SecureStore.setItemAsync(SECURE_INSTALL_MARKER, '1');
return true;
} catch (error) {
console.error('Failed to initialize install marker', error);
return false;
}
};
function RootLayoutInner() {
const {
isDarkMode,
colorPalette,
signOut,
session,
billingSummary,
isActivatingEntitlement,
isInitializing,
isLoadingPlants,
isLoadingBilling,
} = useApp();
const colors = useColors(isDarkMode, colorPalette);
const pathname = usePathname();
const [installCheckDone, setInstallCheckDone] = useState(false);
const [splashAnimationComplete, setSplashAnimationComplete] = useState(false);
useEffect(() => {
(async () => {
const didResetSessionForFreshInstall = await ensureInstallConsistency();
if (didResetSessionForFreshInstall) {
await signOut();
}
setInstallCheckDone(true);
})();
}, [signOut]);
const isAppReady = installCheckDone && !isInitializing && !isLoadingPlants;
const hasActiveEntitlement = isActivatingEntitlement
|| (billingSummary?.entitlement?.plan === 'pro'
&& billingSummary?.entitlement?.status === 'active');
const isAllowedWithoutSession = pathname.includes('onboarding')
|| pathname.includes('auth/')
|| pathname.includes('scanner')
|| isShareIntentCallbackPath(pathname)
|| pathname.includes('profile/billing');
const isAllowedWithoutEntitlement = pathname.includes('auth/')
|| pathname.includes('onboarding')
|| pathname.includes('scanner')
|| isShareIntentCallbackPath(pathname)
|| pathname.includes('profile/billing');
let content = null;
if (isAppReady) {
if (!session) {
// Only redirect if we are not already on an auth-related page or the scanner
if (!isAllowedWithoutSession) {
content = <Redirect href="/onboarding" />;
} else {
content = (
<Stack
screenOptions={{
headerShown: false,
contentStyle: { backgroundColor: colors.background },
}}
>
<Stack.Screen name="onboarding" options={{ animation: 'none' }} />
<Stack.Screen name="onboarding/source" options={{ animation: 'slide_from_right' }} />
<Stack.Screen name="onboarding/goal" options={{ animation: 'slide_from_right' }} />
<Stack.Screen name="onboarding/experience" options={{ animation: 'slide_from_right' }} />
<Stack.Screen name="onboarding/customize" options={{ animation: 'slide_from_right' }} />
<Stack.Screen name="auth/login" options={{ animation: 'slide_from_right' }} />
<Stack.Screen name="auth/signup" options={{ animation: 'slide_from_right' }} />
<Stack.Screen
name="scanner"
options={{ presentation: 'fullScreenModal', animation: 'slide_from_bottom' }}
/>
<Stack.Screen name="dataUrl=greenlensShareKey" options={{ animation: 'none' }} />
<Stack.Screen
name="profile/billing"
options={{ presentation: 'card', animation: 'slide_from_right' }}
/>
</Stack>
);
}
} else if (!hasActiveEntitlement && !isLoadingBilling && billingSummary && !isAllowedWithoutEntitlement) {
// Signed-in but confirmed non-pro: send to the paywall, not back to
// onboarding — bouncing a logged-in user to onboarding looks like an
// app restart. billingSummary === null means "unknown" (fetch failed),
// never redirect on unknown.
content = <Redirect href="/profile/billing" />;
} else {
content = (
<>
<Stack
screenOptions={{
headerShown: false,
contentStyle: { backgroundColor: colors.background },
}}
>
<Stack.Screen name="onboarding" options={{ animation: 'none' }} />
<Stack.Screen name="onboarding/source" options={{ animation: 'slide_from_right' }} />
<Stack.Screen name="onboarding/goal" options={{ animation: 'slide_from_right' }} />
<Stack.Screen name="onboarding/experience" options={{ animation: 'slide_from_right' }} />
<Stack.Screen name="onboarding/customize" options={{ animation: 'slide_from_right' }} />
<Stack.Screen name="auth/login" options={{ animation: 'slide_from_right' }} />
<Stack.Screen name="auth/signup" options={{ animation: 'slide_from_right' }} />
<Stack.Screen name="(tabs)" options={{ animation: 'none' }} />
<Stack.Screen
name="scanner"
options={{ presentation: 'fullScreenModal', animation: 'slide_from_bottom' }}
/>
<Stack.Screen name="dataUrl=greenlensShareKey" options={{ animation: 'none' }} />
<Stack.Screen
name="plant/[id]"
options={{ presentation: 'card', animation: 'slide_from_right' }}
/>
<Stack.Screen
name="lexicon"
options={{ presentation: 'fullScreenModal', animation: 'slide_from_bottom' }}
/>
<Stack.Screen
name="profile/preferences"
options={{ presentation: 'card', animation: 'slide_from_right' }}
/>
<Stack.Screen
name="profile/data"
options={{ presentation: 'card', animation: 'slide_from_right' }}
/>
<Stack.Screen
name="profile/billing"
options={{ presentation: 'card', animation: 'slide_from_right' }}
/>
</Stack>
<CoachMarksOverlay />
</>
);
}
}
return (
<>
<StatusBar style={isDarkMode ? 'light' : 'dark'} />
{content}
{!splashAnimationComplete && (
<AnimatedSplashScreen
isAppReady={isAppReady}
onAnimationComplete={() => setSplashAnimationComplete(true)}
/>
)}
</>
);
}
export default function RootLayout() {
let dbInitError: string | null = null;
try {
initDatabase();
} catch (e) {
dbInitError = String(e);
}
if (dbInitError) {
return <StartupFallback details={`Database init failed: ${dbInitError}`} />;
}
return (
<RootErrorBoundary>
<AppProvider>
<CoachMarksProvider>
<RootLayoutInner />
</CoachMarksProvider>
</AppProvider>
</RootErrorBoundary>
);
}

View File

@@ -1,64 +1,64 @@
import React, { useEffect, useState } from 'react';
import {
View,
Text,
TextInput,
import React, { useEffect, useState } from 'react';
import {
View,
Text,
TextInput,
TouchableOpacity,
StyleSheet,
KeyboardAvoidingView,
Platform,
ActivityIndicator,
ScrollView,
Image,
} from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { Ionicons } from '@expo/vector-icons';
import { router } from 'expo-router';
import { useApp } from '../../context/AppContext';
import { useColors } from '../../constants/Colors';
import { AuthService } from '../../services/authService';
import * as AppleAuthentication from 'expo-apple-authentication';
import Constants from 'expo-constants';
import { useSafeAnalytics } from '../../services/analytics';
const ONBOARDING_AUTH_BACKGROUND = {
light: '#fbfaf3',
dark: '#0a110b',
};
export default function LoginScreen() {
const { isDarkMode, colorPalette, hydrateSession, t } = useApp();
const colors = useColors(isDarkMode, colorPalette);
const posthog = useSafeAnalytics();
const screenBackground = isDarkMode
? ONBOARDING_AUTH_BACKGROUND.dark
: ONBOARDING_AUTH_BACKGROUND.light;
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [appleAvailable, setAppleAvailable] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const isExpoGo = Constants.appOwnership === 'expo';
useEffect(() => {
if (isExpoGo) {
setAppleAvailable(false);
return;
}
let mounted = true;
AppleAuthentication.isAvailableAsync()
.then((available) => {
if (mounted) setAppleAvailable(available);
})
.catch(() => {
if (mounted) setAppleAvailable(false);
});
return () => {
mounted = false;
};
}, [isExpoGo]);
Image,
} from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { Ionicons } from '@expo/vector-icons';
import { router } from 'expo-router';
import { useApp } from '../../context/AppContext';
import { useColors } from '../../constants/Colors';
import { AuthService } from '../../services/authService';
import * as AppleAuthentication from 'expo-apple-authentication';
import Constants from 'expo-constants';
import { useSafeAnalytics } from '../../services/analytics';
const ONBOARDING_AUTH_BACKGROUND = {
light: '#fbfaf3',
dark: '#0a110b',
};
export default function LoginScreen() {
const { isDarkMode, colorPalette, hydrateSession, t } = useApp();
const colors = useColors(isDarkMode, colorPalette);
const posthog = useSafeAnalytics();
const screenBackground = isDarkMode
? ONBOARDING_AUTH_BACKGROUND.dark
: ONBOARDING_AUTH_BACKGROUND.light;
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [appleAvailable, setAppleAvailable] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const isExpoGo = Constants.appOwnership === 'expo';
useEffect(() => {
if (isExpoGo) {
setAppleAvailable(false);
return;
}
let mounted = true;
AppleAuthentication.isAvailableAsync()
.then((available) => {
if (mounted) setAppleAvailable(available);
})
.catch(() => {
if (mounted) setAppleAvailable(false);
});
return () => {
mounted = false;
};
}, [isExpoGo]);
const handleLogin = async () => {
if (!email.trim() || !password) {
@@ -68,9 +68,12 @@ export default function LoginScreen() {
setLoading(true);
setError(null);
try {
const session = await AuthService.login(email, password);
await hydrateSession(session);
router.replace('/(tabs)');
const session = await AuthService.login(email, password);
const billing = await hydrateSession(session);
const isPro = billing?.entitlement?.plan === 'pro' && billing?.entitlement?.status === 'active';
// Non-pro accounts land on the paywall with context instead of being
// bounced through the root redirect (which looks like an app restart).
router.replace(isPro || !billing ? '/(tabs)' : '/profile/billing');
} catch (e: any) {
if (e.message === 'USER_NOT_FOUND') {
setError(t.errUserNotFound);
@@ -85,78 +88,83 @@ export default function LoginScreen() {
}
} finally {
setLoading(false);
}
};
const handleAppleSignIn = async () => {
setLoading(true);
setError(null);
posthog.capture('apple_login_started', { surface: 'login' });
try {
const credential = await AppleAuthentication.signInAsync({
requestedScopes: [
AppleAuthentication.AppleAuthenticationScope.FULL_NAME,
AppleAuthentication.AppleAuthenticationScope.EMAIL,
],
});
if (!credential.identityToken) {
throw new Error('APPLE_AUTH_INVALID');
}
const fullName = [
credential.fullName?.givenName,
credential.fullName?.familyName,
].filter(Boolean).join(' ');
const session = await AuthService.signInWithApple({
identityToken: credential.identityToken,
appleUser: credential.user,
email: credential.email,
name: fullName || undefined,
});
await hydrateSession(session);
if (session.isNewUser) {
await AsyncStorage.setItem('greenlens_show_tour', 'true');
}
posthog.capture('apple_login_succeeded', { surface: 'login' });
router.replace(session.isNewUser ? '/onboarding/source' : '/(tabs)');
} catch (e: any) {
if (e?.code === 'ERR_REQUEST_CANCELED') {
return;
}
posthog.capture('apple_login_failed', {
surface: 'login',
error: e instanceof Error ? e.message : String(e),
});
setError(e?.message === 'APPLE_BACKEND_UNAVAILABLE'
? 'Apple Login ist auf dem Backend noch nicht aktiviert. Bitte Backend neu starten oder deployen.'
: t.errAuthError);
} finally {
setLoading(false);
}
};
}
};
const handleAppleSignIn = async () => {
setLoading(true);
setError(null);
posthog.capture('apple_login_started', { surface: 'login' });
try {
const credential = await AppleAuthentication.signInAsync({
requestedScopes: [
AppleAuthentication.AppleAuthenticationScope.FULL_NAME,
AppleAuthentication.AppleAuthenticationScope.EMAIL,
],
});
if (!credential.identityToken) {
throw new Error('APPLE_AUTH_INVALID');
}
const fullName = [
credential.fullName?.givenName,
credential.fullName?.familyName,
].filter(Boolean).join(' ');
const session = await AuthService.signInWithApple({
identityToken: credential.identityToken,
appleUser: credential.user,
email: credential.email,
name: fullName || undefined,
});
const billing = await hydrateSession(session);
if (session.isNewUser) {
await AsyncStorage.setItem('greenlens_show_tour', 'true');
}
posthog.capture('apple_login_succeeded', { surface: 'login' });
const isPro = billing?.entitlement?.plan === 'pro' && billing?.entitlement?.status === 'active';
if (session.isNewUser) {
router.replace('/onboarding/source');
} else {
router.replace(isPro || !billing ? '/(tabs)' : '/profile/billing');
}
} catch (e: any) {
if (e?.code === 'ERR_REQUEST_CANCELED') {
return;
}
posthog.capture('apple_login_failed', {
surface: 'login',
error: e instanceof Error ? e.message : String(e),
});
setError(e?.message === 'APPLE_BACKEND_UNAVAILABLE'
? 'Apple Login ist auf dem Backend noch nicht aktiviert. Bitte Backend neu starten oder deployen.'
: t.errAuthError);
} finally {
setLoading(false);
}
};
return (
<KeyboardAvoidingView
style={[styles.flex, { backgroundColor: screenBackground }]}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<ScrollView
<KeyboardAvoidingView
style={[styles.flex, { backgroundColor: screenBackground }]}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<ScrollView
contentContainerStyle={styles.scroll}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
{/* Logo / Header */}
<View style={styles.header}>
<TouchableOpacity
style={[styles.backBtn, { backgroundColor: colors.surface, borderColor: colors.border }]}
onPress={() => router.back()}
>
<Ionicons name="arrow-back" size={20} color={colors.text} />
</TouchableOpacity>
<Image
source={require('../../assets/icon.png')}
style={styles.logoIcon}
{/* Logo / Header */}
<View style={styles.header}>
<TouchableOpacity
style={[styles.backBtn, { backgroundColor: colors.surface, borderColor: colors.border }]}
onPress={() => router.back()}
>
<Ionicons name="arrow-back" size={20} color={colors.text} />
</TouchableOpacity>
<Image
source={require('../../assets/icon.png')}
style={styles.logoIcon}
resizeMode="contain"
/>
<Text style={[styles.appName, { color: colors.text }]}>GreenLens</Text>
@@ -165,30 +173,30 @@ export default function LoginScreen() {
</Text>
</View>
{/* Card */}
<View style={[styles.card, { backgroundColor: colors.surface, borderColor: colors.cardBorder, shadowColor: colors.cardShadow }]}>
{appleAvailable ? (
<AppleAuthentication.AppleAuthenticationButton
buttonType={AppleAuthentication.AppleAuthenticationButtonType.CONTINUE}
buttonStyle={isDarkMode
? AppleAuthentication.AppleAuthenticationButtonStyle.WHITE
: AppleAuthentication.AppleAuthenticationButtonStyle.BLACK}
cornerRadius={12}
style={styles.appleButton}
onPress={handleAppleSignIn}
/>
) : null}
{appleAvailable ? (
<View style={styles.dividerRowCompact}>
<View style={[styles.dividerLine, { backgroundColor: colors.border }]} />
<Text style={[styles.dividerText, { color: colors.textMuted }]}>{t.orDivider}</Text>
<View style={[styles.dividerLine, { backgroundColor: colors.border }]} />
</View>
) : null}
{/* Email */}
<View style={styles.fieldGroup}>
{/* Card */}
<View style={[styles.card, { backgroundColor: colors.surface, borderColor: colors.cardBorder, shadowColor: colors.cardShadow }]}>
{appleAvailable ? (
<AppleAuthentication.AppleAuthenticationButton
buttonType={AppleAuthentication.AppleAuthenticationButtonType.CONTINUE}
buttonStyle={isDarkMode
? AppleAuthentication.AppleAuthenticationButtonStyle.WHITE
: AppleAuthentication.AppleAuthenticationButtonStyle.BLACK}
cornerRadius={12}
style={styles.appleButton}
onPress={handleAppleSignIn}
/>
) : null}
{appleAvailable ? (
<View style={styles.dividerRowCompact}>
<View style={[styles.dividerLine, { backgroundColor: colors.border }]} />
<Text style={[styles.dividerText, { color: colors.textMuted }]}>{t.orDivider}</Text>
<View style={[styles.dividerLine, { backgroundColor: colors.border }]} />
</View>
) : null}
{/* Email */}
<View style={styles.fieldGroup}>
<Text style={[styles.label, { color: colors.textSecondary }]}>E-Mail</Text>
<View style={[styles.inputRow, { backgroundColor: colors.inputBg, borderColor: colors.inputBorder }]}>
<Ionicons name="mail-outline" size={18} color={colors.textMuted} style={styles.inputIcon} />
@@ -255,8 +263,8 @@ export default function LoginScreen() {
</TouchableOpacity>
</View>
{/* Divider */}
<View style={styles.dividerRow}>
{/* Divider */}
<View style={styles.dividerRow}>
<View style={[styles.dividerLine, { backgroundColor: colors.border }]} />
<Text style={[styles.dividerText, { color: colors.textMuted }]}>{t.orDivider}</Text>
<View style={[styles.dividerLine, { backgroundColor: colors.border }]} />
@@ -286,27 +294,27 @@ const styles = StyleSheet.create({
paddingHorizontal: 24,
paddingVertical: 48,
},
header: {
alignItems: 'center',
marginBottom: 32,
},
backBtn: {
position: 'absolute',
left: 0,
top: 0,
width: 40,
height: 40,
borderRadius: 20,
borderWidth: 1,
justifyContent: 'center',
alignItems: 'center',
},
logoIcon: {
width: 84,
height: 84,
borderRadius: 20,
marginBottom: 16,
},
header: {
alignItems: 'center',
marginBottom: 32,
},
backBtn: {
position: 'absolute',
left: 0,
top: 0,
width: 40,
height: 40,
borderRadius: 20,
borderWidth: 1,
justifyContent: 'center',
alignItems: 'center',
},
logoIcon: {
width: 84,
height: 84,
borderRadius: 20,
marginBottom: 16,
},
appName: {
fontSize: 30,
fontWeight: '700',
@@ -317,7 +325,7 @@ const styles = StyleSheet.create({
fontSize: 15,
fontWeight: '400',
},
card: {
card: {
borderRadius: 20,
borderWidth: 1,
padding: 24,
@@ -326,18 +334,18 @@ const styles = StyleSheet.create({
shadowOpacity: 1,
shadowRadius: 12,
elevation: 4,
},
appleButton: {
width: '100%',
height: 50,
marginBottom: 2,
},
dividerRowCompact: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
marginVertical: 2,
},
},
appleButton: {
width: '100%',
height: 50,
marginBottom: 2,
},
dividerRowCompact: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
marginVertical: 2,
},
fieldGroup: {
gap: 6,
},

View File

@@ -1,17 +1,17 @@
import React, { useEffect, useState } from 'react';
import {
View,
Text,
TextInput,
TouchableOpacity,
StyleSheet,
KeyboardAvoidingView,
Platform,
ActivityIndicator,
ScrollView,
Image,
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import {
View,
Text,
TextInput,
TouchableOpacity,
StyleSheet,
KeyboardAvoidingView,
Platform,
ActivityIndicator,
ScrollView,
Image,
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { router } from 'expo-router';
import { useApp } from '../../context/AppContext';
import { useColors } from '../../constants/Colors';
@@ -25,8 +25,8 @@ const ONBOARDING_AUTH_BACKGROUND = {
light: '#fbfaf3',
dark: '#0a110b',
};
export default function SignupScreen() {
export default function SignupScreen() {
const { isDarkMode, colorPalette, hydrateSession, getPendingPlant, t } = useApp();
const colors = useColors(isDarkMode, colorPalette);
const posthog = useSafeAnalytics();
@@ -34,10 +34,10 @@ export default function SignupScreen() {
const screenBackground = isDarkMode
? ONBOARDING_AUTH_BACKGROUND.dark
: ONBOARDING_AUTH_BACKGROUND.light;
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [passwordConfirm, setPasswordConfirm] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [showPasswordConfirm, setShowPasswordConfirm] = useState(false);
@@ -63,45 +63,45 @@ export default function SignupScreen() {
mounted = false;
};
}, [isExpoGo]);
const validate = (): string | null => {
if (!name.trim()) return t.errNameRequired;
if (!email.trim() || !email.includes('@')) return t.errEmailInvalid;
if (password.length < 6) return t.errPasswordShort;
if (password !== passwordConfirm) return t.errPasswordMismatch;
return null;
};
const handleSignup = async () => {
const validationError = validate();
if (validationError) {
setError(validationError);
return;
}
setLoading(true);
setError(null);
try {
const validate = (): string | null => {
if (!name.trim()) return t.errNameRequired;
if (!email.trim() || !email.includes('@')) return t.errEmailInvalid;
if (password.length < 6) return t.errPasswordShort;
if (password !== passwordConfirm) return t.errPasswordMismatch;
return null;
};
const handleSignup = async () => {
const validationError = validate();
if (validationError) {
setError(validationError);
return;
}
setLoading(true);
setError(null);
try {
const session = await AuthService.signUp(email, name, password);
await hydrateSession(session);
// Flag setzen: Tour beim nächsten App-Öffnen anzeigen
await AsyncStorage.setItem('greenlens_show_tour', 'true');
router.replace('/onboarding/source');
} catch (e: any) {
if (e.message === 'EMAIL_TAKEN') {
setError(t.errEmailTaken);
} else if (e.message === 'BACKEND_URL_MISSING') {
setError(t.errNetworkError);
} else if (e.message === 'NETWORK_ERROR') {
setError(t.errNetworkError);
} else if (e.message === 'SERVER_ERROR') {
setError(t.errServerError);
} else if (e.message === 'AUTH_ERROR') {
setError(t.errAuthError);
} else {
setError(t.errAuthError);
}
} finally {
setLoading(false);
} catch (e: any) {
if (e.message === 'EMAIL_TAKEN') {
setError(t.errEmailTaken);
} else if (e.message === 'BACKEND_URL_MISSING') {
setError(t.errNetworkError);
} else if (e.message === 'NETWORK_ERROR') {
setError(t.errNetworkError);
} else if (e.message === 'SERVER_ERROR') {
setError(t.errServerError);
} else if (e.message === 'AUTH_ERROR') {
setError(t.errAuthError);
} else {
setError(t.errAuthError);
}
} finally {
setLoading(false);
}
};
@@ -131,10 +131,17 @@ export default function SignupScreen() {
email: credential.email,
name: fullName || undefined,
});
await hydrateSession(session);
const billing = await hydrateSession(session);
await AsyncStorage.setItem('greenlens_show_tour', 'true');
posthog.capture('apple_login_succeeded', { surface: 'signup' });
router.replace(session.isNewUser ? '/onboarding/source' : '/(tabs)');
const isPro = billing?.entitlement?.plan === 'pro' && billing?.entitlement?.status === 'active';
if (session.isNewUser) {
router.replace('/onboarding/source');
} else {
// Same routing as login: existing non-pro accounts go to the paywall
// directly instead of bouncing through the root entitlement redirect.
router.replace(isPro || !billing ? '/(tabs)' : '/profile/billing');
}
} catch (e: any) {
if (e?.code === 'ERR_REQUEST_CANCELED') {
return;
@@ -150,46 +157,46 @@ export default function SignupScreen() {
setLoading(false);
}
};
return (
return (
<KeyboardAvoidingView
style={[styles.flex, { backgroundColor: screenBackground }]}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<ScrollView
contentContainerStyle={styles.scroll}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
{/* Header */}
<View style={styles.header}>
<TouchableOpacity
style={[styles.backBtn, { backgroundColor: colors.surface, borderColor: colors.border }]}
onPress={() => router.back()}
>
<Ionicons name="arrow-back" size={20} color={colors.text} />
</TouchableOpacity>
<Image
source={require('../../assets/icon.png')}
style={styles.logoIcon}
resizeMode="contain"
/>
<Text style={[styles.appName, { color: colors.text }]}>GreenLens</Text>
<Text style={[styles.subtitle, { color: colors.textSecondary }]}>
{t.createAccount}
</Text>
</View>
{/* Pending Plant Hint */}
{pendingPlant && (
<View style={[styles.pendingHint, { backgroundColor: `${colors.primarySoft}40`, borderColor: `${colors.primaryDark}40` }]}>
<Ionicons name="sparkles" size={18} color={colors.primaryDark} />
<Text style={[styles.pendingHintText, { color: colors.primaryDark }]}>
{t.pendingPlantHint.replace('{0}', pendingPlant.result.name)}
</Text>
</View>
)}
contentContainerStyle={styles.scroll}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
{/* Header */}
<View style={styles.header}>
<TouchableOpacity
style={[styles.backBtn, { backgroundColor: colors.surface, borderColor: colors.border }]}
onPress={() => router.back()}
>
<Ionicons name="arrow-back" size={20} color={colors.text} />
</TouchableOpacity>
<Image
source={require('../../assets/icon.png')}
style={styles.logoIcon}
resizeMode="contain"
/>
<Text style={[styles.appName, { color: colors.text }]}>GreenLens</Text>
<Text style={[styles.subtitle, { color: colors.textSecondary }]}>
{t.createAccount}
</Text>
</View>
{/* Pending Plant Hint */}
{pendingPlant && (
<View style={[styles.pendingHint, { backgroundColor: `${colors.primarySoft}40`, borderColor: `${colors.primaryDark}40` }]}>
<Ionicons name="sparkles" size={18} color={colors.primaryDark} />
<Text style={[styles.pendingHintText, { color: colors.primaryDark }]}>
{t.pendingPlantHint.replace('{0}', pendingPlant.result.name)}
</Text>
</View>
)}
{/* Card */}
<View style={[styles.card, { backgroundColor: colors.surface, borderColor: colors.cardBorder, shadowColor: colors.cardShadow }]}>
{appleAvailable ? (
@@ -214,212 +221,212 @@ export default function SignupScreen() {
{/* Name */}
<View style={styles.fieldGroup}>
<Text style={[styles.label, { color: colors.textSecondary }]}>Name</Text>
<View style={[styles.inputRow, { backgroundColor: colors.inputBg, borderColor: colors.inputBorder }]}>
<Ionicons name="person-outline" size={18} color={colors.textMuted} style={styles.inputIcon} />
<TextInput
style={[styles.input, { color: colors.text }]}
placeholder={t.namePlaceholder}
placeholderTextColor={colors.textMuted}
value={name}
onChangeText={setName}
autoCapitalize="words"
autoComplete="name"
returnKeyType="next"
/>
</View>
</View>
{/* Email */}
<View style={styles.fieldGroup}>
<Text style={[styles.label, { color: colors.textSecondary }]}>E-Mail</Text>
<View style={[styles.inputRow, { backgroundColor: colors.inputBg, borderColor: colors.inputBorder }]}>
<Ionicons name="mail-outline" size={18} color={colors.textMuted} style={styles.inputIcon} />
<TextInput
style={[styles.input, { color: colors.text }]}
placeholder={t.emailPlaceholder}
placeholderTextColor={colors.textMuted}
value={email}
onChangeText={setEmail}
autoCapitalize="none"
keyboardType="email-address"
autoComplete="email"
returnKeyType="next"
/>
</View>
</View>
{/* Password */}
<View style={styles.fieldGroup}>
<Text style={[styles.label, { color: colors.textSecondary }]}>{t.passwordLabel}</Text>
<View style={[styles.inputRow, { backgroundColor: colors.inputBg, borderColor: colors.inputBorder }]}>
<Ionicons name="lock-closed-outline" size={18} color={colors.textMuted} style={styles.inputIcon} />
<TextInput
style={[styles.input, { color: colors.text }]}
placeholder={t.passwordPlaceholder}
placeholderTextColor={colors.textMuted}
value={password}
onChangeText={setPassword}
secureTextEntry={!showPassword}
autoComplete="new-password"
returnKeyType="next"
/>
<TouchableOpacity onPress={() => setShowPassword((v) => !v)} style={styles.eyeBtn}>
<Ionicons
name={showPassword ? 'eye-off-outline' : 'eye-outline'}
size={18}
color={colors.textMuted}
/>
</TouchableOpacity>
</View>
</View>
{/* Password Confirm */}
<View style={styles.fieldGroup}>
<Text style={[styles.label, { color: colors.textSecondary }]}>{t.confirmPasswordLabel}</Text>
<View style={[
styles.inputRow,
{
backgroundColor: colors.inputBg,
borderColor: passwordConfirm && password !== passwordConfirm ? colors.danger : colors.inputBorder,
},
]}>
<Ionicons name="lock-closed-outline" size={18} color={colors.textMuted} style={styles.inputIcon} />
<TextInput
style={[styles.input, { color: colors.text }]}
placeholder={t.confirmPasswordPlaceholder}
placeholderTextColor={colors.textMuted}
value={passwordConfirm}
onChangeText={setPasswordConfirm}
secureTextEntry={!showPasswordConfirm}
autoComplete="new-password"
returnKeyType="done"
onSubmitEditing={handleSignup}
/>
<TouchableOpacity onPress={() => setShowPasswordConfirm((v) => !v)} style={styles.eyeBtn}>
<Ionicons
name={showPasswordConfirm ? 'eye-off-outline' : 'eye-outline'}
size={18}
color={colors.textMuted}
/>
</TouchableOpacity>
</View>
</View>
{/* Password strength hint */}
{password.length > 0 && (
<View style={styles.strengthRow}>
{[1, 2, 3, 4].map((level) => (
<View
key={level}
style={[
styles.strengthBar,
{
backgroundColor:
password.length >= level * 3
? level <= 1
? colors.danger
: level === 2
? colors.warning
: colors.success
: colors.border,
},
]}
/>
))}
<Text style={[styles.strengthText, { color: colors.textMuted }]}>
{password.length < 4
? t.strengthTooShort
: password.length < 7
? t.strengthWeak
: password.length < 10
? t.strengthMedium
: t.strengthStrong}
</Text>
</View>
)}
{/* Error */}
{error && (
<View style={[styles.errorBox, { backgroundColor: colors.dangerSoft }]}>
<Ionicons name="alert-circle-outline" size={15} color={colors.danger} />
<Text style={[styles.errorText, { color: colors.danger }]}>{error}</Text>
</View>
)}
{/* Signup Button */}
<TouchableOpacity
style={[styles.primaryBtn, { backgroundColor: colors.primary, opacity: loading ? 0.7 : 1 }]}
onPress={handleSignup}
activeOpacity={0.82}
disabled={loading}
>
{loading ? (
<ActivityIndicator color={colors.onPrimary} size="small" />
) : (
<Text style={[styles.primaryBtnText, { color: colors.onPrimary }]}>{t.onboardingRegister}</Text>
)}
</TouchableOpacity>
</View>
{/* Login link */}
<TouchableOpacity style={styles.loginLink} onPress={() => router.replace('/auth/login')}>
<Text style={[styles.loginLinkText, { color: colors.textSecondary }]}>
{t.alreadyHaveAccount}{' '}
<Text style={{ color: colors.primary, fontWeight: '600' }}>{t.onboardingLogin}</Text>
</Text>
</TouchableOpacity>
</ScrollView>
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
flex: { flex: 1 },
scroll: {
flexGrow: 1,
justifyContent: 'center',
paddingHorizontal: 24,
paddingVertical: 48,
},
header: {
alignItems: 'center',
marginBottom: 32,
},
backBtn: {
position: 'absolute',
left: 0,
top: 0,
width: 40,
height: 40,
borderRadius: 20,
borderWidth: 1,
justifyContent: 'center',
alignItems: 'center',
},
<Text style={[styles.label, { color: colors.textSecondary }]}>Name</Text>
<View style={[styles.inputRow, { backgroundColor: colors.inputBg, borderColor: colors.inputBorder }]}>
<Ionicons name="person-outline" size={18} color={colors.textMuted} style={styles.inputIcon} />
<TextInput
style={[styles.input, { color: colors.text }]}
placeholder={t.namePlaceholder}
placeholderTextColor={colors.textMuted}
value={name}
onChangeText={setName}
autoCapitalize="words"
autoComplete="name"
returnKeyType="next"
/>
</View>
</View>
{/* Email */}
<View style={styles.fieldGroup}>
<Text style={[styles.label, { color: colors.textSecondary }]}>E-Mail</Text>
<View style={[styles.inputRow, { backgroundColor: colors.inputBg, borderColor: colors.inputBorder }]}>
<Ionicons name="mail-outline" size={18} color={colors.textMuted} style={styles.inputIcon} />
<TextInput
style={[styles.input, { color: colors.text }]}
placeholder={t.emailPlaceholder}
placeholderTextColor={colors.textMuted}
value={email}
onChangeText={setEmail}
autoCapitalize="none"
keyboardType="email-address"
autoComplete="email"
returnKeyType="next"
/>
</View>
</View>
{/* Password */}
<View style={styles.fieldGroup}>
<Text style={[styles.label, { color: colors.textSecondary }]}>{t.passwordLabel}</Text>
<View style={[styles.inputRow, { backgroundColor: colors.inputBg, borderColor: colors.inputBorder }]}>
<Ionicons name="lock-closed-outline" size={18} color={colors.textMuted} style={styles.inputIcon} />
<TextInput
style={[styles.input, { color: colors.text }]}
placeholder={t.passwordPlaceholder}
placeholderTextColor={colors.textMuted}
value={password}
onChangeText={setPassword}
secureTextEntry={!showPassword}
autoComplete="new-password"
returnKeyType="next"
/>
<TouchableOpacity onPress={() => setShowPassword((v) => !v)} style={styles.eyeBtn}>
<Ionicons
name={showPassword ? 'eye-off-outline' : 'eye-outline'}
size={18}
color={colors.textMuted}
/>
</TouchableOpacity>
</View>
</View>
{/* Password Confirm */}
<View style={styles.fieldGroup}>
<Text style={[styles.label, { color: colors.textSecondary }]}>{t.confirmPasswordLabel}</Text>
<View style={[
styles.inputRow,
{
backgroundColor: colors.inputBg,
borderColor: passwordConfirm && password !== passwordConfirm ? colors.danger : colors.inputBorder,
},
]}>
<Ionicons name="lock-closed-outline" size={18} color={colors.textMuted} style={styles.inputIcon} />
<TextInput
style={[styles.input, { color: colors.text }]}
placeholder={t.confirmPasswordPlaceholder}
placeholderTextColor={colors.textMuted}
value={passwordConfirm}
onChangeText={setPasswordConfirm}
secureTextEntry={!showPasswordConfirm}
autoComplete="new-password"
returnKeyType="done"
onSubmitEditing={handleSignup}
/>
<TouchableOpacity onPress={() => setShowPasswordConfirm((v) => !v)} style={styles.eyeBtn}>
<Ionicons
name={showPasswordConfirm ? 'eye-off-outline' : 'eye-outline'}
size={18}
color={colors.textMuted}
/>
</TouchableOpacity>
</View>
</View>
{/* Password strength hint */}
{password.length > 0 && (
<View style={styles.strengthRow}>
{[1, 2, 3, 4].map((level) => (
<View
key={level}
style={[
styles.strengthBar,
{
backgroundColor:
password.length >= level * 3
? level <= 1
? colors.danger
: level === 2
? colors.warning
: colors.success
: colors.border,
},
]}
/>
))}
<Text style={[styles.strengthText, { color: colors.textMuted }]}>
{password.length < 4
? t.strengthTooShort
: password.length < 7
? t.strengthWeak
: password.length < 10
? t.strengthMedium
: t.strengthStrong}
</Text>
</View>
)}
{/* Error */}
{error && (
<View style={[styles.errorBox, { backgroundColor: colors.dangerSoft }]}>
<Ionicons name="alert-circle-outline" size={15} color={colors.danger} />
<Text style={[styles.errorText, { color: colors.danger }]}>{error}</Text>
</View>
)}
{/* Signup Button */}
<TouchableOpacity
style={[styles.primaryBtn, { backgroundColor: colors.primary, opacity: loading ? 0.7 : 1 }]}
onPress={handleSignup}
activeOpacity={0.82}
disabled={loading}
>
{loading ? (
<ActivityIndicator color={colors.onPrimary} size="small" />
) : (
<Text style={[styles.primaryBtnText, { color: colors.onPrimary }]}>{t.onboardingRegister}</Text>
)}
</TouchableOpacity>
</View>
{/* Login link */}
<TouchableOpacity style={styles.loginLink} onPress={() => router.replace('/auth/login')}>
<Text style={[styles.loginLinkText, { color: colors.textSecondary }]}>
{t.alreadyHaveAccount}{' '}
<Text style={{ color: colors.primary, fontWeight: '600' }}>{t.onboardingLogin}</Text>
</Text>
</TouchableOpacity>
</ScrollView>
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
flex: { flex: 1 },
scroll: {
flexGrow: 1,
justifyContent: 'center',
paddingHorizontal: 24,
paddingVertical: 48,
},
header: {
alignItems: 'center',
marginBottom: 32,
},
backBtn: {
position: 'absolute',
left: 0,
top: 0,
width: 40,
height: 40,
borderRadius: 20,
borderWidth: 1,
justifyContent: 'center',
alignItems: 'center',
},
logoIcon: {
width: 84,
height: 84,
borderRadius: 20,
marginBottom: 16,
},
appName: {
fontSize: 30,
fontWeight: '700',
letterSpacing: -0.5,
marginBottom: 6,
},
subtitle: {
fontSize: 15,
fontWeight: '400',
},
appName: {
fontSize: 30,
fontWeight: '700',
letterSpacing: -0.5,
marginBottom: 6,
},
subtitle: {
fontSize: 15,
fontWeight: '400',
},
card: {
borderRadius: 20,
borderWidth: 1,
padding: 24,
gap: 14,
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 1,
padding: 24,
gap: 14,
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 1,
shadowRadius: 12,
elevation: 4,
},
@@ -443,93 +450,93 @@ const styles = StyleSheet.create({
fontWeight: '500',
},
fieldGroup: {
gap: 6,
},
label: {
fontSize: 13,
fontWeight: '500',
marginLeft: 2,
},
inputRow: {
flexDirection: 'row',
alignItems: 'center',
borderWidth: 1,
borderRadius: 12,
paddingHorizontal: 14,
height: 50,
},
inputIcon: {
marginRight: 10,
},
input: {
flex: 1,
fontSize: 15,
height: 50,
},
eyeBtn: {
padding: 4,
marginLeft: 6,
},
strengthRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
marginTop: -4,
},
strengthBar: {
flex: 1,
height: 3,
borderRadius: 2,
},
strengthText: {
fontSize: 11,
marginLeft: 4,
width: 40,
},
errorBox: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
borderRadius: 10,
paddingHorizontal: 12,
paddingVertical: 10,
},
errorText: {
fontSize: 13,
flex: 1,
},
primaryBtn: {
height: 52,
borderRadius: 14,
justifyContent: 'center',
alignItems: 'center',
marginTop: 4,
},
primaryBtnText: {
fontSize: 16,
fontWeight: '600',
},
loginLink: {
alignItems: 'center',
marginTop: 24,
paddingVertical: 8,
},
loginLinkText: {
fontSize: 15,
},
pendingHint: {
flexDirection: 'row',
alignItems: 'center',
padding: 16,
borderRadius: 16,
borderWidth: 1,
marginBottom: 20,
gap: 12,
},
pendingHintText: {
flex: 1,
fontSize: 13,
fontWeight: '600',
lineHeight: 18,
},
});
gap: 6,
},
label: {
fontSize: 13,
fontWeight: '500',
marginLeft: 2,
},
inputRow: {
flexDirection: 'row',
alignItems: 'center',
borderWidth: 1,
borderRadius: 12,
paddingHorizontal: 14,
height: 50,
},
inputIcon: {
marginRight: 10,
},
input: {
flex: 1,
fontSize: 15,
height: 50,
},
eyeBtn: {
padding: 4,
marginLeft: 6,
},
strengthRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
marginTop: -4,
},
strengthBar: {
flex: 1,
height: 3,
borderRadius: 2,
},
strengthText: {
fontSize: 11,
marginLeft: 4,
width: 40,
},
errorBox: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
borderRadius: 10,
paddingHorizontal: 12,
paddingVertical: 10,
},
errorText: {
fontSize: 13,
flex: 1,
},
primaryBtn: {
height: 52,
borderRadius: 14,
justifyContent: 'center',
alignItems: 'center',
marginTop: 4,
},
primaryBtnText: {
fontSize: 16,
fontWeight: '600',
},
loginLink: {
alignItems: 'center',
marginTop: 24,
paddingVertical: 8,
},
loginLinkText: {
fontSize: 15,
},
pendingHint: {
flexDirection: 'row',
alignItems: 'center',
padding: 16,
borderRadius: 16,
borderWidth: 1,
marginBottom: 20,
gap: 12,
},
pendingHintText: {
flex: 1,
fontSize: 13,
fontWeight: '600',
lineHeight: 18,
},
});