feat: implement plant health scanning functionality with backend integration and UI support

This commit is contained in:
2026-04-08 19:34:43 +02:00
parent d0a13fa4f0
commit de8130686a
15 changed files with 232 additions and 128 deletions

49
SplitImage.ps1 Normal file
View File

@@ -0,0 +1,49 @@
param (
[string]$ImagePath,
[string]$OutputDir,
[string]$Prefix
)
Add-Type -AssemblyName System.Drawing
$img = [System.Drawing.Image]::FromFile($ImagePath)
$w = $img.Width
$h = $img.Height
$targetWidth = $w
$targetHeight = [math]::Floor($w / 3.0)
$left = 0
$top = 0
if ($targetHeight -gt $h) {
$targetHeight = $h
$targetWidth = $h * 3
$left = [math]::Floor(($w - $targetWidth) / 2.0)
} else {
$top = [math]::Floor(($h - $targetHeight) / 2.0)
}
[int]$sqSize = [math]::Floor($targetWidth / 3.0)
[int]$leftInt = $left
[int]$topInt = $top
for ($i = 0; $i -lt 3; $i++) {
$bmp = New-Object System.Drawing.Bitmap -ArgumentList $sqSize, $sqSize
$g = [System.Drawing.Graphics]::FromImage($bmp)
[int]$rx = $leftInt + ($i * $sqSize)
$srcRect = New-Object System.Drawing.Rectangle -ArgumentList $rx, $topInt, $sqSize, $sqSize
$destRect = New-Object System.Drawing.Rectangle -ArgumentList 0, 0, $sqSize, $sqSize
$g.DrawImage($img, $destRect, $srcRect, [System.Drawing.GraphicsUnit]::Pixel)
$g.Dispose()
$outFile = Join-Path $OutputDir ("{0}_part{1}.png" -f $Prefix, ($i + 1))
$bmp.Save($outFile, [System.Drawing.Imaging.ImageFormat]::Png)
$bmp.Dispose()
}
$img.Dispose()
Write-Output "Successfully split the image into 3 pieces."

View File

@@ -14,7 +14,7 @@ import { useColors } from '../constants/Colors';
import { PlantRecognitionService } from '../services/plantRecognitionService'; import { PlantRecognitionService } from '../services/plantRecognitionService';
import { IdentificationResult } from '../types'; import { IdentificationResult } from '../types';
import { ResultCard } from '../components/ResultCard'; import { ResultCard } from '../components/ResultCard';
import { backendApiClient, isInsufficientCreditsError, isNetworkError } from '../services/backend/backendApiClient'; import { backendApiClient, isInsufficientCreditsError, isNetworkError, isTimeoutError } from '../services/backend/backendApiClient';
import { isBackendApiError } from '../services/backend/contracts'; import { isBackendApiError } from '../services/backend/contracts';
import { createIdempotencyKey } from '../utils/idempotency'; import { createIdempotencyKey } from '../utils/idempotency';
@@ -33,6 +33,8 @@ const getBillingCopy = (language: 'de' | 'en' | 'es') => {
genericErrorMessage: 'Analyse fehlgeschlagen.', genericErrorMessage: 'Analyse fehlgeschlagen.',
noConnectionTitle: 'Keine Verbindung', noConnectionTitle: 'Keine Verbindung',
noConnectionMessage: 'Keine Verbindung zum Server. Bitte prüfe deine Internetverbindung und versuche es erneut.', noConnectionMessage: 'Keine Verbindung zum Server. Bitte prüfe deine Internetverbindung und versuche es erneut.',
timeoutTitle: 'Scan zu langsam',
timeoutMessage: 'Die Analyse hat zu lange gedauert. Bitte erneut versuchen.',
retryLabel: 'Erneut versuchen', retryLabel: 'Erneut versuchen',
providerErrorMessage: 'KI-Scan gerade nicht verfügbar. Bitte 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.', healthProviderErrorMessage: 'KI-Health-Check gerade nicht verfügbar. Bitte versuche es erneut.',
@@ -55,6 +57,8 @@ const getBillingCopy = (language: 'de' | 'en' | 'es') => {
genericErrorMessage: 'Analisis fallido.', genericErrorMessage: 'Analisis fallido.',
noConnectionTitle: 'Sin conexión', noConnectionTitle: 'Sin conexión',
noConnectionMessage: 'Sin conexión al servidor. Comprueba tu internet e inténtalo de nuevo.', noConnectionMessage: 'Sin conexión al servidor. Comprueba tu internet e inténtalo de nuevo.',
timeoutTitle: 'Escaneo lento',
timeoutMessage: 'El análisis tardó demasiado. Inténtalo de nuevo.',
retryLabel: 'Reintentar', retryLabel: 'Reintentar',
providerErrorMessage: 'Escaneo IA no disponible ahora. 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.', healthProviderErrorMessage: 'Health-check IA no disponible ahora. Inténtalo de nuevo.',
@@ -76,6 +80,8 @@ const getBillingCopy = (language: 'de' | 'en' | 'es') => {
genericErrorMessage: 'Analysis failed.', genericErrorMessage: 'Analysis failed.',
noConnectionTitle: 'No connection', noConnectionTitle: 'No connection',
noConnectionMessage: 'Could not reach the server. Check your internet connection and try again.', noConnectionMessage: 'Could not reach the server. Check your internet connection and try again.',
timeoutTitle: 'Scan Too Slow',
timeoutMessage: 'Analysis took too long. Please try again.',
retryLabel: 'Try again', retryLabel: 'Try again',
providerErrorMessage: 'AI scan is currently unavailable. Please try again.', providerErrorMessage: 'AI scan is currently unavailable. Please try again.',
healthProviderErrorMessage: 'AI health check is currently unavailable. Please try again.', healthProviderErrorMessage: 'AI health check is currently unavailable. Please try again.',
@@ -295,6 +301,15 @@ export default function ScannerScreen() {
}, },
], ],
); );
} else if (isTimeoutError(error)) {
Alert.alert(
billingCopy.timeoutTitle,
billingCopy.timeoutMessage,
[
{ text: billingCopy.dismiss, style: 'cancel' },
{ text: billingCopy.retryLabel, onPress: () => analyzeImage(imageUri, galleryImageUri) },
],
);
} else if (isNetworkError(error)) { } else if (isNetworkError(error)) {
Alert.alert( Alert.alert(
billingCopy.noConnectionTitle, billingCopy.noConnectionTitle,

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 709 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 514 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 985 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 854 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 812 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 731 KiB

View File

@@ -1,25 +1,25 @@
import { import {
BackendApiError, BackendApiError,
BackendErrorCode, BackendErrorCode,
BillingSummary, BillingSummary,
HealthCheckResponse, HealthCheckResponse,
PurchaseProductId, PurchaseProductId,
RevenueCatCustomerInfo, RevenueCatCustomerInfo,
RevenueCatSyncSource, RevenueCatSyncSource,
ScanPlantResponse, ScanPlantResponse,
SemanticSearchResponse, SemanticSearchResponse,
ServiceHealthResponse, ServiceHealthResponse,
SimulatedWebhookEvent, SimulatedWebhookEvent,
SimulatePurchaseResponse, SimulatePurchaseResponse,
SimulateWebhookResponse, SimulateWebhookResponse,
SyncRevenueCatStateResponse, SyncRevenueCatStateResponse,
} from './contracts'; } from './contracts';
import { getAuthToken } from './userIdentityService'; import { getAuthToken } from './userIdentityService';
import { mockBackendService } from './mockBackendService'; import { mockBackendService } from './mockBackendService';
import { CareInfo, Language } from '../../types'; import { CareInfo, Language } from '../../types';
import { getConfiguredBackendRootUrl } from '../../utils/backendUrl'; import { getConfiguredBackendRootUrl } from '../../utils/backendUrl';
const REQUEST_TIMEOUT_MS = 15000; const REQUEST_TIMEOUT_MS = 60000;
const mapHttpStatusToErrorCode = (status: number): BackendErrorCode => { const mapHttpStatusToErrorCode = (status: number): BackendErrorCode => {
if (status === 400) return 'BAD_REQUEST'; if (status === 400) return 'BAD_REQUEST';
@@ -29,12 +29,12 @@ const mapHttpStatusToErrorCode = (status: number): BackendErrorCode => {
return 'PROVIDER_ERROR'; return 'PROVIDER_ERROR';
}; };
const buildBackendUrl = (path: string): string => { const buildBackendUrl = (path: string): string => {
const backendBaseUrl = getConfiguredBackendRootUrl(); const backendBaseUrl = getConfiguredBackendRootUrl();
if (!backendBaseUrl) return path; if (!backendBaseUrl) return path;
const base = backendBaseUrl.replace(/\/$/, ''); const base = backendBaseUrl.replace(/\/$/, '');
return `${base}${path}`; return `${base}${path}`;
}; };
const parseMaybeJson = (value: string): Record<string, unknown> | null => { const parseMaybeJson = (value: string): Record<string, unknown> | null => {
if (!value) return null; if (!value) return null;
@@ -107,18 +107,18 @@ const makeRequest = async <T,>(
}; };
export const backendApiClient = { export const backendApiClient = {
getServiceHealth: async (): Promise<ServiceHealthResponse> => { getServiceHealth: async (): Promise<ServiceHealthResponse> => {
if (!getConfiguredBackendRootUrl()) { if (!getConfiguredBackendRootUrl()) {
return { return {
ok: true, ok: true,
uptimeSec: 0, uptimeSec: 0,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
openAiConfigured: Boolean(process.env.EXPO_PUBLIC_OPENAI_API_KEY), openAiConfigured: Boolean(process.env.EXPO_PUBLIC_OPENAI_API_KEY),
dbReady: true, dbReady: true,
dbPath: 'in-app-mock-backend', dbPath: 'in-app-mock-backend',
scanModel: (process.env.EXPO_PUBLIC_OPENAI_SCAN_MODEL || 'gpt-5').trim(), scanModel: (process.env.EXPO_PUBLIC_OPENAI_SCAN_MODEL || 'gpt-5').trim(),
healthModel: (process.env.EXPO_PUBLIC_OPENAI_HEALTH_MODEL || process.env.EXPO_PUBLIC_OPENAI_SCAN_MODEL || 'gpt-5').trim(), healthModel: (process.env.EXPO_PUBLIC_OPENAI_HEALTH_MODEL || process.env.EXPO_PUBLIC_OPENAI_SCAN_MODEL || 'gpt-5').trim(),
}; };
} }
const token = await getAuthToken(); const token = await getAuthToken();
@@ -128,49 +128,49 @@ export const backendApiClient = {
}); });
}, },
getBillingSummary: async (): Promise<BillingSummary> => { getBillingSummary: async (): Promise<BillingSummary> => {
const token = await getAuthToken(); const token = await getAuthToken();
if (!getConfiguredBackendRootUrl()) { if (!getConfiguredBackendRootUrl()) {
return mockBackendService.getBillingSummary(token); return mockBackendService.getBillingSummary(token);
} }
return makeRequest<BillingSummary>('/v1/billing/summary', { return makeRequest<BillingSummary>('/v1/billing/summary', {
method: 'GET', method: 'GET',
token, token,
}); });
}, },
syncRevenueCatState: async (params: { syncRevenueCatState: async (params: {
customerInfo: RevenueCatCustomerInfo; customerInfo: RevenueCatCustomerInfo;
source?: RevenueCatSyncSource; source?: RevenueCatSyncSource;
}): Promise<SyncRevenueCatStateResponse> => { }): Promise<SyncRevenueCatStateResponse> => {
const token = await getAuthToken(); const token = await getAuthToken();
if (!getConfiguredBackendRootUrl()) { if (!getConfiguredBackendRootUrl()) {
return mockBackendService.syncRevenueCatState({ return mockBackendService.syncRevenueCatState({
userId: token, userId: token,
customerInfo: params.customerInfo, customerInfo: params.customerInfo,
source: params.source, source: params.source,
}); });
} }
return makeRequest<SyncRevenueCatStateResponse>('/v1/billing/sync-revenuecat', { return makeRequest<SyncRevenueCatStateResponse>('/v1/billing/sync-revenuecat', {
method: 'POST', method: 'POST',
token, token,
body: { body: {
customerInfo: params.customerInfo, customerInfo: params.customerInfo,
source: params.source, source: params.source,
}, },
}); });
}, },
scanPlant: async (params: { scanPlant: async (params: {
idempotencyKey: string; idempotencyKey: string;
imageUri: string; imageUri: string;
language: Language; language: Language;
}): Promise<ScanPlantResponse> => { }): Promise<ScanPlantResponse> => {
const token = await getAuthToken(); const token = await getAuthToken();
if (!getConfiguredBackendRootUrl()) { if (!getConfiguredBackendRootUrl()) {
return mockBackendService.scanPlant({ return mockBackendService.scanPlant({
userId: token, userId: token,
idempotencyKey: params.idempotencyKey, idempotencyKey: params.idempotencyKey,
imageUri: params.imageUri, imageUri: params.imageUri,
@@ -193,10 +193,10 @@ export const backendApiClient = {
idempotencyKey: string; idempotencyKey: string;
query: string; query: string;
language: Language; language: Language;
}): Promise<SemanticSearchResponse> => { }): Promise<SemanticSearchResponse> => {
const token = await getAuthToken(); const token = await getAuthToken();
if (!getConfiguredBackendRootUrl()) { if (!getConfiguredBackendRootUrl()) {
return mockBackendService.semanticSearch({ return mockBackendService.semanticSearch({
userId: token, userId: token,
idempotencyKey: params.idempotencyKey, idempotencyKey: params.idempotencyKey,
query: params.query, query: params.query,
@@ -225,10 +225,10 @@ export const backendApiClient = {
careInfo: CareInfo; careInfo: CareInfo;
description?: string; description?: string;
}; };
}): Promise<HealthCheckResponse> => { }): Promise<HealthCheckResponse> => {
const token = await getAuthToken(); const token = await getAuthToken();
if (!getConfiguredBackendRootUrl()) { if (!getConfiguredBackendRootUrl()) {
return mockBackendService.healthCheck({ return mockBackendService.healthCheck({
userId: token, userId: token,
idempotencyKey: params.idempotencyKey, idempotencyKey: params.idempotencyKey,
imageUri: params.imageUri, imageUri: params.imageUri,
@@ -252,10 +252,10 @@ export const backendApiClient = {
simulatePurchase: async (params: { simulatePurchase: async (params: {
idempotencyKey: string; idempotencyKey: string;
productId: PurchaseProductId; productId: PurchaseProductId;
}): Promise<SimulatePurchaseResponse> => { }): Promise<SimulatePurchaseResponse> => {
const token = await getAuthToken(); const token = await getAuthToken();
if (!getConfiguredBackendRootUrl()) { if (!getConfiguredBackendRootUrl()) {
return mockBackendService.simulatePurchase({ return mockBackendService.simulatePurchase({
userId: token, userId: token,
idempotencyKey: params.idempotencyKey, idempotencyKey: params.idempotencyKey,
productId: params.productId, productId: params.productId,
@@ -276,10 +276,10 @@ export const backendApiClient = {
idempotencyKey: string; idempotencyKey: string;
event: SimulatedWebhookEvent; event: SimulatedWebhookEvent;
payload?: { credits?: number }; payload?: { credits?: number };
}): Promise<SimulateWebhookResponse> => { }): Promise<SimulateWebhookResponse> => {
const token = await getAuthToken(); const token = await getAuthToken();
if (!getConfiguredBackendRootUrl()) { if (!getConfiguredBackendRootUrl()) {
return mockBackendService.simulateWebhook({ return mockBackendService.simulateWebhook({
userId: token, userId: token,
idempotencyKey: params.idempotencyKey, idempotencyKey: params.idempotencyKey,
event: params.event, event: params.event,
@@ -304,8 +304,9 @@ export const isInsufficientCreditsError = (error: unknown): boolean => {
}; };
export const isNetworkError = (error: unknown): boolean => { export const isNetworkError = (error: unknown): boolean => {
return ( return error instanceof BackendApiError && error.code === 'NETWORK_ERROR';
error instanceof BackendApiError && };
(error.code === 'NETWORK_ERROR' || error.code === 'TIMEOUT')
); export const isTimeoutError = (error: unknown): boolean => {
return error instanceof BackendApiError && error.code === 'TIMEOUT';
}; };

41
split_image.py Normal file
View File

@@ -0,0 +1,41 @@
import sys
from PIL import Image
def split_image(image_path, output_prefix):
try:
img = Image.open(image_path)
width, height = img.size
target_height = width // 3
if target_height > height:
target_height = height
target_width = height * 3
left = (width - target_width) // 2
top = 0
right = left + target_width
bottom = height
else:
target_width = width
left = 0
top = (height - target_height) // 2
right = width
bottom = top + target_height
img_cropped = img.crop((left, top, right, bottom))
sq_size = target_width // 3
for i in range(3):
box = (i * sq_size, 0, (i + 1) * sq_size, sq_size)
part = img_cropped.crop(box)
part.save(f"{output_prefix}_{i+1}.png")
print("Success")
except Exception as e:
print(f"Error: {str(e)}")
if __name__ == "__main__":
if len(sys.argv) != 3:
print("Usage: split_image.py <input_img> <output_prefix>")
else:
split_image(sys.argv[1], sys.argv[2])

View File

@@ -1,5 +1,3 @@
const DEFAULT_API_BASE_URL = 'http://localhost:3000/api';
const normalizeHttpUrl = (value?: string | null): string | null => { const normalizeHttpUrl = (value?: string | null): string | null => {
const trimmed = String(value || '').trim(); const trimmed = String(value || '').trim();
if (!trimmed) return null; if (!trimmed) return null;
@@ -12,32 +10,32 @@ const normalizeHttpUrl = (value?: string | null): string | null => {
} }
}; };
export const getConfiguredApiBaseUrl = (): string => { export const getConfiguredApiBaseUrl = (): string => {
const explicitApiUrl = normalizeHttpUrl(process.env.EXPO_PUBLIC_API_URL); const explicitApiUrl = normalizeHttpUrl(process.env.EXPO_PUBLIC_API_URL);
if (explicitApiUrl) return explicitApiUrl; if (explicitApiUrl) return explicitApiUrl;
const backendBaseUrl = normalizeHttpUrl(
process.env.EXPO_PUBLIC_BACKEND_URL || process.env.EXPO_PUBLIC_PAYMENT_SERVER_URL,
);
if (backendBaseUrl) {
return backendBaseUrl.endsWith('/api') ? backendBaseUrl : `${backendBaseUrl}/api`;
}
return DEFAULT_API_BASE_URL; const backendBaseUrl = normalizeHttpUrl(
}; process.env.EXPO_PUBLIC_BACKEND_URL || process.env.EXPO_PUBLIC_PAYMENT_SERVER_URL,
);
export const getConfiguredBackendRootUrl = (): string => { if (backendBaseUrl) {
const explicitApiUrl = normalizeHttpUrl(process.env.EXPO_PUBLIC_API_URL); return backendBaseUrl.endsWith('/api') ? backendBaseUrl : `${backendBaseUrl}/api`;
if (explicitApiUrl) { }
return explicitApiUrl.endsWith('/api')
? explicitApiUrl.slice(0, -4).replace(/\/+$/, '') return '';
: explicitApiUrl; };
}
export const getConfiguredBackendRootUrl = (): string => {
return normalizeHttpUrl( const explicitApiUrl = normalizeHttpUrl(process.env.EXPO_PUBLIC_API_URL);
process.env.EXPO_PUBLIC_BACKEND_URL || process.env.EXPO_PUBLIC_PAYMENT_SERVER_URL, if (explicitApiUrl) {
) || ''; return explicitApiUrl.endsWith('/api')
}; ? explicitApiUrl.slice(0, -4).replace(/\/+$/, '')
: explicitApiUrl;
}
return normalizeHttpUrl(
process.env.EXPO_PUBLIC_BACKEND_URL || process.env.EXPO_PUBLIC_PAYMENT_SERVER_URL,
) || '';
};
export const getConfiguredAssetBaseUrl = (): string => { export const getConfiguredAssetBaseUrl = (): string => {
const apiBaseUrl = getConfiguredApiBaseUrl(); const apiBaseUrl = getConfiguredApiBaseUrl();
@@ -50,6 +48,6 @@ export const getConfiguredAssetBaseUrl = (): string => {
: pathname; : pathname;
return `${parsed.origin}${assetPath}`.replace(/\/+$/, ''); return `${parsed.origin}${assetPath}`.replace(/\/+$/, '');
} catch { } catch {
return 'http://localhost:3000'; return '';
} }
}; };