Not a Plant Fehlermeldung

This commit is contained in:
2026-04-17 13:12:36 +02:00
parent 383d8484a6
commit 77b98a3ebf
12 changed files with 831 additions and 195 deletions

View File

@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from 'react';
import { Animated, Easing, Image, StyleSheet, Text, View } from 'react-native';
import { Animated, AppState, Easing, Image, StyleSheet, 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';
@@ -53,14 +53,14 @@ const ensureInstallConsistency = async (): Promise<boolean> => {
import { AnimatedSplashScreen } from '../components/AnimatedSplashScreen';
function RootLayoutInner() {
const { isDarkMode, colorPalette, signOut, session, isInitializing, isLoadingPlants, syncRevenueCatState } = useApp();
const colors = useColors(isDarkMode, colorPalette);
const pathname = usePathname();
const [installCheckDone, setInstallCheckDone] = useState(false);
const [splashAnimationComplete, setSplashAnimationComplete] = useState(false);
const [revenueCatReady, setRevenueCatReady] = useState(Constants.appOwnership === 'expo');
const posthog = usePostHog();
function RootLayoutInner() {
const { isDarkMode, colorPalette, signOut, session, isInitializing, isLoadingPlants, syncRevenueCatState } = useApp();
const colors = useColors(isDarkMode, colorPalette);
const pathname = usePathname();
const [installCheckDone, setInstallCheckDone] = useState(false);
const [splashAnimationComplete, setSplashAnimationComplete] = useState(false);
const [revenueCatReady, setRevenueCatReady] = useState(Constants.appOwnership === 'expo');
const posthog = usePostHog();
useEffect(() => {
// RevenueCat requires native store access — not available in Expo Go
@@ -72,42 +72,42 @@ function RootLayoutInner() {
Purchases.setLogLevel(LOG_LEVEL.VERBOSE);
const iosApiKey = process.env.EXPO_PUBLIC_REVENUECAT_IOS_API_KEY || 'appl_hrSpsuUuVstbHhYIDnOqYxPOnmR';
const androidApiKey = process.env.EXPO_PUBLIC_REVENUECAT_ANDROID_API_KEY || 'goog_placeholder';
if (Platform.OS === 'ios') {
Purchases.configure({ apiKey: iosApiKey });
} else if (Platform.OS === 'android') {
Purchases.configure({ apiKey: androidApiKey });
}
setRevenueCatReady(true);
}, []);
useEffect(() => {
const isExpoGo = Constants.appOwnership === 'expo';
if (isExpoGo || !revenueCatReady) {
return;
}
let cancelled = false;
(async () => {
try {
if (session?.serverUserId) {
await Purchases.logIn(session.serverUserId);
const customerInfo = await Purchases.getCustomerInfo();
if (!cancelled) {
await syncRevenueCatState(customerInfo as any, 'app_init');
}
} else {
await Purchases.logOut();
}
} catch (error) {
console.error('Failed to align RevenueCat identity', error);
}
})();
return () => {
cancelled = true;
};
}, [revenueCatReady, session?.serverUserId, syncRevenueCatState]);
const androidApiKey = process.env.EXPO_PUBLIC_REVENUECAT_ANDROID_API_KEY || 'goog_placeholder';
if (Platform.OS === 'ios') {
Purchases.configure({ apiKey: iosApiKey });
} else if (Platform.OS === 'android') {
Purchases.configure({ apiKey: androidApiKey });
}
setRevenueCatReady(true);
}, []);
useEffect(() => {
const isExpoGo = Constants.appOwnership === 'expo';
if (isExpoGo || !revenueCatReady) {
return;
}
let cancelled = false;
(async () => {
try {
if (session?.serverUserId) {
await Purchases.logIn(session.serverUserId);
const customerInfo = await Purchases.getCustomerInfo();
if (!cancelled) {
await syncRevenueCatState(customerInfo as any, 'app_init');
}
} else {
await Purchases.logOut();
}
} catch (error) {
console.error('Failed to align RevenueCat identity', error);
}
})();
return () => {
cancelled = true;
};
}, [revenueCatReady, session?.serverUserId, syncRevenueCatState]);
useEffect(() => {
if (session?.serverUserId) {
@@ -120,6 +120,20 @@ function RootLayoutInner() {
}
}, [session, posthog]);
useEffect(() => {
posthog.capture('screen_viewed', { screen: pathname });
}, [pathname, posthog]);
useEffect(() => {
posthog.capture('app_opened');
const subscription = AppState.addEventListener('change', (nextState) => {
if (nextState === 'active') {
posthog.capture('app_opened');
}
});
return () => subscription.remove();
}, [posthog]);
useEffect(() => {
(async () => {
const didResetSessionForFreshInstall = await ensureInstallConsistency();
@@ -226,7 +240,8 @@ export default function RootLayout() {
return (
<PostHogProvider apiKey={POSTHOG_API_KEY} options={{
host: 'https://us.i.posthog.com',
enableSessionReplay: true,
enableSessionReplay: false,
debug: __DEV__,
}}>
<AppProvider>
<CoachMarksProvider>

View File

@@ -12,6 +12,7 @@ import Purchases, {
PurchasesStoreProduct,
} from 'react-native-purchases';
import { useApp } from '../../context/AppContext';
import { usePostHog } from 'posthog-react-native';
import { useColors } from '../../constants/Colors';
import { ThemeBackdrop } from '../../components/ThemeBackdrop';
import { Language } from '../../types';
@@ -211,6 +212,7 @@ export default function BillingScreen() {
const router = useRouter();
const { isDarkMode, language, billingSummary, isLoadingBilling, simulatePurchase, simulateWebhookEvent, syncRevenueCatState, colorPalette, session } = useApp();
const colors = useColors(isDarkMode, colorPalette);
const posthog = usePostHog();
const copy = getBillingCopy(language);
const isExpoGo = Constants.appOwnership === 'expo';
@@ -272,6 +274,10 @@ export default function BillingScreen() {
};
}, [isExpoGo]);
useEffect(() => {
posthog.capture('paywall_viewed', { plan_id: planId });
}, [posthog, planId]);
const monthlyPackage = subscriptionPackages.monthly_pro;
const yearlyPackage = subscriptionPackages.yearly_pro;
@@ -290,6 +296,7 @@ export default function BillingScreen() {
const handlePurchase = async (productId: PurchaseProductId) => {
setIsUpdating(true);
posthog.capture('purchase_initiated', { product_id: productId });
try {
if (isExpoGo) {
// ExpoGo has no native RevenueCat — use simulation for development only
@@ -316,6 +323,7 @@ export default function BillingScreen() {
// Derive plan locally from RevenueCat — backend sync via webhook comes later (Step 3)
const customerInfo = await Purchases.getCustomerInfo();
await syncRevenueCatState(customerInfo as any, 'subscription_purchase');
posthog.capture('subscription_started', { product_id: productId });
} else {
const selectedProduct = topupProducts[productId];
if (!selectedProduct) {
@@ -324,6 +332,7 @@ export default function BillingScreen() {
await Purchases.purchaseStoreProduct(selectedProduct);
const customerInfo = await Purchases.getCustomerInfo();
await syncRevenueCatState(customerInfo as any, 'topup_purchase');
posthog.capture('topup_purchased', { product_id: productId });
}
}
setSubModalVisible(false);
@@ -332,6 +341,7 @@ export default function BillingScreen() {
const userCancelled = typeof e === 'object' && e !== null && 'userCancelled' in e && Boolean((e as { userCancelled?: boolean }).userCancelled);
if (userCancelled) {
posthog.capture('purchase_cancelled', { product_id: productId });
return;
}
@@ -345,6 +355,7 @@ export default function BillingScreen() {
}
console.error('Payment failed', e);
posthog.capture('purchase_failed', { product_id: productId, error: msg });
Alert.alert('Unerwarteter Fehler', msg);
} finally {
setIsUpdating(false);

View File

@@ -37,6 +37,8 @@ const getBillingCopy = (language: 'de' | 'en' | 'es') => {
timeoutTitle: 'Scan zu langsam',
timeoutMessage: 'Die Analyse hat zu lange gedauert. Bitte erneut versuchen.',
retryLabel: 'Erneut versuchen',
notAPlantTitle: 'Keine Pflanze erkannt',
notAPlantMessage: 'Das Bild zeigt keine erkennbare Pflanze. Bitte fotografiere eine Pflanze und versuche es erneut.',
providerErrorMessage: 'KI-Scan gerade nicht verfügbar. Bitte versuche es erneut.',
healthProviderErrorMessage: 'KI-Health-Check gerade nicht verfügbar. Bitte versuche es erneut.',
healthTitle: 'Health Check',
@@ -61,6 +63,8 @@ const getBillingCopy = (language: 'de' | 'en' | 'es') => {
timeoutTitle: 'Escaneo lento',
timeoutMessage: 'El análisis tardó demasiado. Inténtalo de nuevo.',
retryLabel: 'Reintentar',
notAPlantTitle: 'No es una planta',
notAPlantMessage: 'La imagen no muestra una planta reconocible. Por favor fotografía una planta e inténtalo de nuevo.',
providerErrorMessage: 'Escaneo IA no disponible ahora. Inténtalo de nuevo.',
healthProviderErrorMessage: 'Health-check IA no disponible ahora. Inténtalo de nuevo.',
healthTitle: 'Health Check',
@@ -84,6 +88,8 @@ const getBillingCopy = (language: 'de' | 'en' | 'es') => {
timeoutTitle: 'Scan Too Slow',
timeoutMessage: 'Analysis took too long. Please try again.',
retryLabel: 'Try again',
notAPlantTitle: 'No plant detected',
notAPlantMessage: 'The image does not show a recognizable plant. Please photograph a plant and try again.',
providerErrorMessage: 'AI scan is currently unavailable. Please try again.',
healthProviderErrorMessage: 'AI health check is currently unavailable. Please try again.',
healthTitle: 'Health Check',
@@ -181,8 +187,8 @@ export default function ScannerScreen() {
try {
const result = await ImageManipulator.manipulateAsync(
uri,
[{ resize: { width: 1024 } }],
{ compress: 0.6, format: ImageManipulator.SaveFormat.JPEG, base64: true },
[{ resize: { width: 768 } }],
{ compress: 0.7, format: ImageManipulator.SaveFormat.JPEG, base64: true },
);
return result.base64 ? `data:image/jpeg;base64,${result.base64}` : result.uri;
} catch {
@@ -334,6 +340,12 @@ export default function ScannerScreen() {
{ text: billingCopy.retryLabel, onPress: () => analyzeImage(imageUri, galleryImageUri) },
],
);
} else if (isBackendApiError(error) && error.code === 'NOT_A_PLANT') {
Alert.alert(
billingCopy.notAPlantTitle,
billingCopy.notAPlantMessage,
[{ text: billingCopy.dismiss, style: 'cancel' }],
);
} else if (isBackendApiError(error) && error.code === 'PROVIDER_ERROR') {
Alert.alert(
billingCopy.genericErrorTitle,
@@ -357,14 +369,11 @@ export default function ScannerScreen() {
const takePicture = async () => {
if (!cameraRef.current || isAnalyzing) return;
await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
const photo = await cameraRef.current.takePictureAsync({ base64: true, quality: 0.5 });
const photo = await cameraRef.current.takePictureAsync({ base64: false, quality: 0.9 });
if (photo) {
const analysisUri = photo.base64
? `data:image/jpeg;base64,${photo.base64}`
: photo.uri;
const galleryUri = photo.uri || analysisUri;
const analysisUri = await resizeForAnalysis(photo.uri);
setSelectedImage(analysisUri);
analyzeImage(analysisUri, galleryUri);
analyzeImage(analysisUri, photo.uri);
}
};