SEO
This commit is contained in:
@@ -1,7 +1,6 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Text, View } from 'react-native';
|
||||
import { Redirect, Stack, usePathname, useRouter } from 'expo-router';
|
||||
import { useShareIntent } from 'expo-share-intent';
|
||||
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';
|
||||
@@ -11,7 +10,6 @@ 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 * as ExpoLinking from 'expo-linking';
|
||||
import { AuthService } from '../services/authService';
|
||||
import { AnimatedSplashScreen } from '../components/AnimatedSplashScreen';
|
||||
|
||||
@@ -19,7 +17,8 @@ import { AnimatedSplashScreen } from '../components/AnimatedSplashScreen';
|
||||
SplashScreen.preventAutoHideAsync().catch(() => { });
|
||||
|
||||
const SECURE_INSTALL_MARKER = 'greenlens_install_v1';
|
||||
const isShareIntentUrl = (url: string | null | undefined) => Boolean(url?.includes('://dataUrl='));
|
||||
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';
|
||||
@@ -105,35 +104,9 @@ function RootLayoutInner() {
|
||||
} = useApp();
|
||||
const colors = useColors(isDarkMode, colorPalette);
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const [shareIntentEnabled, setShareIntentEnabled] = useState(false);
|
||||
const { hasShareIntent, shareIntent, resetShareIntent } = useShareIntent({ disabled: !shareIntentEnabled });
|
||||
const [installCheckDone, setInstallCheckDone] = useState(false);
|
||||
const [splashAnimationComplete, setSplashAnimationComplete] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
ExpoLinking.getInitialURL()
|
||||
.then((url) => {
|
||||
if (mounted && isShareIntentUrl(url)) {
|
||||
setShareIntentEnabled(true);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
const subscription = ExpoLinking.addEventListener('url', ({ url }) => {
|
||||
if (isShareIntentUrl(url)) {
|
||||
setShareIntentEnabled(true);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
subscription.remove();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const didResetSessionForFreshInstall = await ensureInstallConsistency();
|
||||
@@ -145,35 +118,18 @@ function RootLayoutInner() {
|
||||
}, [signOut]);
|
||||
|
||||
const isAppReady = installCheckDone && !isInitializing && !isLoadingPlants;
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasShareIntent || !isAppReady) return;
|
||||
const sharedImage = shareIntent.files?.find((file) => file.mimeType?.startsWith('image/'));
|
||||
if (!sharedImage) {
|
||||
resetShareIntent();
|
||||
return;
|
||||
}
|
||||
const uri = sharedImage.path;
|
||||
if (!uri) {
|
||||
resetShareIntent();
|
||||
return;
|
||||
}
|
||||
resetShareIntent();
|
||||
router.push({
|
||||
pathname: '/scanner',
|
||||
params: { sharedImageUri: uri },
|
||||
});
|
||||
}, [hasShareIntent, shareIntent, resetShareIntent, router, isAppReady]);
|
||||
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;
|
||||
@@ -202,6 +158,7 @@ function RootLayoutInner() {
|
||||
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' }}
|
||||
@@ -232,6 +189,7 @@ function RootLayoutInner() {
|
||||
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' }}
|
||||
|
||||
204
app/dataUrl=greenlensShareKey.tsx
Normal file
204
app/dataUrl=greenlensShareKey.tsx
Normal file
@@ -0,0 +1,204 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { ActivityIndicator, Image, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { parseShareIntent, ShareIntentModule } from 'expo-share-intent';
|
||||
import * as ExpoLinking from 'expo-linking';
|
||||
import { SHARE_INTENT_KEY, storeSharedImageUri } from '../utils/shareHandoff';
|
||||
import { resolveSharedImageUri, summarizeShareIntent } from '../utils/shareIntent';
|
||||
|
||||
const SHARE_INTENT_SCHEME = 'greenlens';
|
||||
const SHARE_INTENT_OPTIONS = {
|
||||
scheme: SHARE_INTENT_SCHEME,
|
||||
};
|
||||
const isShareIntentUrl = (url: string | null | undefined) => Boolean(url?.includes(`${SHARE_INTENT_SCHEME}://dataUrl=`));
|
||||
|
||||
export default function ShareIntentCallbackScreen() {
|
||||
const router = useRouter();
|
||||
const [isWaiting, setIsWaiting] = React.useState(true);
|
||||
const [failureDetails, setFailureDetails] = React.useState<string | null>(null);
|
||||
const [previewUri, setPreviewUri] = React.useState<string | null>(null);
|
||||
const pendingKeyRef = React.useRef<string | null>(null);
|
||||
const getIntentCalledRef = React.useRef(false);
|
||||
const settledRef = React.useRef(false);
|
||||
const linkingUrl = ExpoLinking.useLinkingURL();
|
||||
|
||||
useEffect(() => {
|
||||
const fallback = setTimeout(() => {
|
||||
if (settledRef.current) return;
|
||||
setIsWaiting(false);
|
||||
setFailureDetails((current) => current || 'Keine Antwort von der Share Extension.');
|
||||
}, 15000);
|
||||
|
||||
return () => clearTimeout(fallback);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const showFailure = (message: string) => {
|
||||
settledRef.current = true;
|
||||
ShareIntentModule?.clearShareIntent(SHARE_INTENT_KEY);
|
||||
setPreviewUri(null);
|
||||
setIsWaiting(false);
|
||||
setFailureDetails(message);
|
||||
};
|
||||
|
||||
const changeSubscription = ShareIntentModule?.addListener('onChange', async (event) => {
|
||||
try {
|
||||
setIsWaiting(true);
|
||||
setFailureDetails(null);
|
||||
const shareIntent = parseShareIntent(event.value, SHARE_INTENT_OPTIONS);
|
||||
if (__DEV__) {
|
||||
console.debug('[ShareIntentCallback]', summarizeShareIntent(shareIntent));
|
||||
}
|
||||
const resolved = await resolveSharedImageUri(shareIntent);
|
||||
if (!resolved) {
|
||||
showFailure('Die Quelle hat keinen nutzbaren Bildanhang oder Bild-Link geliefert.');
|
||||
return;
|
||||
}
|
||||
settledRef.current = true;
|
||||
const sharedImageKey = storeSharedImageUri(resolved.uri);
|
||||
ShareIntentModule?.clearShareIntent(SHARE_INTENT_KEY);
|
||||
if (resolved.requiresConfirmation) {
|
||||
pendingKeyRef.current = sharedImageKey;
|
||||
setIsWaiting(false);
|
||||
setPreviewUri(resolved.uri);
|
||||
return;
|
||||
}
|
||||
router.replace({
|
||||
pathname: '/scanner',
|
||||
params: { sharedImageKey },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[ShareIntentCallback] failed to parse share intent', error);
|
||||
showFailure('Die Share-Daten konnten nicht gelesen werden.');
|
||||
}
|
||||
});
|
||||
|
||||
const errorSubscription = ShareIntentModule?.addListener('onError', (event) => {
|
||||
console.error('[ShareIntentCallback] native error', event.value);
|
||||
showFailure(event.value || 'Die Share Extension hat einen Fehler gemeldet.');
|
||||
});
|
||||
|
||||
const url = linkingUrl || ExpoLinking.getLinkingURL();
|
||||
if (url && isShareIntentUrl(url) && !getIntentCalledRef.current) {
|
||||
getIntentCalledRef.current = true;
|
||||
ShareIntentModule?.getShareIntent(url);
|
||||
}
|
||||
|
||||
return () => {
|
||||
changeSubscription?.remove();
|
||||
errorSubscription?.remove();
|
||||
};
|
||||
}, [linkingUrl, router]);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{isWaiting ? (
|
||||
<ActivityIndicator color="#F4F7F1" size="large" />
|
||||
) : previewUri ? (
|
||||
<View style={styles.messageBox}>
|
||||
<Text style={styles.title}>Pflanze gefunden</Text>
|
||||
<Text style={styles.body}>Ist das die Pflanze, die du scannen möchtest?</Text>
|
||||
<Image source={{ uri: previewUri }} style={styles.previewImage} resizeMode="cover" />
|
||||
<Text style={styles.hint}>
|
||||
Wenn das nicht die Pflanze ist, tippe „Scanner öffnen" und teile das Bild direkt in Safari.
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.button}
|
||||
onPress={() => {
|
||||
if (pendingKeyRef.current) {
|
||||
router.replace({ pathname: '/scanner', params: { sharedImageKey: pendingKeyRef.current } });
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Text style={styles.buttonText}>Diese Pflanze scannen</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={styles.buttonSecondary} onPress={() => router.replace('/scanner')}>
|
||||
<Text style={styles.buttonSecondaryText}>Scanner öffnen</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.messageBox}>
|
||||
<Text style={styles.title}>Bild konnte nicht geladen werden.</Text>
|
||||
<Text style={styles.body}>
|
||||
Tippe und halte das Bild in Safari oder Instagram, wähle „Bild teilen" und teile es direkt mit GreenLens.
|
||||
</Text>
|
||||
{failureDetails ? (
|
||||
<Text style={styles.detail}>{failureDetails}</Text>
|
||||
) : null}
|
||||
<TouchableOpacity style={styles.button} onPress={() => router.replace('/scanner')}>
|
||||
<Text style={styles.buttonText}>Scanner öffnen</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: '#111813',
|
||||
padding: 24,
|
||||
},
|
||||
messageBox: {
|
||||
alignItems: 'center',
|
||||
gap: 14,
|
||||
},
|
||||
title: {
|
||||
color: '#F4F7F1',
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
textAlign: 'center',
|
||||
},
|
||||
body: {
|
||||
color: '#CAD3CB',
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
textAlign: 'center',
|
||||
},
|
||||
detail: {
|
||||
color: '#8F9A91',
|
||||
fontSize: 12,
|
||||
lineHeight: 17,
|
||||
textAlign: 'center',
|
||||
},
|
||||
previewImage: {
|
||||
width: '100%',
|
||||
aspectRatio: 4 / 3,
|
||||
borderRadius: 16,
|
||||
backgroundColor: '#1E2820',
|
||||
},
|
||||
button: {
|
||||
marginTop: 8,
|
||||
borderRadius: 12,
|
||||
backgroundColor: '#D7F5A2',
|
||||
paddingHorizontal: 18,
|
||||
paddingVertical: 12,
|
||||
},
|
||||
buttonText: {
|
||||
color: '#111813',
|
||||
fontSize: 14,
|
||||
fontWeight: '800',
|
||||
},
|
||||
hint: {
|
||||
color: '#8F9A91',
|
||||
fontSize: 11,
|
||||
lineHeight: 16,
|
||||
textAlign: 'center',
|
||||
marginTop: -6,
|
||||
},
|
||||
buttonSecondary: {
|
||||
marginTop: 4,
|
||||
borderRadius: 12,
|
||||
paddingHorizontal: 18,
|
||||
paddingVertical: 12,
|
||||
},
|
||||
buttonSecondaryText: {
|
||||
color: '#8F9A91',
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
textAlign: 'center',
|
||||
},
|
||||
});
|
||||
@@ -11,6 +11,7 @@ import * as ImageManipulator from 'expo-image-manipulator';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
import * as AppleAuthentication from 'expo-apple-authentication';
|
||||
import Constants from 'expo-constants';
|
||||
import { ShareIntentModule } from 'expo-share-intent';
|
||||
import { useSafeAnalytics } from '../services/analytics';
|
||||
import { useApp } from '../context/AppContext';
|
||||
import { useColors } from '../constants/Colors';
|
||||
@@ -22,6 +23,7 @@ import { isBackendApiError } from '../services/backend/contracts';
|
||||
import { createIdempotencyKey } from '../utils/idempotency';
|
||||
import { AuthService } from '../services/authService';
|
||||
import { getMockPlantByImage } from '../services/backend/mockCatalog';
|
||||
import { consumeSharedImageUri, SHARE_INTENT_KEY } from '../utils/shareHandoff';
|
||||
|
||||
const HEALTH_CHECK_CREDIT_COST = 2;
|
||||
const DEMO_SCAN_LIMIT = 5;
|
||||
@@ -129,7 +131,7 @@ const getBillingCopy = (language: 'de' | 'en' | 'es') => {
|
||||
};
|
||||
|
||||
export default function ScannerScreen() {
|
||||
const params = useLocalSearchParams<{ mode?: string; plantId?: string; sharedImageUri?: string }>();
|
||||
const params = useLocalSearchParams<{ mode?: string; plantId?: string; sharedImageKey?: string; sharedImageUri?: string }>();
|
||||
const posthog = useSafeAnalytics();
|
||||
const {
|
||||
isDarkMode,
|
||||
@@ -160,6 +162,9 @@ export default function ScannerScreen() {
|
||||
const sharedImageUri = Array.isArray(params.sharedImageUri)
|
||||
? params.sharedImageUri[0]
|
||||
: params.sharedImageUri;
|
||||
const sharedImageKey = Array.isArray(params.sharedImageKey)
|
||||
? params.sharedImageKey[0]
|
||||
: params.sharedImageKey;
|
||||
const hasActiveEntitlement = billingSummary?.entitlement?.plan === 'pro'
|
||||
&& billingSummary?.entitlement?.status === 'active';
|
||||
const isDemoMode = !hasActiveEntitlement;
|
||||
@@ -197,17 +202,10 @@ export default function ScannerScreen() {
|
||||
};
|
||||
}, [isExpoGo]);
|
||||
|
||||
const hasProcessedSharedImage = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!sharedImageUri || hasProcessedSharedImage.current) return;
|
||||
hasProcessedSharedImage.current = true;
|
||||
(async () => {
|
||||
const analysisUri = await resizeForAnalysis(sharedImageUri);
|
||||
setDemoResultVisible(false);
|
||||
setSelectedImage(sharedImageUri);
|
||||
analyzeImage(analysisUri, sharedImageUri);
|
||||
})();
|
||||
}, [sharedImageUri]);
|
||||
const lastProcessedShareToken = useRef<string | null>(null);
|
||||
const sharedAnalysisInFlightToken = useRef<string | null>(null);
|
||||
const resizeForAnalysisRef = useRef<(uri: string) => Promise<string>>(async (uri) => uri);
|
||||
const analyzeImageRef = useRef<(imageUri: string, galleryImageUri?: string) => Promise<void>>(async () => {});
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAnalyzing) {
|
||||
@@ -256,8 +254,8 @@ export default function ScannerScreen() {
|
||||
try {
|
||||
const result = await ImageManipulator.manipulateAsync(
|
||||
uri,
|
||||
[{ resize: { width: 768 } }],
|
||||
{ compress: 0.7, format: ImageManipulator.SaveFormat.JPEG, base64: true },
|
||||
[{ resize: { width: 1280 } }],
|
||||
{ compress: 0.9, format: ImageManipulator.SaveFormat.JPEG, base64: true },
|
||||
);
|
||||
return result.base64 ? `data:image/jpeg;base64,${result.base64}` : result.uri;
|
||||
} catch {
|
||||
@@ -472,6 +470,45 @@ export default function ScannerScreen() {
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
resizeForAnalysisRef.current = resizeForAnalysis;
|
||||
analyzeImageRef.current = analyzeImage;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const shareToken = sharedImageKey || sharedImageUri;
|
||||
if (!shareToken || isLoadingBilling || isAnalyzing) return;
|
||||
if (lastProcessedShareToken.current === shareToken) return;
|
||||
if (sharedAnalysisInFlightToken.current) return;
|
||||
|
||||
const handoffImageUri = consumeSharedImageUri(sharedImageKey);
|
||||
const nextSharedImageUri = handoffImageUri || sharedImageUri;
|
||||
if (!nextSharedImageUri) return;
|
||||
|
||||
lastProcessedShareToken.current = shareToken;
|
||||
sharedAnalysisInFlightToken.current = shareToken;
|
||||
ShareIntentModule?.clearShareIntent(SHARE_INTENT_KEY);
|
||||
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const analysisUri = await resizeForAnalysisRef.current(nextSharedImageUri);
|
||||
if (cancelled || sharedAnalysisInFlightToken.current !== shareToken) return;
|
||||
setDemoResultVisible(false);
|
||||
setSelectedImage(analysisUri);
|
||||
await analyzeImageRef.current(analysisUri, nextSharedImageUri);
|
||||
} finally {
|
||||
if (sharedAnalysisInFlightToken.current === shareToken) {
|
||||
sharedAnalysisInFlightToken.current = null;
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [sharedImageKey, sharedImageUri, isLoadingBilling, isAnalyzing]);
|
||||
|
||||
const takePicture = async () => {
|
||||
if (!cameraRef.current || isAnalyzing) return;
|
||||
await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
|
||||
|
||||
Reference in New Issue
Block a user