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 { IdentificationResult } from '../types';
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 { createIdempotencyKey } from '../utils/idempotency';
@@ -33,6 +33,8 @@ const getBillingCopy = (language: 'de' | 'en' | 'es') => {
genericErrorMessage: 'Analyse fehlgeschlagen.',
noConnectionTitle: 'Keine Verbindung',
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',
providerErrorMessage: 'KI-Scan 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.',
noConnectionTitle: 'Sin conexión',
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',
providerErrorMessage: 'Escaneo 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.',
noConnectionTitle: 'No connection',
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',
providerErrorMessage: 'AI scan 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)) {
Alert.alert(
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 {
BackendApiError,
BackendErrorCode,
BillingSummary,
HealthCheckResponse,
PurchaseProductId,
RevenueCatCustomerInfo,
RevenueCatSyncSource,
ScanPlantResponse,
SemanticSearchResponse,
ServiceHealthResponse,
SimulatedWebhookEvent,
SimulatePurchaseResponse,
SimulateWebhookResponse,
SyncRevenueCatStateResponse,
} from './contracts';
import { getAuthToken } from './userIdentityService';
import { mockBackendService } from './mockBackendService';
import { CareInfo, Language } from '../../types';
import { getConfiguredBackendRootUrl } from '../../utils/backendUrl';
const REQUEST_TIMEOUT_MS = 15000;
import {
BackendApiError,
BackendErrorCode,
BillingSummary,
HealthCheckResponse,
PurchaseProductId,
RevenueCatCustomerInfo,
RevenueCatSyncSource,
ScanPlantResponse,
SemanticSearchResponse,
ServiceHealthResponse,
SimulatedWebhookEvent,
SimulatePurchaseResponse,
SimulateWebhookResponse,
SyncRevenueCatStateResponse,
} from './contracts';
import { getAuthToken } from './userIdentityService';
import { mockBackendService } from './mockBackendService';
import { CareInfo, Language } from '../../types';
import { getConfiguredBackendRootUrl } from '../../utils/backendUrl';
const REQUEST_TIMEOUT_MS = 60000;
const mapHttpStatusToErrorCode = (status: number): BackendErrorCode => {
if (status === 400) return 'BAD_REQUEST';
@@ -29,12 +29,12 @@ const mapHttpStatusToErrorCode = (status: number): BackendErrorCode => {
return 'PROVIDER_ERROR';
};
const buildBackendUrl = (path: string): string => {
const backendBaseUrl = getConfiguredBackendRootUrl();
if (!backendBaseUrl) return path;
const base = backendBaseUrl.replace(/\/$/, '');
return `${base}${path}`;
};
const buildBackendUrl = (path: string): string => {
const backendBaseUrl = getConfiguredBackendRootUrl();
if (!backendBaseUrl) return path;
const base = backendBaseUrl.replace(/\/$/, '');
return `${base}${path}`;
};
const parseMaybeJson = (value: string): Record<string, unknown> | null => {
if (!value) return null;
@@ -107,18 +107,18 @@ const makeRequest = async <T,>(
};
export const backendApiClient = {
getServiceHealth: async (): Promise<ServiceHealthResponse> => {
if (!getConfiguredBackendRootUrl()) {
return {
ok: true,
uptimeSec: 0,
timestamp: new Date().toISOString(),
openAiConfigured: Boolean(process.env.EXPO_PUBLIC_OPENAI_API_KEY),
dbReady: true,
dbPath: 'in-app-mock-backend',
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(),
};
getServiceHealth: async (): Promise<ServiceHealthResponse> => {
if (!getConfiguredBackendRootUrl()) {
return {
ok: true,
uptimeSec: 0,
timestamp: new Date().toISOString(),
openAiConfigured: Boolean(process.env.EXPO_PUBLIC_OPENAI_API_KEY),
dbReady: true,
dbPath: 'in-app-mock-backend',
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(),
};
}
const token = await getAuthToken();
@@ -128,49 +128,49 @@ export const backendApiClient = {
});
},
getBillingSummary: async (): Promise<BillingSummary> => {
const token = await getAuthToken();
if (!getConfiguredBackendRootUrl()) {
return mockBackendService.getBillingSummary(token);
}
getBillingSummary: async (): Promise<BillingSummary> => {
const token = await getAuthToken();
if (!getConfiguredBackendRootUrl()) {
return mockBackendService.getBillingSummary(token);
}
return makeRequest<BillingSummary>('/v1/billing/summary', {
method: 'GET',
token,
});
},
syncRevenueCatState: async (params: {
customerInfo: RevenueCatCustomerInfo;
source?: RevenueCatSyncSource;
}): Promise<SyncRevenueCatStateResponse> => {
const token = await getAuthToken();
if (!getConfiguredBackendRootUrl()) {
return mockBackendService.syncRevenueCatState({
userId: token,
customerInfo: params.customerInfo,
source: params.source,
});
}
return makeRequest<SyncRevenueCatStateResponse>('/v1/billing/sync-revenuecat', {
method: 'POST',
token,
body: {
customerInfo: params.customerInfo,
source: params.source,
},
});
},
scanPlant: async (params: {
return makeRequest<BillingSummary>('/v1/billing/summary', {
method: 'GET',
token,
});
},
syncRevenueCatState: async (params: {
customerInfo: RevenueCatCustomerInfo;
source?: RevenueCatSyncSource;
}): Promise<SyncRevenueCatStateResponse> => {
const token = await getAuthToken();
if (!getConfiguredBackendRootUrl()) {
return mockBackendService.syncRevenueCatState({
userId: token,
customerInfo: params.customerInfo,
source: params.source,
});
}
return makeRequest<SyncRevenueCatStateResponse>('/v1/billing/sync-revenuecat', {
method: 'POST',
token,
body: {
customerInfo: params.customerInfo,
source: params.source,
},
});
},
scanPlant: async (params: {
idempotencyKey: string;
imageUri: string;
language: Language;
}): Promise<ScanPlantResponse> => {
const token = await getAuthToken();
if (!getConfiguredBackendRootUrl()) {
return mockBackendService.scanPlant({
}): Promise<ScanPlantResponse> => {
const token = await getAuthToken();
if (!getConfiguredBackendRootUrl()) {
return mockBackendService.scanPlant({
userId: token,
idempotencyKey: params.idempotencyKey,
imageUri: params.imageUri,
@@ -193,10 +193,10 @@ export const backendApiClient = {
idempotencyKey: string;
query: string;
language: Language;
}): Promise<SemanticSearchResponse> => {
const token = await getAuthToken();
if (!getConfiguredBackendRootUrl()) {
return mockBackendService.semanticSearch({
}): Promise<SemanticSearchResponse> => {
const token = await getAuthToken();
if (!getConfiguredBackendRootUrl()) {
return mockBackendService.semanticSearch({
userId: token,
idempotencyKey: params.idempotencyKey,
query: params.query,
@@ -225,10 +225,10 @@ export const backendApiClient = {
careInfo: CareInfo;
description?: string;
};
}): Promise<HealthCheckResponse> => {
const token = await getAuthToken();
if (!getConfiguredBackendRootUrl()) {
return mockBackendService.healthCheck({
}): Promise<HealthCheckResponse> => {
const token = await getAuthToken();
if (!getConfiguredBackendRootUrl()) {
return mockBackendService.healthCheck({
userId: token,
idempotencyKey: params.idempotencyKey,
imageUri: params.imageUri,
@@ -252,10 +252,10 @@ export const backendApiClient = {
simulatePurchase: async (params: {
idempotencyKey: string;
productId: PurchaseProductId;
}): Promise<SimulatePurchaseResponse> => {
const token = await getAuthToken();
if (!getConfiguredBackendRootUrl()) {
return mockBackendService.simulatePurchase({
}): Promise<SimulatePurchaseResponse> => {
const token = await getAuthToken();
if (!getConfiguredBackendRootUrl()) {
return mockBackendService.simulatePurchase({
userId: token,
idempotencyKey: params.idempotencyKey,
productId: params.productId,
@@ -276,10 +276,10 @@ export const backendApiClient = {
idempotencyKey: string;
event: SimulatedWebhookEvent;
payload?: { credits?: number };
}): Promise<SimulateWebhookResponse> => {
const token = await getAuthToken();
if (!getConfiguredBackendRootUrl()) {
return mockBackendService.simulateWebhook({
}): Promise<SimulateWebhookResponse> => {
const token = await getAuthToken();
if (!getConfiguredBackendRootUrl()) {
return mockBackendService.simulateWebhook({
userId: token,
idempotencyKey: params.idempotencyKey,
event: params.event,
@@ -304,8 +304,9 @@ export const isInsufficientCreditsError = (error: unknown): boolean => {
};
export const isNetworkError = (error: unknown): boolean => {
return (
error instanceof BackendApiError &&
(error.code === 'NETWORK_ERROR' || error.code === 'TIMEOUT')
);
return error instanceof BackendApiError && error.code === 'NETWORK_ERROR';
};
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 trimmed = String(value || '').trim();
if (!trimmed) return null;
@@ -12,32 +10,32 @@ const normalizeHttpUrl = (value?: string | null): string | null => {
}
};
export const getConfiguredApiBaseUrl = (): string => {
const explicitApiUrl = normalizeHttpUrl(process.env.EXPO_PUBLIC_API_URL);
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`;
}
export const getConfiguredApiBaseUrl = (): string => {
const explicitApiUrl = normalizeHttpUrl(process.env.EXPO_PUBLIC_API_URL);
if (explicitApiUrl) return explicitApiUrl;
return DEFAULT_API_BASE_URL;
};
export const getConfiguredBackendRootUrl = (): string => {
const explicitApiUrl = normalizeHttpUrl(process.env.EXPO_PUBLIC_API_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,
) || '';
};
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 '';
};
export const getConfiguredBackendRootUrl = (): string => {
const explicitApiUrl = normalizeHttpUrl(process.env.EXPO_PUBLIC_API_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 => {
const apiBaseUrl = getConfiguredApiBaseUrl();
@@ -50,6 +48,6 @@ export const getConfiguredAssetBaseUrl = (): string => {
: pathname;
return `${parsed.origin}${assetPath}`.replace(/\/+$/, '');
} catch {
return 'http://localhost:3000';
return '';
}
};