Initial commit for Greenlens
This commit is contained in:
@@ -118,4 +118,20 @@ export const AuthService = {
|
||||
return 'unreachable';
|
||||
}
|
||||
},
|
||||
|
||||
async checkIfFirstRun(): Promise<boolean> {
|
||||
const flag = await SecureStore.getItemAsync('greenlens_first_run_complete');
|
||||
return flag !== 'true';
|
||||
},
|
||||
|
||||
async markFirstRunComplete(): Promise<void> {
|
||||
await SecureStore.setItemAsync('greenlens_first_run_complete', 'true');
|
||||
},
|
||||
|
||||
async clearAllData(): Promise<void> {
|
||||
await clearStoredSession();
|
||||
await SecureStore.deleteItemAsync('greenlens_first_run_complete');
|
||||
// Note: SQLite tables aren't cleared here to avoid destroying user data
|
||||
// without explicit consent, but session tokens are wiped.
|
||||
},
|
||||
};
|
||||
|
||||
277
services/backend/backendApiClient.ts
Normal file
277
services/backend/backendApiClient.ts
Normal file
@@ -0,0 +1,277 @@
|
||||
import {
|
||||
BackendApiError,
|
||||
BackendErrorCode,
|
||||
BillingSummary,
|
||||
HealthCheckResponse,
|
||||
PurchaseProductId,
|
||||
ScanPlantResponse,
|
||||
SemanticSearchResponse,
|
||||
ServiceHealthResponse,
|
||||
SimulatedWebhookEvent,
|
||||
SimulatePurchaseResponse,
|
||||
SimulateWebhookResponse,
|
||||
} from './contracts';
|
||||
import { getAuthToken } from './userIdentityService';
|
||||
import { mockBackendService } from './mockBackendService';
|
||||
import { CareInfo, Language } from '../../types';
|
||||
|
||||
const BACKEND_BASE_URL = (process.env.EXPO_PUBLIC_BACKEND_URL || process.env.EXPO_PUBLIC_PAYMENT_SERVER_URL || '').trim();
|
||||
const REQUEST_TIMEOUT_MS = 15000;
|
||||
|
||||
const mapHttpStatusToErrorCode = (status: number): BackendErrorCode => {
|
||||
if (status === 400) return 'BAD_REQUEST';
|
||||
if (status === 401 || status === 403) return 'UNAUTHORIZED';
|
||||
if (status === 402) return 'INSUFFICIENT_CREDITS';
|
||||
if (status === 408 || status === 504) return 'TIMEOUT';
|
||||
return 'PROVIDER_ERROR';
|
||||
};
|
||||
|
||||
const buildBackendUrl = (path: string): string => {
|
||||
const base = BACKEND_BASE_URL.replace(/\/$/, '');
|
||||
return `${base}${path}`;
|
||||
};
|
||||
|
||||
const parseMaybeJson = (value: string): Record<string, unknown> | null => {
|
||||
if (!value) return null;
|
||||
try {
|
||||
return JSON.parse(value) as Record<string, unknown>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const makeRequest = async <T,>(
|
||||
path: string,
|
||||
options: {
|
||||
method: 'GET' | 'POST';
|
||||
token: string;
|
||||
idempotencyKey?: string;
|
||||
body?: Record<string, unknown>;
|
||||
},
|
||||
): Promise<T> => {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${options.token}`,
|
||||
};
|
||||
if (options.idempotencyKey) {
|
||||
headers['Idempotency-Key'] = options.idempotencyKey;
|
||||
}
|
||||
|
||||
const response = await fetch(buildBackendUrl(path), {
|
||||
method: options.method,
|
||||
headers,
|
||||
body: options.body ? JSON.stringify(options.body) : undefined,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
const rawText = await response.text();
|
||||
const jsonPayload = parseMaybeJson(rawText);
|
||||
|
||||
if (!response.ok) {
|
||||
const payloadCode = jsonPayload?.code;
|
||||
const code = typeof payloadCode === 'string'
|
||||
? payloadCode as BackendErrorCode
|
||||
: mapHttpStatusToErrorCode(response.status);
|
||||
const payloadMessage = jsonPayload?.message;
|
||||
const message = typeof payloadMessage === 'string'
|
||||
? payloadMessage
|
||||
: `Backend request failed with status ${response.status}.`;
|
||||
throw new BackendApiError(code, message, response.status, jsonPayload || undefined);
|
||||
}
|
||||
|
||||
if (jsonPayload == null) {
|
||||
throw new BackendApiError('PROVIDER_ERROR', 'Backend returned invalid JSON.', 502);
|
||||
}
|
||||
|
||||
return jsonPayload as T;
|
||||
} catch (error) {
|
||||
if (error instanceof BackendApiError) throw error;
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
throw new BackendApiError('TIMEOUT', 'Backend request timed out.', 408);
|
||||
}
|
||||
throw new BackendApiError('PROVIDER_ERROR', 'Backend request failed unexpectedly.', 500, {
|
||||
detail: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
};
|
||||
|
||||
export const backendApiClient = {
|
||||
getServiceHealth: async (): Promise<ServiceHealthResponse> => {
|
||||
if (!BACKEND_BASE_URL) {
|
||||
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',
|
||||
stripeConfigured: Boolean(process.env.EXPO_PUBLIC_STRIPE_PUBLISHABLE_KEY),
|
||||
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();
|
||||
return makeRequest<ServiceHealthResponse>('/health', {
|
||||
method: 'GET',
|
||||
token,
|
||||
});
|
||||
},
|
||||
|
||||
getBillingSummary: async (): Promise<BillingSummary> => {
|
||||
const token = await getAuthToken();
|
||||
if (!BACKEND_BASE_URL) {
|
||||
return mockBackendService.getBillingSummary(token);
|
||||
}
|
||||
|
||||
return makeRequest<BillingSummary>('/v1/billing/summary', {
|
||||
method: 'GET',
|
||||
token,
|
||||
});
|
||||
},
|
||||
|
||||
scanPlant: async (params: {
|
||||
idempotencyKey: string;
|
||||
imageUri: string;
|
||||
language: Language;
|
||||
}): Promise<ScanPlantResponse> => {
|
||||
const token = await getAuthToken();
|
||||
if (!BACKEND_BASE_URL) {
|
||||
return mockBackendService.scanPlant({
|
||||
userId: token,
|
||||
idempotencyKey: params.idempotencyKey,
|
||||
imageUri: params.imageUri,
|
||||
language: params.language,
|
||||
});
|
||||
}
|
||||
|
||||
return makeRequest<ScanPlantResponse>('/v1/scan', {
|
||||
method: 'POST',
|
||||
token,
|
||||
idempotencyKey: params.idempotencyKey,
|
||||
body: {
|
||||
imageUri: params.imageUri,
|
||||
language: params.language,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
semanticSearch: async (params: {
|
||||
idempotencyKey: string;
|
||||
query: string;
|
||||
language: Language;
|
||||
}): Promise<SemanticSearchResponse> => {
|
||||
const token = await getAuthToken();
|
||||
if (!BACKEND_BASE_URL) {
|
||||
return mockBackendService.semanticSearch({
|
||||
userId: token,
|
||||
idempotencyKey: params.idempotencyKey,
|
||||
query: params.query,
|
||||
language: params.language,
|
||||
});
|
||||
}
|
||||
|
||||
return makeRequest<SemanticSearchResponse>('/v1/search/semantic', {
|
||||
method: 'POST',
|
||||
token,
|
||||
idempotencyKey: params.idempotencyKey,
|
||||
body: {
|
||||
query: params.query,
|
||||
language: params.language,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
runHealthCheck: async (params: {
|
||||
idempotencyKey: string;
|
||||
imageUri: string;
|
||||
language: Language;
|
||||
plantContext?: {
|
||||
name: string;
|
||||
botanicalName: string;
|
||||
careInfo: CareInfo;
|
||||
description?: string;
|
||||
};
|
||||
}): Promise<HealthCheckResponse> => {
|
||||
const token = await getAuthToken();
|
||||
if (!BACKEND_BASE_URL) {
|
||||
return mockBackendService.healthCheck({
|
||||
userId: token,
|
||||
idempotencyKey: params.idempotencyKey,
|
||||
imageUri: params.imageUri,
|
||||
language: params.language,
|
||||
plantContext: params.plantContext,
|
||||
});
|
||||
}
|
||||
|
||||
return makeRequest<HealthCheckResponse>('/v1/health-check', {
|
||||
method: 'POST',
|
||||
token,
|
||||
idempotencyKey: params.idempotencyKey,
|
||||
body: {
|
||||
imageUri: params.imageUri,
|
||||
language: params.language,
|
||||
plantContext: params.plantContext,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
simulatePurchase: async (params: {
|
||||
idempotencyKey: string;
|
||||
productId: PurchaseProductId;
|
||||
}): Promise<SimulatePurchaseResponse> => {
|
||||
const token = await getAuthToken();
|
||||
if (!BACKEND_BASE_URL) {
|
||||
return mockBackendService.simulatePurchase({
|
||||
userId: token,
|
||||
idempotencyKey: params.idempotencyKey,
|
||||
productId: params.productId,
|
||||
});
|
||||
}
|
||||
|
||||
return makeRequest<SimulatePurchaseResponse>('/v1/billing/simulate-purchase', {
|
||||
method: 'POST',
|
||||
token,
|
||||
idempotencyKey: params.idempotencyKey,
|
||||
body: {
|
||||
productId: params.productId,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
simulateWebhook: async (params: {
|
||||
idempotencyKey: string;
|
||||
event: SimulatedWebhookEvent;
|
||||
payload?: { credits?: number };
|
||||
}): Promise<SimulateWebhookResponse> => {
|
||||
const token = await getAuthToken();
|
||||
if (!BACKEND_BASE_URL) {
|
||||
return mockBackendService.simulateWebhook({
|
||||
userId: token,
|
||||
idempotencyKey: params.idempotencyKey,
|
||||
event: params.event,
|
||||
payload: params.payload,
|
||||
});
|
||||
}
|
||||
|
||||
return makeRequest<SimulateWebhookResponse>('/v1/billing/simulate-webhook', {
|
||||
method: 'POST',
|
||||
token,
|
||||
idempotencyKey: params.idempotencyKey,
|
||||
body: {
|
||||
event: params.event,
|
||||
payload: params.payload || {},
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const isInsufficientCreditsError = (error: unknown): boolean => {
|
||||
return error instanceof BackendApiError && error.code === 'INSUFFICIENT_CREDITS';
|
||||
};
|
||||
156
services/backend/contracts.ts
Normal file
156
services/backend/contracts.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
import { CareInfo, IdentificationResult, Language, PlantHealthCheck } from '../../types';
|
||||
|
||||
export type PlanId = 'free' | 'pro';
|
||||
export type BillingProvider = 'mock' | 'revenuecat' | 'stripe';
|
||||
export type PurchaseProductId = 'monthly_pro' | 'yearly_pro' | 'topup_small' | 'topup_medium' | 'topup_large';
|
||||
export type SimulatedWebhookEvent =
|
||||
| 'entitlement_granted'
|
||||
| 'entitlement_revoked'
|
||||
| 'topup_granted'
|
||||
| 'credits_depleted';
|
||||
|
||||
export interface BackendDatabaseEntry extends IdentificationResult {
|
||||
imageUri: string;
|
||||
imageStatus?: 'ok' | 'missing' | 'invalid';
|
||||
categories: string[];
|
||||
}
|
||||
|
||||
export interface CreditState {
|
||||
monthlyAllowance: number;
|
||||
usedThisCycle: number;
|
||||
topupBalance: number;
|
||||
available: number;
|
||||
cycleStartedAt: string;
|
||||
cycleEndsAt: string;
|
||||
}
|
||||
|
||||
export interface EntitlementState {
|
||||
plan: PlanId;
|
||||
provider: BillingProvider;
|
||||
status: 'active' | 'inactive';
|
||||
renewsAt: string | null;
|
||||
}
|
||||
|
||||
export interface BillingSummary {
|
||||
entitlement: EntitlementState;
|
||||
credits: CreditState;
|
||||
availableProducts: PurchaseProductId[];
|
||||
}
|
||||
|
||||
export interface ScanPlantRequest {
|
||||
userId: string;
|
||||
idempotencyKey: string;
|
||||
imageUri: string;
|
||||
language: Language;
|
||||
}
|
||||
|
||||
export interface ScanPlantResponse {
|
||||
result: IdentificationResult;
|
||||
creditsCharged: number;
|
||||
modelPath: string[];
|
||||
modelUsed?: string | null;
|
||||
modelFallbackCount?: number;
|
||||
billing: BillingSummary;
|
||||
}
|
||||
|
||||
export interface SemanticSearchRequest {
|
||||
userId: string;
|
||||
idempotencyKey: string;
|
||||
query: string;
|
||||
language: Language;
|
||||
}
|
||||
|
||||
export interface SemanticSearchResponse {
|
||||
status: 'success' | 'no_results';
|
||||
results: BackendDatabaseEntry[];
|
||||
creditsCharged: number;
|
||||
billing: BillingSummary;
|
||||
}
|
||||
|
||||
export interface HealthCheckRequest {
|
||||
userId: string;
|
||||
idempotencyKey: string;
|
||||
imageUri: string;
|
||||
language: Language;
|
||||
plantContext?: {
|
||||
name: string;
|
||||
botanicalName: string;
|
||||
careInfo: CareInfo;
|
||||
description?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface HealthCheckResponse {
|
||||
healthCheck: PlantHealthCheck;
|
||||
creditsCharged: number;
|
||||
modelUsed?: string | null;
|
||||
modelFallbackCount?: number;
|
||||
billing: BillingSummary;
|
||||
}
|
||||
|
||||
export interface ServiceHealthResponse {
|
||||
ok: boolean;
|
||||
uptimeSec: number;
|
||||
timestamp: string;
|
||||
openAiConfigured: boolean;
|
||||
dbReady?: boolean;
|
||||
dbPath?: string;
|
||||
stripeConfigured?: boolean;
|
||||
scanModel?: string;
|
||||
healthModel?: string;
|
||||
}
|
||||
|
||||
export interface SimulatePurchaseRequest {
|
||||
userId: string;
|
||||
idempotencyKey: string;
|
||||
productId: PurchaseProductId;
|
||||
}
|
||||
|
||||
export interface SimulatePurchaseResponse {
|
||||
appliedProduct: PurchaseProductId;
|
||||
billing: BillingSummary;
|
||||
}
|
||||
|
||||
export interface SimulateWebhookRequest {
|
||||
userId: string;
|
||||
idempotencyKey: string;
|
||||
event: SimulatedWebhookEvent;
|
||||
payload?: {
|
||||
credits?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SimulateWebhookResponse {
|
||||
event: SimulatedWebhookEvent;
|
||||
billing: BillingSummary;
|
||||
}
|
||||
|
||||
export type BackendErrorCode =
|
||||
| 'INSUFFICIENT_CREDITS'
|
||||
| 'UNAUTHORIZED'
|
||||
| 'TIMEOUT'
|
||||
| 'PROVIDER_ERROR'
|
||||
| 'BAD_REQUEST';
|
||||
|
||||
export class BackendApiError extends Error {
|
||||
public readonly code: BackendErrorCode;
|
||||
public readonly status: number;
|
||||
public readonly metadata?: Record<string, unknown>;
|
||||
|
||||
constructor(
|
||||
code: BackendErrorCode,
|
||||
message: string,
|
||||
status = 500,
|
||||
metadata?: Record<string, unknown>,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'BackendApiError';
|
||||
this.code = code;
|
||||
this.status = status;
|
||||
this.metadata = metadata;
|
||||
}
|
||||
}
|
||||
|
||||
export const isBackendApiError = (error: unknown): error is BackendApiError => {
|
||||
return error instanceof BackendApiError;
|
||||
};
|
||||
975
services/backend/mockBackendService.ts
Normal file
975
services/backend/mockBackendService.ts
Normal file
@@ -0,0 +1,975 @@
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import {
|
||||
BackendApiError,
|
||||
BillingProvider,
|
||||
BillingSummary,
|
||||
HealthCheckRequest,
|
||||
HealthCheckResponse,
|
||||
PlanId,
|
||||
PurchaseProductId,
|
||||
ScanPlantRequest,
|
||||
ScanPlantResponse,
|
||||
SemanticSearchRequest,
|
||||
SemanticSearchResponse,
|
||||
SimulatePurchaseRequest,
|
||||
SimulatePurchaseResponse,
|
||||
SimulateWebhookRequest,
|
||||
SimulateWebhookResponse,
|
||||
isBackendApiError,
|
||||
} from './contracts';
|
||||
import { getMockPlantByImage, searchMockCatalog } from './mockCatalog';
|
||||
import { openAiScanService } from './openAiScanService';
|
||||
import { IdentificationResult, PlantHealthCheck } from '../../types';
|
||||
|
||||
const MOCK_ACCOUNT_STORE_KEY = 'greenlens_mock_backend_accounts_v1';
|
||||
const MOCK_IDEMPOTENCY_STORE_KEY = 'greenlens_mock_backend_idempotency_v1';
|
||||
|
||||
const FREE_MONTHLY_CREDITS = 15;
|
||||
const GUEST_TRIAL_CREDITS = 5;
|
||||
const PRO_MONTHLY_CREDITS = 250;
|
||||
|
||||
const SCAN_PRIMARY_COST = 1;
|
||||
const SCAN_REVIEW_COST = 1;
|
||||
const SEMANTIC_SEARCH_COST = 2;
|
||||
const HEALTH_CHECK_COST = 2;
|
||||
|
||||
const LOW_CONFIDENCE_REVIEW_THRESHOLD = 0.8;
|
||||
const FREE_SIMULATED_DELAY_MS = 1100;
|
||||
const PRO_SIMULATED_DELAY_MS = 280;
|
||||
|
||||
const TOPUP_DEFAULT_CREDITS = 60;
|
||||
|
||||
const TOPUP_CREDITS_BY_PRODUCT: Record<PurchaseProductId, number> = {
|
||||
monthly_pro: 0,
|
||||
yearly_pro: 0,
|
||||
topup_small: 25,
|
||||
topup_medium: 75,
|
||||
topup_large: 200,
|
||||
};
|
||||
|
||||
interface MockAccountRecord {
|
||||
userId: string;
|
||||
plan: PlanId;
|
||||
provider: BillingProvider;
|
||||
cycleStartedAt: string;
|
||||
cycleEndsAt: string;
|
||||
monthlyAllowance: number;
|
||||
usedThisCycle: number;
|
||||
topupBalance: number;
|
||||
renewsAt: string | null;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface IdempotencyRecord {
|
||||
response: unknown;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
type AccountStore = Record<string, MockAccountRecord>;
|
||||
type IdempotencyStore = Record<string, IdempotencyRecord>;
|
||||
|
||||
const userLocks = new Map<string, Promise<void>>();
|
||||
|
||||
const nowIso = (): string => new Date().toISOString();
|
||||
|
||||
const startOfUtcMonth = (date: Date): Date => {
|
||||
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), 1, 0, 0, 0, 0));
|
||||
};
|
||||
|
||||
const addUtcMonths = (date: Date, months: number): Date => {
|
||||
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + months, 1, 0, 0, 0, 0));
|
||||
};
|
||||
|
||||
const addDays = (date: Date, days: number): Date => {
|
||||
const result = new Date(date.getTime());
|
||||
result.setUTCDate(result.getUTCDate() + days);
|
||||
return result;
|
||||
};
|
||||
|
||||
const getCycleBounds = (now: Date) => {
|
||||
const cycleStartedAt = startOfUtcMonth(now);
|
||||
const cycleEndsAt = addUtcMonths(cycleStartedAt, 1);
|
||||
return { cycleStartedAt, cycleEndsAt };
|
||||
};
|
||||
|
||||
const getMonthlyAllowanceForPlan = (plan: PlanId, userId?: string): number => {
|
||||
if (userId === 'guest') return GUEST_TRIAL_CREDITS;
|
||||
return plan === 'pro' ? PRO_MONTHLY_CREDITS : FREE_MONTHLY_CREDITS;
|
||||
};
|
||||
|
||||
const getSimulatedDelay = (plan: PlanId): number => {
|
||||
return plan === 'pro' ? PRO_SIMULATED_DELAY_MS : FREE_SIMULATED_DELAY_MS;
|
||||
};
|
||||
|
||||
const sleep = async (ms: number): Promise<void> => {
|
||||
if (ms <= 0) return;
|
||||
await new Promise(resolve => setTimeout(resolve, ms));
|
||||
};
|
||||
|
||||
const withUserLock = async <T>(userId: string, worker: () => Promise<T>): Promise<T> => {
|
||||
const previousLock = userLocks.get(userId) || Promise.resolve();
|
||||
let releaseLock: () => void = () => {};
|
||||
const activeLock = new Promise<void>((resolve) => {
|
||||
releaseLock = resolve;
|
||||
});
|
||||
userLocks.set(userId, activeLock);
|
||||
|
||||
await previousLock;
|
||||
try {
|
||||
return await worker();
|
||||
} finally {
|
||||
releaseLock();
|
||||
if (userLocks.get(userId) === activeLock) {
|
||||
userLocks.delete(userId);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const readJson = async <T,>(key: string, fallbackValue: T): Promise<T> => {
|
||||
try {
|
||||
const raw = await AsyncStorage.getItem(key);
|
||||
if (!raw) return fallbackValue;
|
||||
return JSON.parse(raw) as T;
|
||||
} catch (error) {
|
||||
console.error(`Failed to read mock backend key ${key}`, error);
|
||||
return fallbackValue;
|
||||
}
|
||||
};
|
||||
|
||||
const writeJson = async <T,>(key: string, value: T): Promise<void> => {
|
||||
try {
|
||||
await AsyncStorage.setItem(key, JSON.stringify(value));
|
||||
} catch (error) {
|
||||
console.error(`Failed to write mock backend key ${key}`, error);
|
||||
}
|
||||
};
|
||||
|
||||
const loadStores = async (): Promise<{ accounts: AccountStore; idempotency: IdempotencyStore }> => {
|
||||
const [accounts, idempotency] = await Promise.all([
|
||||
readJson<AccountStore>(MOCK_ACCOUNT_STORE_KEY, {}),
|
||||
readJson<IdempotencyStore>(MOCK_IDEMPOTENCY_STORE_KEY, {}),
|
||||
]);
|
||||
return { accounts, idempotency };
|
||||
};
|
||||
|
||||
const persistStores = async (stores: { accounts: AccountStore; idempotency: IdempotencyStore }): Promise<void> => {
|
||||
await Promise.all([
|
||||
writeJson(MOCK_ACCOUNT_STORE_KEY, stores.accounts),
|
||||
writeJson(MOCK_IDEMPOTENCY_STORE_KEY, stores.idempotency),
|
||||
]);
|
||||
};
|
||||
|
||||
const buildDefaultAccount = (userId: string, now: Date): MockAccountRecord => {
|
||||
const { cycleStartedAt, cycleEndsAt } = getCycleBounds(now);
|
||||
const allowance = getMonthlyAllowanceForPlan('free', userId);
|
||||
return {
|
||||
userId,
|
||||
plan: 'free',
|
||||
provider: 'mock',
|
||||
cycleStartedAt: cycleStartedAt.toISOString(),
|
||||
cycleEndsAt: cycleEndsAt.toISOString(),
|
||||
monthlyAllowance: allowance,
|
||||
usedThisCycle: 0,
|
||||
topupBalance: 0,
|
||||
renewsAt: null,
|
||||
updatedAt: nowIso(),
|
||||
};
|
||||
};
|
||||
|
||||
const alignAccountToCurrentCycle = (account: MockAccountRecord, now: Date): MockAccountRecord => {
|
||||
const next = { ...account };
|
||||
const expectedMonthlyAllowance = getMonthlyAllowanceForPlan(next.plan, next.userId);
|
||||
if (next.monthlyAllowance !== expectedMonthlyAllowance) {
|
||||
next.monthlyAllowance = expectedMonthlyAllowance;
|
||||
}
|
||||
|
||||
if (!next.renewsAt && next.plan === 'pro' && next.provider === 'mock') {
|
||||
next.renewsAt = addDays(now, 30).toISOString();
|
||||
}
|
||||
|
||||
const cycleEndsAtMs = new Date(next.cycleEndsAt).getTime();
|
||||
if (Number.isNaN(cycleEndsAtMs) || now.getTime() >= cycleEndsAtMs) {
|
||||
const { cycleStartedAt, cycleEndsAt } = getCycleBounds(now);
|
||||
next.cycleStartedAt = cycleStartedAt.toISOString();
|
||||
next.cycleEndsAt = cycleEndsAt.toISOString();
|
||||
next.usedThisCycle = 0;
|
||||
next.monthlyAllowance = expectedMonthlyAllowance;
|
||||
}
|
||||
|
||||
return next;
|
||||
};
|
||||
|
||||
const getOrCreateAccount = (stores: { accounts: AccountStore }, userId: string): MockAccountRecord => {
|
||||
const now = new Date();
|
||||
const existing = stores.accounts[userId] || buildDefaultAccount(userId, now);
|
||||
const aligned = alignAccountToCurrentCycle(existing, now);
|
||||
stores.accounts[userId] = aligned;
|
||||
return aligned;
|
||||
};
|
||||
|
||||
const getAvailableCredits = (account: MockAccountRecord): number => {
|
||||
const monthlyRemaining = Math.max(0, account.monthlyAllowance - account.usedThisCycle);
|
||||
return monthlyRemaining + Math.max(0, account.topupBalance);
|
||||
};
|
||||
|
||||
const buildBillingSummary = (account: MockAccountRecord): BillingSummary => {
|
||||
return {
|
||||
entitlement: {
|
||||
plan: account.plan,
|
||||
provider: account.provider,
|
||||
status: 'active',
|
||||
renewsAt: account.renewsAt,
|
||||
},
|
||||
credits: {
|
||||
monthlyAllowance: account.monthlyAllowance,
|
||||
usedThisCycle: account.usedThisCycle,
|
||||
topupBalance: account.topupBalance,
|
||||
available: getAvailableCredits(account),
|
||||
cycleStartedAt: account.cycleStartedAt,
|
||||
cycleEndsAt: account.cycleEndsAt,
|
||||
},
|
||||
availableProducts: ['monthly_pro', 'yearly_pro', 'topup_small', 'topup_medium', 'topup_large'],
|
||||
};
|
||||
};
|
||||
|
||||
const readIdempotentResponse = <T,>(store: IdempotencyStore, key: string): T | null => {
|
||||
const record = store[key];
|
||||
if (!record) return null;
|
||||
return record.response as T;
|
||||
};
|
||||
|
||||
const writeIdempotentResponse = <T,>(store: IdempotencyStore, key: string, value: T): void => {
|
||||
store[key] = {
|
||||
response: value,
|
||||
createdAt: nowIso(),
|
||||
};
|
||||
};
|
||||
|
||||
const consumeCredits = (account: MockAccountRecord, cost: number): number => {
|
||||
if (cost <= 0) return 0;
|
||||
|
||||
const available = getAvailableCredits(account);
|
||||
if (available < cost) {
|
||||
throw new BackendApiError(
|
||||
'INSUFFICIENT_CREDITS',
|
||||
`Insufficient credits. Required ${cost}, available ${available}.`,
|
||||
402,
|
||||
{ required: cost, available },
|
||||
);
|
||||
}
|
||||
|
||||
let remaining = cost;
|
||||
const monthlyRemaining = Math.max(0, account.monthlyAllowance - account.usedThisCycle);
|
||||
if (monthlyRemaining > 0) {
|
||||
const monthlyUsage = Math.min(monthlyRemaining, remaining);
|
||||
account.usedThisCycle += monthlyUsage;
|
||||
remaining -= monthlyUsage;
|
||||
}
|
||||
|
||||
if (remaining > 0 && account.topupBalance > 0) {
|
||||
const topupUsage = Math.min(account.topupBalance, remaining);
|
||||
account.topupBalance -= topupUsage;
|
||||
remaining -= topupUsage;
|
||||
}
|
||||
|
||||
return cost;
|
||||
};
|
||||
|
||||
const consumeCreditsWithIdempotency = (
|
||||
account: MockAccountRecord,
|
||||
idempotencyStore: IdempotencyStore,
|
||||
key: string,
|
||||
cost: number,
|
||||
): number => {
|
||||
const existing = readIdempotentResponse<{ charged: number }>(idempotencyStore, key);
|
||||
if (existing) return existing.charged;
|
||||
|
||||
const charged = consumeCredits(account, cost);
|
||||
writeIdempotentResponse(idempotencyStore, key, { charged });
|
||||
return charged;
|
||||
};
|
||||
|
||||
const endpointKey = (scope: string, userId: string, idempotencyKey: string): string => {
|
||||
return `endpoint:${scope}:${userId}:${idempotencyKey}`;
|
||||
};
|
||||
|
||||
const chargeKey = (scope: string, userId: string, idempotencyKey: string): string => {
|
||||
return `charge:${scope}:${userId}:${idempotencyKey}`;
|
||||
};
|
||||
|
||||
const hashString = (value: string): number => {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < value.length; i += 1) {
|
||||
hash = ((hash << 5) - hash + value.charCodeAt(i)) | 0;
|
||||
}
|
||||
return Math.abs(hash);
|
||||
};
|
||||
|
||||
const clampConfidence = (value: number): number => {
|
||||
return Math.max(0.05, Math.min(0.99, Number(value.toFixed(2))));
|
||||
};
|
||||
|
||||
const buildMockHealthCheck = (request: HealthCheckRequest, creditsCharged: number): PlantHealthCheck => {
|
||||
const source = [
|
||||
request.imageUri,
|
||||
request.plantContext?.name || '',
|
||||
request.plantContext?.botanicalName || '',
|
||||
request.plantContext?.careInfo?.light || '',
|
||||
request.plantContext?.careInfo?.temp || '',
|
||||
].join('|');
|
||||
const hash = hashString(source);
|
||||
const score = 40 + (hash % 56);
|
||||
|
||||
const status: PlantHealthCheck['status'] = score >= 75
|
||||
? 'healthy'
|
||||
: score >= 55
|
||||
? 'watch'
|
||||
: 'critical';
|
||||
|
||||
const confidenceBase = 0.45 + ((hash % 20) / 100);
|
||||
const confidenceMid = 0.35 + (((hash >> 2) % 20) / 100);
|
||||
const confidenceLow = 0.25 + (((hash >> 4) % 20) / 100);
|
||||
|
||||
if (request.language === 'de') {
|
||||
const likelyIssues = status === 'critical'
|
||||
? [
|
||||
{
|
||||
title: 'Moegliche Ueberwaesserung',
|
||||
confidence: clampConfidence(confidenceBase + 0.22),
|
||||
details: 'Gelbe, weiche Blaetter koennen auf zu nasse Erde hindeuten.',
|
||||
},
|
||||
{
|
||||
title: 'Wurzelstress',
|
||||
confidence: clampConfidence(confidenceMid + 0.15),
|
||||
details: 'Pruefe, ob das Substrat verdichtet ist oder unangenehm riecht.',
|
||||
},
|
||||
{
|
||||
title: 'Lichtmangel',
|
||||
confidence: clampConfidence(confidenceLow + 0.1),
|
||||
details: 'Zu wenig Licht kann zu Vergilbung und schwaecherem Wuchs fuehren.',
|
||||
},
|
||||
]
|
||||
: status === 'watch'
|
||||
? [
|
||||
{
|
||||
title: 'Leichter Naehrstoffmangel',
|
||||
confidence: clampConfidence(confidenceBase),
|
||||
details: 'Ein Teil der Vergilbung kann durch fehlende Naehrstoffe entstehen.',
|
||||
},
|
||||
{
|
||||
title: 'Unregelmaessiges Giessen',
|
||||
confidence: clampConfidence(confidenceMid),
|
||||
details: 'Zu grosse Schwankungen zwischen trocken und sehr nass belasten die Pflanze.',
|
||||
},
|
||||
{
|
||||
title: 'Standortstress',
|
||||
confidence: clampConfidence(confidenceLow),
|
||||
details: 'Zugluft oder haeufige Standortwechsel koennen Blattreaktionen ausloesen.',
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
title: 'Leichte Lichtanpassung noetig',
|
||||
confidence: clampConfidence(confidenceBase - 0.1),
|
||||
details: 'Einige Blaetter zeigen milde Stressanzeichen, insgesamt wirkt die Pflanze stabil.',
|
||||
},
|
||||
{
|
||||
title: 'Naechsten Duengertermin beobachten',
|
||||
confidence: clampConfidence(confidenceMid - 0.1),
|
||||
details: 'Bei weiterem Vergilben Duengung in kleiner Dosis einplanen.',
|
||||
},
|
||||
{
|
||||
title: 'Normale Blattalterung',
|
||||
confidence: clampConfidence(confidenceLow - 0.05),
|
||||
details: 'Aeltere untere Blaetter duerfen gelegentlich gelb werden.',
|
||||
},
|
||||
];
|
||||
|
||||
const actionsNow = status === 'critical'
|
||||
? [
|
||||
'Giessen fuer 3-5 Tage pausieren und Feuchtigkeit tief im Topf pruefen.',
|
||||
'Gelbe oder matschige Blaetter sauber entfernen.',
|
||||
'Topf auf gute Drainage pruefen; stehendes Wasser sofort entfernen.',
|
||||
]
|
||||
: status === 'watch'
|
||||
? [
|
||||
'Giessrhythmus fuer die naechsten 7 Tage konsistent halten.',
|
||||
'Pflanze heller, aber ohne harte Mittagssonne stellen.',
|
||||
'Auf Schaedlinge an Blattunterseiten kontrollieren.',
|
||||
]
|
||||
: [
|
||||
'Aktuellen Pflegeplan beibehalten.',
|
||||
'Nur bei deutlich trockener Erde giessen.',
|
||||
'Gelbe Altblaetter nach Bedarf entfernen.',
|
||||
];
|
||||
|
||||
const plan7Days = status === 'critical'
|
||||
? [
|
||||
'Tag 1: Feuchtigkeit messen und Uebertopf entleeren.',
|
||||
'Tag 3: Blattfarbe und Festigkeit erneut pruefen.',
|
||||
'Tag 5: Bei nasser Erde Umtopfen mit luftiger Mischung erwaegen.',
|
||||
'Tag 7: Neuen Foto-Health-Check ausfuehren.',
|
||||
]
|
||||
: [
|
||||
'Tag 1: Standort und Lichtdauer notieren.',
|
||||
'Tag 3: Leichte Drehung fuer gleichmaessigen Wuchs.',
|
||||
'Tag 5: Bodenfeuchte vor Giessen kontrollieren.',
|
||||
'Tag 7: Vergleichsfoto erstellen.',
|
||||
];
|
||||
|
||||
return {
|
||||
generatedAt: nowIso(),
|
||||
overallHealthScore: score,
|
||||
status,
|
||||
likelyIssues,
|
||||
actionsNow,
|
||||
plan7Days,
|
||||
creditsCharged,
|
||||
imageUri: request.imageUri,
|
||||
};
|
||||
}
|
||||
|
||||
if (request.language === 'es') {
|
||||
const likelyIssues = status === 'critical'
|
||||
? [
|
||||
{
|
||||
title: 'Posible exceso de riego',
|
||||
confidence: clampConfidence(confidenceBase + 0.22),
|
||||
details: 'Hojas amarillas y blandas pueden indicar demasiada humedad.',
|
||||
},
|
||||
{
|
||||
title: 'Estres de raiz',
|
||||
confidence: clampConfidence(confidenceMid + 0.15),
|
||||
details: 'Revisa si el sustrato esta compacto o con mal olor.',
|
||||
},
|
||||
{
|
||||
title: 'Falta de luz',
|
||||
confidence: clampConfidence(confidenceLow + 0.1),
|
||||
details: 'La luz insuficiente puede causar amarilleo y crecimiento lento.',
|
||||
},
|
||||
]
|
||||
: status === 'watch'
|
||||
? [
|
||||
{
|
||||
title: 'Deficit leve de nutrientes',
|
||||
confidence: clampConfidence(confidenceBase),
|
||||
details: 'Parte del amarilleo puede venir de nutricion insuficiente.',
|
||||
},
|
||||
{
|
||||
title: 'Riego irregular',
|
||||
confidence: clampConfidence(confidenceMid),
|
||||
details: 'Cambios bruscos entre seco y muy humedo estresan la planta.',
|
||||
},
|
||||
{
|
||||
title: 'Estres de ubicacion',
|
||||
confidence: clampConfidence(confidenceLow),
|
||||
details: 'Corrientes de aire o cambios frecuentes pueden afectar las hojas.',
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
title: 'Ajuste suave de luz',
|
||||
confidence: clampConfidence(confidenceBase - 0.1),
|
||||
details: 'Se observan signos leves, pero el estado general es bueno.',
|
||||
},
|
||||
{
|
||||
title: 'Monitorear fertilizacion',
|
||||
confidence: clampConfidence(confidenceMid - 0.1),
|
||||
details: 'Si continua el amarilleo, aplicar dosis pequena de fertilizante.',
|
||||
},
|
||||
{
|
||||
title: 'Envejecimiento normal',
|
||||
confidence: clampConfidence(confidenceLow - 0.05),
|
||||
details: 'Hojas inferiores viejas pueden amarillear de forma natural.',
|
||||
},
|
||||
];
|
||||
|
||||
const actionsNow = status === 'critical'
|
||||
? [
|
||||
'Pausar riego 3-5 dias y comprobar humedad profunda.',
|
||||
'Retirar hojas amarillas o blandas con herramienta limpia.',
|
||||
'Verificar drenaje y eliminar agua acumulada.',
|
||||
]
|
||||
: status === 'watch'
|
||||
? [
|
||||
'Mantener riego estable durante 7 dias.',
|
||||
'Mover a una zona mas luminosa sin sol fuerte directo.',
|
||||
'Revisar plagas en el reverso de las hojas.',
|
||||
]
|
||||
: [
|
||||
'Mantener rutina actual de cuidado.',
|
||||
'Regar solo cuando el sustrato este claramente seco.',
|
||||
'Retirar hojas amarillas antiguas si hace falta.',
|
||||
];
|
||||
|
||||
const plan7Days = status === 'critical'
|
||||
? [
|
||||
'Dia 1: Medir humedad y vaciar agua retenida.',
|
||||
'Dia 3: Revisar color y firmeza de hojas.',
|
||||
'Dia 5: Si sigue muy humedo, considerar trasplante con mezcla aireada.',
|
||||
'Dia 7: Repetir health-check con foto nueva.',
|
||||
]
|
||||
: [
|
||||
'Dia 1: Registrar ubicacion y horas de luz.',
|
||||
'Dia 3: Girar planta ligeramente para crecimiento uniforme.',
|
||||
'Dia 5: Comprobar humedad antes de regar.',
|
||||
'Dia 7: Tomar foto de comparacion.',
|
||||
];
|
||||
|
||||
return {
|
||||
generatedAt: nowIso(),
|
||||
overallHealthScore: score,
|
||||
status,
|
||||
likelyIssues,
|
||||
actionsNow,
|
||||
plan7Days,
|
||||
creditsCharged,
|
||||
imageUri: request.imageUri,
|
||||
};
|
||||
}
|
||||
|
||||
const likelyIssues = status === 'critical'
|
||||
? [
|
||||
{
|
||||
title: 'Possible overwatering',
|
||||
confidence: clampConfidence(confidenceBase + 0.22),
|
||||
details: 'Yellow and soft leaves can indicate excess moisture.',
|
||||
},
|
||||
{
|
||||
title: 'Root stress',
|
||||
confidence: clampConfidence(confidenceMid + 0.15),
|
||||
details: 'Check if the substrate is compacted or has a sour smell.',
|
||||
},
|
||||
{
|
||||
title: 'Low light stress',
|
||||
confidence: clampConfidence(confidenceLow + 0.1),
|
||||
details: 'Insufficient light can trigger yellowing and slower growth.',
|
||||
},
|
||||
]
|
||||
: status === 'watch'
|
||||
? [
|
||||
{
|
||||
title: 'Mild nutrient deficiency',
|
||||
confidence: clampConfidence(confidenceBase),
|
||||
details: 'Part of the yellowing may come from missing nutrients.',
|
||||
},
|
||||
{
|
||||
title: 'Inconsistent watering',
|
||||
confidence: clampConfidence(confidenceMid),
|
||||
details: 'Large swings between dry and wet can stress foliage.',
|
||||
},
|
||||
{
|
||||
title: 'Placement stress',
|
||||
confidence: clampConfidence(confidenceLow),
|
||||
details: 'Drafts or frequent location changes can affect leaves.',
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
title: 'Minor light adjustment',
|
||||
confidence: clampConfidence(confidenceBase - 0.1),
|
||||
details: 'Mild stress signs are present, but overall condition looks stable.',
|
||||
},
|
||||
{
|
||||
title: 'Monitor next feeding',
|
||||
confidence: clampConfidence(confidenceMid - 0.1),
|
||||
details: 'If yellowing continues, apply a light fertilizer dose.',
|
||||
},
|
||||
{
|
||||
title: 'Normal leaf aging',
|
||||
confidence: clampConfidence(confidenceLow - 0.05),
|
||||
details: 'Older lower leaves can yellow naturally over time.',
|
||||
},
|
||||
];
|
||||
|
||||
const actionsNow = status === 'critical'
|
||||
? [
|
||||
'Pause watering for 3-5 days and check deep soil moisture.',
|
||||
'Remove yellow or mushy leaves with clean tools.',
|
||||
'Ensure good drainage and empty standing water.',
|
||||
]
|
||||
: status === 'watch'
|
||||
? [
|
||||
'Keep watering schedule stable for 7 days.',
|
||||
'Move to brighter indirect light.',
|
||||
'Inspect leaf undersides for pests.',
|
||||
]
|
||||
: [
|
||||
'Keep the current care routine.',
|
||||
'Water only when soil is clearly dry.',
|
||||
'Trim older yellow leaves if needed.',
|
||||
];
|
||||
|
||||
const plan7Days = status === 'critical'
|
||||
? [
|
||||
'Day 1: Check moisture and remove excess water.',
|
||||
'Day 3: Re-check leaf color and firmness.',
|
||||
'Day 5: If still soggy, repot into an airy mix.',
|
||||
'Day 7: Run another health-check photo.',
|
||||
]
|
||||
: [
|
||||
'Day 1: Note light duration and placement.',
|
||||
'Day 3: Rotate plant slightly for even growth.',
|
||||
'Day 5: Check soil moisture before watering.',
|
||||
'Day 7: Take a comparison photo.',
|
||||
];
|
||||
|
||||
return {
|
||||
generatedAt: nowIso(),
|
||||
overallHealthScore: score,
|
||||
status,
|
||||
likelyIssues,
|
||||
actionsNow,
|
||||
plan7Days,
|
||||
creditsCharged,
|
||||
imageUri: request.imageUri,
|
||||
};
|
||||
};
|
||||
|
||||
export const mockBackendService = {
|
||||
getBillingSummary: async (userId: string): Promise<BillingSummary> => {
|
||||
return withUserLock(userId, async () => {
|
||||
const stores = await loadStores();
|
||||
const account = getOrCreateAccount(stores, userId);
|
||||
account.updatedAt = nowIso();
|
||||
await persistStores(stores);
|
||||
return buildBillingSummary(account);
|
||||
});
|
||||
},
|
||||
|
||||
scanPlant: async (request: ScanPlantRequest): Promise<ScanPlantResponse> => {
|
||||
const { response, simulatedDelayMs } = await withUserLock(request.userId, async () => {
|
||||
const stores = await loadStores();
|
||||
const account = getOrCreateAccount(stores, request.userId);
|
||||
|
||||
const idemEndpointKey = endpointKey('scan', request.userId, request.idempotencyKey);
|
||||
const cachedResponse = readIdempotentResponse<ScanPlantResponse>(stores.idempotency, idemEndpointKey);
|
||||
if (cachedResponse) {
|
||||
return {
|
||||
response: cachedResponse,
|
||||
simulatedDelayMs: getSimulatedDelay(account.plan),
|
||||
};
|
||||
}
|
||||
|
||||
let creditsCharged = 0;
|
||||
const modelPath: string[] = [];
|
||||
|
||||
creditsCharged += consumeCreditsWithIdempotency(
|
||||
account,
|
||||
stores.idempotency,
|
||||
chargeKey('scan-primary', request.userId, request.idempotencyKey),
|
||||
SCAN_PRIMARY_COST,
|
||||
);
|
||||
|
||||
let usedOpenAi = false;
|
||||
let result: IdentificationResult = getMockPlantByImage(request.imageUri, request.language, false);
|
||||
|
||||
if (openAiScanService.isConfigured()) {
|
||||
const openAiPrimary = await openAiScanService.identifyPlant(
|
||||
request.imageUri,
|
||||
request.language,
|
||||
'primary',
|
||||
account.plan === 'pro' ? 'pro' : 'free',
|
||||
);
|
||||
if (openAiPrimary) {
|
||||
result = openAiPrimary;
|
||||
usedOpenAi = true;
|
||||
modelPath.push('openai-primary');
|
||||
} else {
|
||||
result = getMockPlantByImage(request.imageUri, request.language, false);
|
||||
modelPath.push('openai-primary-failed');
|
||||
modelPath.push('mock-primary-fallback');
|
||||
}
|
||||
} else {
|
||||
modelPath.push('mock-primary');
|
||||
}
|
||||
|
||||
const shouldReview = result.confidence < LOW_CONFIDENCE_REVIEW_THRESHOLD;
|
||||
if (shouldReview && account.plan === 'pro') {
|
||||
try {
|
||||
creditsCharged += consumeCreditsWithIdempotency(
|
||||
account,
|
||||
stores.idempotency,
|
||||
chargeKey('scan-review', request.userId, request.idempotencyKey),
|
||||
SCAN_REVIEW_COST,
|
||||
);
|
||||
if (usedOpenAi) {
|
||||
const openAiReview = await openAiScanService.identifyPlant(
|
||||
request.imageUri,
|
||||
request.language,
|
||||
'review',
|
||||
account.plan === 'pro' ? 'pro' : 'free',
|
||||
);
|
||||
if (openAiReview) {
|
||||
result = openAiReview;
|
||||
modelPath.push('openai-review');
|
||||
} else {
|
||||
modelPath.push('openai-review-failed');
|
||||
}
|
||||
} else {
|
||||
result = getMockPlantByImage(request.imageUri, request.language, true);
|
||||
modelPath.push('mock-review');
|
||||
}
|
||||
} catch (error) {
|
||||
if (isBackendApiError(error) && error.code === 'INSUFFICIENT_CREDITS') {
|
||||
modelPath.push('review-skipped-insufficient-credits');
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
} else if (shouldReview) {
|
||||
modelPath.push('review-skipped-free-plan');
|
||||
}
|
||||
|
||||
account.updatedAt = nowIso();
|
||||
const response: ScanPlantResponse = {
|
||||
result,
|
||||
creditsCharged,
|
||||
modelPath,
|
||||
billing: buildBillingSummary(account),
|
||||
};
|
||||
|
||||
writeIdempotentResponse(stores.idempotency, idemEndpointKey, response);
|
||||
await persistStores(stores);
|
||||
|
||||
return {
|
||||
response,
|
||||
simulatedDelayMs: getSimulatedDelay(account.plan),
|
||||
};
|
||||
});
|
||||
|
||||
await sleep(simulatedDelayMs);
|
||||
return response;
|
||||
},
|
||||
|
||||
semanticSearch: async (request: SemanticSearchRequest): Promise<SemanticSearchResponse> => {
|
||||
const { response, simulatedDelayMs } = await withUserLock(request.userId, async () => {
|
||||
const stores = await loadStores();
|
||||
const account = getOrCreateAccount(stores, request.userId);
|
||||
|
||||
const idemEndpointKey = endpointKey('semantic-search', request.userId, request.idempotencyKey);
|
||||
const cachedResponse = readIdempotentResponse<SemanticSearchResponse>(stores.idempotency, idemEndpointKey);
|
||||
if (cachedResponse) {
|
||||
return {
|
||||
response: cachedResponse,
|
||||
simulatedDelayMs: getSimulatedDelay(account.plan),
|
||||
};
|
||||
}
|
||||
|
||||
const normalizedQuery = request.query.trim();
|
||||
if (!normalizedQuery) {
|
||||
const noResultResponse: SemanticSearchResponse = {
|
||||
status: 'no_results',
|
||||
results: [],
|
||||
creditsCharged: 0,
|
||||
billing: buildBillingSummary(account),
|
||||
};
|
||||
writeIdempotentResponse(stores.idempotency, idemEndpointKey, noResultResponse);
|
||||
await persistStores(stores);
|
||||
return {
|
||||
response: noResultResponse,
|
||||
simulatedDelayMs: getSimulatedDelay(account.plan),
|
||||
};
|
||||
}
|
||||
|
||||
const creditsCharged = consumeCreditsWithIdempotency(
|
||||
account,
|
||||
stores.idempotency,
|
||||
chargeKey('semantic-search', request.userId, request.idempotencyKey),
|
||||
SEMANTIC_SEARCH_COST,
|
||||
);
|
||||
|
||||
const results = searchMockCatalog(request.query, request.language, 18);
|
||||
account.updatedAt = nowIso();
|
||||
|
||||
const response: SemanticSearchResponse = {
|
||||
status: results.length > 0 ? 'success' : 'no_results',
|
||||
results,
|
||||
creditsCharged,
|
||||
billing: buildBillingSummary(account),
|
||||
};
|
||||
|
||||
writeIdempotentResponse(stores.idempotency, idemEndpointKey, response);
|
||||
await persistStores(stores);
|
||||
|
||||
return {
|
||||
response,
|
||||
simulatedDelayMs: getSimulatedDelay(account.plan),
|
||||
};
|
||||
});
|
||||
|
||||
await sleep(simulatedDelayMs);
|
||||
return response;
|
||||
},
|
||||
|
||||
healthCheck: async (request: HealthCheckRequest): Promise<HealthCheckResponse> => {
|
||||
const { response, simulatedDelayMs } = await withUserLock(request.userId, async () => {
|
||||
const stores = await loadStores();
|
||||
const account = getOrCreateAccount(stores, request.userId);
|
||||
|
||||
const idemEndpointKey = endpointKey('health-check', request.userId, request.idempotencyKey);
|
||||
const cachedResponse = readIdempotentResponse<HealthCheckResponse>(stores.idempotency, idemEndpointKey);
|
||||
if (cachedResponse) {
|
||||
return {
|
||||
response: cachedResponse,
|
||||
simulatedDelayMs: getSimulatedDelay(account.plan),
|
||||
};
|
||||
}
|
||||
|
||||
const normalizedImageUri = request.imageUri.trim();
|
||||
if (!normalizedImageUri) {
|
||||
throw new BackendApiError('BAD_REQUEST', 'Health check requires an image URI.', 400);
|
||||
}
|
||||
|
||||
if (!openAiScanService.isConfigured()) {
|
||||
throw new BackendApiError(
|
||||
'PROVIDER_ERROR',
|
||||
'OpenAI health check is unavailable. Please configure EXPO_PUBLIC_OPENAI_API_KEY.',
|
||||
502,
|
||||
);
|
||||
}
|
||||
|
||||
const aiAnalysis = await openAiScanService.analyzePlantHealth(
|
||||
normalizedImageUri,
|
||||
request.language,
|
||||
request.plantContext,
|
||||
);
|
||||
if (!aiAnalysis) {
|
||||
throw new BackendApiError(
|
||||
'PROVIDER_ERROR',
|
||||
'OpenAI health check failed. Please verify API key, network access, and image quality.',
|
||||
502,
|
||||
);
|
||||
}
|
||||
|
||||
const creditsCharged = consumeCreditsWithIdempotency(
|
||||
account,
|
||||
stores.idempotency,
|
||||
chargeKey('health-check', request.userId, request.idempotencyKey),
|
||||
HEALTH_CHECK_COST,
|
||||
);
|
||||
|
||||
const healthCheck: PlantHealthCheck = {
|
||||
generatedAt: nowIso(),
|
||||
overallHealthScore: aiAnalysis.overallHealthScore,
|
||||
status: aiAnalysis.status,
|
||||
likelyIssues: aiAnalysis.likelyIssues,
|
||||
actionsNow: aiAnalysis.actionsNow,
|
||||
plan7Days: aiAnalysis.plan7Days,
|
||||
creditsCharged,
|
||||
imageUri: normalizedImageUri,
|
||||
};
|
||||
account.updatedAt = nowIso();
|
||||
|
||||
const response: HealthCheckResponse = {
|
||||
healthCheck,
|
||||
creditsCharged,
|
||||
billing: buildBillingSummary(account),
|
||||
};
|
||||
|
||||
writeIdempotentResponse(stores.idempotency, idemEndpointKey, response);
|
||||
await persistStores(stores);
|
||||
|
||||
return {
|
||||
response,
|
||||
simulatedDelayMs: getSimulatedDelay(account.plan),
|
||||
};
|
||||
});
|
||||
|
||||
await sleep(simulatedDelayMs);
|
||||
return response;
|
||||
},
|
||||
|
||||
simulatePurchase: async (request: SimulatePurchaseRequest): Promise<SimulatePurchaseResponse> => {
|
||||
return withUserLock(request.userId, async () => {
|
||||
const stores = await loadStores();
|
||||
const account = getOrCreateAccount(stores, request.userId);
|
||||
|
||||
const idemEndpointKey = endpointKey('simulate-purchase', request.userId, request.idempotencyKey);
|
||||
const cachedResponse = readIdempotentResponse<SimulatePurchaseResponse>(stores.idempotency, idemEndpointKey);
|
||||
if (cachedResponse) return cachedResponse;
|
||||
|
||||
if (request.productId === 'monthly_pro' || request.productId === 'yearly_pro') {
|
||||
const now = new Date();
|
||||
const { cycleStartedAt, cycleEndsAt } = getCycleBounds(now);
|
||||
account.plan = 'pro';
|
||||
account.provider = 'mock';
|
||||
account.monthlyAllowance = PRO_MONTHLY_CREDITS;
|
||||
account.usedThisCycle = 0;
|
||||
account.cycleStartedAt = cycleStartedAt.toISOString();
|
||||
account.cycleEndsAt = cycleEndsAt.toISOString();
|
||||
account.renewsAt = addDays(now, 30).toISOString();
|
||||
} else {
|
||||
const credits = TOPUP_CREDITS_BY_PRODUCT[request.productId];
|
||||
account.topupBalance += credits;
|
||||
}
|
||||
|
||||
account.updatedAt = nowIso();
|
||||
|
||||
const response: SimulatePurchaseResponse = {
|
||||
appliedProduct: request.productId,
|
||||
billing: buildBillingSummary(account),
|
||||
};
|
||||
|
||||
writeIdempotentResponse(stores.idempotency, idemEndpointKey, response);
|
||||
await persistStores(stores);
|
||||
return response;
|
||||
});
|
||||
},
|
||||
|
||||
simulateWebhook: async (request: SimulateWebhookRequest): Promise<SimulateWebhookResponse> => {
|
||||
return withUserLock(request.userId, async () => {
|
||||
const stores = await loadStores();
|
||||
const account = getOrCreateAccount(stores, request.userId);
|
||||
|
||||
const idemEndpointKey = endpointKey('simulate-webhook', request.userId, request.idempotencyKey);
|
||||
const cachedResponse = readIdempotentResponse<SimulateWebhookResponse>(stores.idempotency, idemEndpointKey);
|
||||
if (cachedResponse) return cachedResponse;
|
||||
|
||||
if (request.event === 'entitlement_granted') {
|
||||
const now = new Date();
|
||||
const { cycleStartedAt, cycleEndsAt } = getCycleBounds(now);
|
||||
account.plan = 'pro';
|
||||
account.provider = 'revenuecat';
|
||||
account.monthlyAllowance = PRO_MONTHLY_CREDITS;
|
||||
account.usedThisCycle = 0;
|
||||
account.cycleStartedAt = cycleStartedAt.toISOString();
|
||||
account.cycleEndsAt = cycleEndsAt.toISOString();
|
||||
account.renewsAt = addDays(now, 30).toISOString();
|
||||
}
|
||||
|
||||
if (request.event === 'entitlement_revoked') {
|
||||
const now = new Date();
|
||||
const { cycleStartedAt, cycleEndsAt } = getCycleBounds(now);
|
||||
account.plan = 'free';
|
||||
account.provider = 'revenuecat';
|
||||
account.monthlyAllowance = FREE_MONTHLY_CREDITS;
|
||||
account.usedThisCycle = 0;
|
||||
account.cycleStartedAt = cycleStartedAt.toISOString();
|
||||
account.cycleEndsAt = cycleEndsAt.toISOString();
|
||||
account.renewsAt = null;
|
||||
}
|
||||
|
||||
if (request.event === 'topup_granted') {
|
||||
const credits = Math.max(1, request.payload?.credits || TOPUP_DEFAULT_CREDITS);
|
||||
account.topupBalance += credits;
|
||||
}
|
||||
|
||||
if (request.event === 'credits_depleted') {
|
||||
account.usedThisCycle = account.monthlyAllowance;
|
||||
account.topupBalance = 0;
|
||||
}
|
||||
|
||||
account.updatedAt = nowIso();
|
||||
|
||||
const response: SimulateWebhookResponse = {
|
||||
event: request.event,
|
||||
billing: buildBillingSummary(account),
|
||||
};
|
||||
|
||||
writeIdempotentResponse(stores.idempotency, idemEndpointKey, response);
|
||||
await persistStores(stores);
|
||||
return response;
|
||||
});
|
||||
},
|
||||
};
|
||||
299
services/backend/mockCatalog.ts
Normal file
299
services/backend/mockCatalog.ts
Normal file
@@ -0,0 +1,299 @@
|
||||
import { Language } from '../../types';
|
||||
import { BackendDatabaseEntry } from './contracts';
|
||||
import { LEXICON_BATCH_1_ENTRIES } from '../../constants/lexiconBatch1';
|
||||
import { LEXICON_BATCH_2_ENTRIES } from '../../constants/lexiconBatch2';
|
||||
import { normalizeSearchText, rankHybridEntries } from '../../utils/hybridSearch';
|
||||
|
||||
interface MockPlantSeed {
|
||||
botanicalName: string;
|
||||
names: Record<Language, string>;
|
||||
descriptions: Record<Language, string>;
|
||||
imageUri: string;
|
||||
categories: string[];
|
||||
baseConfidence: number;
|
||||
care: {
|
||||
waterIntervalDays: number;
|
||||
light: Record<Language, string>;
|
||||
temp: string;
|
||||
};
|
||||
}
|
||||
|
||||
const PLANTS: MockPlantSeed[] = [
|
||||
{
|
||||
botanicalName: 'Monstera deliciosa',
|
||||
names: { de: 'Monstera', en: 'Monstera', es: 'Monstera' },
|
||||
descriptions: {
|
||||
de: 'Beliebte Zimmerpflanze mit grossen gelochten Blaettern. Mag helles indirektes Licht.',
|
||||
en: 'Popular indoor plant with large split leaves. Prefers bright indirect light.',
|
||||
es: 'Planta de interior popular con hojas grandes. Prefiere luz brillante indirecta.',
|
||||
},
|
||||
imageUri: 'https://upload.wikimedia.org/wikipedia/commons/thumb/2/2e/Monstera_deliciosa2.jpg/500px-Monstera_deliciosa2.jpg',
|
||||
categories: ['easy', 'low_light', 'air_purifier'],
|
||||
baseConfidence: 0.76,
|
||||
care: {
|
||||
waterIntervalDays: 7,
|
||||
light: { de: 'Halbschatten', en: 'Partial shade', es: 'Sombra parcial' },
|
||||
temp: '18-24C',
|
||||
},
|
||||
},
|
||||
{
|
||||
botanicalName: 'Sansevieria trifasciata',
|
||||
names: { de: 'Bogenhanf', en: 'Snake Plant', es: 'Lengua de suegra' },
|
||||
descriptions: {
|
||||
de: 'Sehr robust und trockenheitsvertraeglich. Kommt auch mit wenig Licht klar.',
|
||||
en: 'Very resilient and drought tolerant. Handles low light well.',
|
||||
es: 'Muy resistente y tolera sequia. Funciona bien con poca luz.',
|
||||
},
|
||||
imageUri: 'https://upload.wikimedia.org/wikipedia/commons/thumb/f/fb/Snake_Plant_%28Sansevieria_trifasciata_%27Laurentii%27%29.jpg/500px-Snake_Plant_%28Sansevieria_trifasciata_%27Laurentii%27%29.jpg',
|
||||
categories: ['easy', 'succulent', 'low_light'],
|
||||
baseConfidence: 0.73,
|
||||
care: {
|
||||
waterIntervalDays: 14,
|
||||
light: { de: 'Schatten bis Sonne', en: 'Shade to sun', es: 'Sombra a sol' },
|
||||
temp: '16-30C',
|
||||
},
|
||||
},
|
||||
{
|
||||
botanicalName: 'Aloe vera',
|
||||
names: { de: 'Aloe Vera', en: 'Aloe Vera', es: 'Aloe Vera' },
|
||||
descriptions: {
|
||||
de: 'Sukkulente mit heilender Gelstruktur in den Blaettern. Braucht viel Licht.',
|
||||
en: 'Succulent with gel-filled leaves. Needs plenty of light.',
|
||||
es: 'Suculenta con hojas de gel. Necesita bastante luz.',
|
||||
},
|
||||
imageUri: 'https://upload.wikimedia.org/wikipedia/commons/thumb/4/4b/Aloe_vera_flower_inset.png/500px-Aloe_vera_flower_inset.png',
|
||||
categories: ['succulent', 'easy', 'sun'],
|
||||
baseConfidence: 0.78,
|
||||
care: {
|
||||
waterIntervalDays: 12,
|
||||
light: { de: 'Sonnig', en: 'Sunny', es: 'Soleado' },
|
||||
temp: '18-30C',
|
||||
},
|
||||
},
|
||||
{
|
||||
botanicalName: 'Epipremnum aureum',
|
||||
names: { de: 'Efeutute', en: 'Pothos', es: 'Poto' },
|
||||
descriptions: {
|
||||
de: 'Schnell wachsende Haengepflanze fuer Einsteiger. Vertraegt variierende Bedingungen.',
|
||||
en: 'Fast-growing trailing plant ideal for beginners. Handles varied conditions.',
|
||||
es: 'Planta colgante de crecimiento rapido para principiantes.',
|
||||
},
|
||||
imageUri: 'https://upload.wikimedia.org/wikipedia/commons/thumb/6/62/Money_Plant_%28Epipremnum_aureum%29_4.jpg/500px-Money_Plant_%28Epipremnum_aureum%29_4.jpg',
|
||||
categories: ['easy', 'hanging', 'air_purifier'],
|
||||
baseConfidence: 0.74,
|
||||
care: {
|
||||
waterIntervalDays: 7,
|
||||
light: { de: 'Halbschatten bis hell', en: 'Partial shade to bright', es: 'Sombra parcial a brillante' },
|
||||
temp: '18-27C',
|
||||
},
|
||||
},
|
||||
{
|
||||
botanicalName: 'Spathiphyllum',
|
||||
names: { de: 'Einblatt', en: 'Peace Lily', es: 'Cuna de moises' },
|
||||
descriptions: {
|
||||
de: 'Bluetenpflanze fuer Innenraeume. Zeigt Wasserbedarf schnell durch haengende Blaetter.',
|
||||
en: 'Indoor flowering plant. Droops clearly when it needs water.',
|
||||
es: 'Planta de flor de interior. Muestra rapido cuando necesita riego.',
|
||||
},
|
||||
imageUri: 'https://upload.wikimedia.org/wikipedia/commons/thumb/b/bd/Spathiphyllum_cochlearispathum_RTBG.jpg/500px-Spathiphyllum_cochlearispathum_RTBG.jpg',
|
||||
categories: ['flowering', 'low_light'],
|
||||
baseConfidence: 0.72,
|
||||
care: {
|
||||
waterIntervalDays: 5,
|
||||
light: { de: 'Halbschatten', en: 'Partial shade', es: 'Sombra parcial' },
|
||||
temp: '18-26C',
|
||||
},
|
||||
},
|
||||
{
|
||||
botanicalName: 'Zamioculcas zamiifolia',
|
||||
names: { de: 'Gluecksfeder', en: 'ZZ Plant', es: 'Planta ZZ' },
|
||||
descriptions: {
|
||||
de: 'Extrem pflegeleichte Pflanze mit dickem Rhizom. Perfekt fuer wenig Zeit.',
|
||||
en: 'Extremely low-maintenance plant with thick rhizomes. Great for busy schedules.',
|
||||
es: 'Planta muy facil de cuidar con rizomas gruesos.',
|
||||
},
|
||||
imageUri: 'https://upload.wikimedia.org/wikipedia/commons/thumb/c/cf/Zamioculcas_zamiifolia_1.jpg/500px-Zamioculcas_zamiifolia_1.jpg',
|
||||
categories: ['easy', 'low_light'],
|
||||
baseConfidence: 0.77,
|
||||
care: {
|
||||
waterIntervalDays: 14,
|
||||
light: { de: 'Wenig Licht bis hell', en: 'Low light to bright', es: 'Poca luz a brillante' },
|
||||
temp: '18-28C',
|
||||
},
|
||||
},
|
||||
{
|
||||
botanicalName: 'Calathea orbifolia',
|
||||
names: { de: 'Korbmarante', en: 'Calathea', es: 'Calathea' },
|
||||
descriptions: {
|
||||
de: 'Dekorative Blaetter mit Muster. Liebt gleichmaessige Feuchte und hohe Luftfeuchtigkeit.',
|
||||
en: 'Decorative patterned leaves. Prefers steady moisture and humidity.',
|
||||
es: 'Hojas decorativas con patron. Prefiere humedad constante.',
|
||||
},
|
||||
imageUri: 'https://upload.wikimedia.org/wikipedia/commons/thumb/f/fa/Calathea_orbifolia_2.jpg/500px-Calathea_orbifolia_2.jpg',
|
||||
categories: ['patterned', 'pet_friendly'],
|
||||
baseConfidence: 0.68,
|
||||
care: {
|
||||
waterIntervalDays: 4,
|
||||
light: { de: 'Halbschatten', en: 'Partial shade', es: 'Sombra parcial' },
|
||||
temp: '18-25C',
|
||||
},
|
||||
},
|
||||
{
|
||||
botanicalName: 'Chlorophytum comosum',
|
||||
names: { de: 'Gruenlilie', en: 'Spider Plant', es: 'Cinta' },
|
||||
descriptions: {
|
||||
de: 'Unkomplizierte Zimmerpflanze mit langen gebogenen Blaettern. Vermehrt sich schnell.',
|
||||
en: 'Easy houseplant with long arched leaves. Propagates quickly.',
|
||||
es: 'Planta sencilla con hojas largas arqueadas. Se multiplica rapido.',
|
||||
},
|
||||
imageUri: 'https://upload.wikimedia.org/wikipedia/commons/thumb/b/b1/Hierbabuena_0611_Revised.jpg/500px-Hierbabuena_0611_Revised.jpg',
|
||||
categories: ['easy', 'pet_friendly', 'air_purifier'],
|
||||
baseConfidence: 0.79,
|
||||
care: {
|
||||
waterIntervalDays: 6,
|
||||
light: { de: 'Hell bis halbschattig', en: 'Bright to partial shade', es: 'Brillante a sombra parcial' },
|
||||
temp: '16-24C',
|
||||
},
|
||||
},
|
||||
{
|
||||
botanicalName: 'Anthurium andraeanum',
|
||||
names: { de: 'Flamingoblume', en: 'Anthurium', es: 'Anturio' },
|
||||
descriptions: {
|
||||
de: 'Auffaellige Hochblaetter und lange Bluetezeit. Braucht Waerme und gleichmaessige Feuchte.',
|
||||
en: 'Known for vivid spathes and long bloom cycles. Likes warmth and even moisture.',
|
||||
es: 'Conocida por sus flores vistosas. Requiere calor y riego regular.',
|
||||
},
|
||||
imageUri: 'https://upload.wikimedia.org/wikipedia/commons/thumb/8/8b/AnthuriumAndraenum.jpg/500px-AnthuriumAndraenum.jpg',
|
||||
categories: ['flowering', 'high_humidity'],
|
||||
baseConfidence: 0.71,
|
||||
care: {
|
||||
waterIntervalDays: 6,
|
||||
light: { de: 'Hell ohne direkte Sonne', en: 'Bright indirect light', es: 'Luz brillante indirecta' },
|
||||
temp: '18-27C',
|
||||
},
|
||||
},
|
||||
{
|
||||
botanicalName: 'Nephrolepis exaltata',
|
||||
names: { de: 'Schwertfarn', en: 'Boston Fern', es: 'Helecho de Boston' },
|
||||
descriptions: {
|
||||
de: 'Fein gefiederter Farn. Benoetigt regelmaessige Feuchte und keine direkte Sonne.',
|
||||
en: 'Fine-textured fern. Needs regular moisture and no harsh direct sun.',
|
||||
es: 'Helecho de textura fina. Necesita humedad constante.',
|
||||
},
|
||||
imageUri: 'https://upload.wikimedia.org/wikipedia/commons/thumb/3/30/Boston_Fern_%282873392811%29.png/500px-Boston_Fern_%282873392811%29.png',
|
||||
categories: ['pet_friendly', 'air_purifier', 'high_humidity'],
|
||||
baseConfidence: 0.7,
|
||||
care: {
|
||||
waterIntervalDays: 3,
|
||||
light: { de: 'Halbschatten', en: 'Partial shade', es: 'Sombra parcial' },
|
||||
temp: '16-24C',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const hashString = (value: string): number => {
|
||||
let hash = 2166136261;
|
||||
for (let i = 0; i < value.length; i += 1) {
|
||||
hash ^= value.charCodeAt(i);
|
||||
hash = Math.imul(hash, 16777619);
|
||||
}
|
||||
return hash >>> 0;
|
||||
};
|
||||
|
||||
const clampConfidence = (value: number): number => {
|
||||
return Number(Math.max(0.4, Math.min(0.99, value)).toFixed(2));
|
||||
};
|
||||
|
||||
const toEntry = (
|
||||
seed: MockPlantSeed,
|
||||
lang: Language,
|
||||
confidence: number,
|
||||
): BackendDatabaseEntry => {
|
||||
return {
|
||||
name: seed.names[lang],
|
||||
botanicalName: seed.botanicalName,
|
||||
description: seed.descriptions[lang],
|
||||
confidence: clampConfidence(confidence),
|
||||
careInfo: {
|
||||
waterIntervalDays: seed.care.waterIntervalDays,
|
||||
light: seed.care.light[lang],
|
||||
temp: seed.care.temp,
|
||||
},
|
||||
imageUri: seed.imageUri,
|
||||
categories: seed.categories,
|
||||
};
|
||||
};
|
||||
|
||||
const getFullCatalog = (lang: Language): BackendDatabaseEntry[] => {
|
||||
const seeds = PLANTS.map(seed => toEntry(seed, lang, seed.baseConfidence));
|
||||
const batches = getBatchCatalog(lang);
|
||||
const catalog = [...seeds, ...batches];
|
||||
|
||||
const seenBotanical = new Set<string>();
|
||||
const seenImage = new Set<string>();
|
||||
|
||||
return catalog.filter(e => {
|
||||
const botanicalKey = normalizeSearchText(e.botanicalName);
|
||||
const imageKey = e.imageUri.trim();
|
||||
|
||||
if (seenBotanical.has(botanicalKey) || seenImage.has(imageKey)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
seenBotanical.add(botanicalKey);
|
||||
seenImage.add(imageKey);
|
||||
return true;
|
||||
});
|
||||
};
|
||||
|
||||
export const getMockCatalog = (lang: Language): BackendDatabaseEntry[] => {
|
||||
return getFullCatalog(lang);
|
||||
};
|
||||
|
||||
export const getMockPlantByImage = (
|
||||
imageUri: string,
|
||||
lang: Language,
|
||||
preferHighConfidence: boolean,
|
||||
): BackendDatabaseEntry => {
|
||||
const seeds = PLANTS;
|
||||
const indexHash = hashString(`${lang}:${imageUri}`);
|
||||
const selectedSeed = seeds[indexHash % seeds.length];
|
||||
|
||||
const varianceHash = hashString(`confidence:${imageUri}:${selectedSeed.botanicalName}`);
|
||||
const variance = (varianceHash % 28) / 100; // 0.00 - 0.27
|
||||
const confidenceBase = selectedSeed.baseConfidence - 0.08 + variance;
|
||||
const confidence = preferHighConfidence ? confidenceBase + 0.09 : confidenceBase;
|
||||
|
||||
return toEntry(selectedSeed, lang, confidence);
|
||||
};
|
||||
|
||||
const getBatchCatalog = (lang: Language): BackendDatabaseEntry[] => {
|
||||
const all = [...LEXICON_BATCH_1_ENTRIES, ...LEXICON_BATCH_2_ENTRIES];
|
||||
return all.map(entry => ({
|
||||
name: entry.name,
|
||||
botanicalName: entry.botanicalName,
|
||||
description: entry.description || '',
|
||||
confidence: entry.confidence,
|
||||
careInfo: {
|
||||
waterIntervalDays: entry.careInfo.waterIntervalDays,
|
||||
light: entry.careInfo.light,
|
||||
temp: entry.careInfo.temp,
|
||||
},
|
||||
imageUri: entry.imageUri,
|
||||
categories: entry.categories,
|
||||
}));
|
||||
};
|
||||
|
||||
export const searchMockCatalog = (
|
||||
query: string,
|
||||
lang: Language,
|
||||
limit = 12,
|
||||
): BackendDatabaseEntry[] => {
|
||||
const normalizedQuery = normalizeSearchText(query);
|
||||
const deduped = getFullCatalog(lang);
|
||||
|
||||
if (!normalizedQuery) return deduped.slice(0, limit);
|
||||
|
||||
return rankHybridEntries(deduped, normalizedQuery, limit)
|
||||
.map((candidate) => candidate.entry);
|
||||
};
|
||||
564
services/backend/openAiScanService.ts
Normal file
564
services/backend/openAiScanService.ts
Normal file
@@ -0,0 +1,564 @@
|
||||
import { CareInfo, IdentificationResult, Language } from '../../types';
|
||||
|
||||
type OpenAiScanMode = 'primary' | 'review';
|
||||
|
||||
export interface OpenAiHealthIssue {
|
||||
title: string;
|
||||
confidence: number;
|
||||
details: string;
|
||||
}
|
||||
|
||||
export interface OpenAiHealthAnalysis {
|
||||
overallHealthScore: number;
|
||||
status: 'healthy' | 'watch' | 'critical';
|
||||
likelyIssues: OpenAiHealthIssue[];
|
||||
actionsNow: string[];
|
||||
plan7Days: string[];
|
||||
}
|
||||
|
||||
const OPENAI_API_KEY = (process.env.EXPO_PUBLIC_OPENAI_API_KEY || '').trim();
|
||||
const OPENAI_SCAN_MODEL = (process.env.EXPO_PUBLIC_OPENAI_SCAN_MODEL || 'gpt-5-mini').trim();
|
||||
const OPENAI_SCAN_MODEL_PRO = (process.env.EXPO_PUBLIC_OPENAI_SCAN_MODEL_PRO || OPENAI_SCAN_MODEL).trim();
|
||||
const OPENAI_HEALTH_MODEL = (process.env.EXPO_PUBLIC_OPENAI_HEALTH_MODEL || OPENAI_SCAN_MODEL).trim();
|
||||
const OPENAI_SCAN_FALLBACK_MODELS = (process.env.EXPO_PUBLIC_OPENAI_SCAN_FALLBACK_MODELS || 'gpt-5-mini,gpt-4.1-mini').trim();
|
||||
const OPENAI_SCAN_FALLBACK_MODELS_PRO = (process.env.EXPO_PUBLIC_OPENAI_SCAN_FALLBACK_MODELS_PRO || OPENAI_SCAN_FALLBACK_MODELS).trim();
|
||||
const OPENAI_HEALTH_FALLBACK_MODELS = (process.env.EXPO_PUBLIC_OPENAI_HEALTH_FALLBACK_MODELS || OPENAI_SCAN_FALLBACK_MODELS).trim();
|
||||
const OPENAI_CHAT_COMPLETIONS_URL = 'https://api.openai.com/v1/chat/completions';
|
||||
const OPENAI_TIMEOUT_MS = (() => {
|
||||
const raw = (process.env.EXPO_PUBLIC_OPENAI_TIMEOUT_MS || '45000').trim();
|
||||
const parsed = Number.parseInt(raw, 10);
|
||||
if (Number.isFinite(parsed) && parsed >= 10000) return parsed;
|
||||
return 45000;
|
||||
})();
|
||||
|
||||
const parseModelChain = (primaryModel: string, fallbackModels: string): string[] => {
|
||||
const models = [primaryModel];
|
||||
fallbackModels.split(',').forEach((model) => {
|
||||
const normalized = model.trim();
|
||||
if (normalized) models.push(normalized);
|
||||
});
|
||||
return [...new Set(models)];
|
||||
};
|
||||
|
||||
const OPENAI_SCAN_MODEL_CHAIN = parseModelChain(OPENAI_SCAN_MODEL, OPENAI_SCAN_FALLBACK_MODELS);
|
||||
const OPENAI_SCAN_MODEL_CHAIN_PRO = parseModelChain(OPENAI_SCAN_MODEL_PRO, OPENAI_SCAN_FALLBACK_MODELS_PRO);
|
||||
const OPENAI_HEALTH_MODEL_CHAIN = parseModelChain(OPENAI_HEALTH_MODEL, OPENAI_HEALTH_FALLBACK_MODELS);
|
||||
|
||||
const getScanModelChain = (plan: 'free' | 'pro'): string[] => {
|
||||
return plan === 'pro' ? OPENAI_SCAN_MODEL_CHAIN_PRO : OPENAI_SCAN_MODEL_CHAIN;
|
||||
};
|
||||
|
||||
const clamp = (value: number, min: number, max: number): number => {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
};
|
||||
|
||||
const toErrorMessage = (error: unknown): string => {
|
||||
if (error instanceof Error) return error.message;
|
||||
return String(error);
|
||||
};
|
||||
|
||||
const summarizeImageUri = (imageUri: string): string => {
|
||||
const trimmed = imageUri.trim();
|
||||
if (!trimmed) return 'empty';
|
||||
if (trimmed.startsWith('data:image')) return `data-uri(${Math.round(trimmed.length / 1024)}kb)`;
|
||||
return trimmed.length > 120 ? `${trimmed.slice(0, 120)}...` : trimmed;
|
||||
};
|
||||
|
||||
const toJsonString = (content: string): string => {
|
||||
const trimmed = content.trim();
|
||||
if (!trimmed) return trimmed;
|
||||
|
||||
const fenced = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i);
|
||||
if (fenced?.[1]) return fenced[1].trim();
|
||||
return trimmed;
|
||||
};
|
||||
|
||||
const parseContentToJson = (content: string): Record<string, unknown> | null => {
|
||||
try {
|
||||
const parsed = JSON.parse(toJsonString(content));
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
return parsed as Record<string, unknown>;
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getString = (value: unknown): string => {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
};
|
||||
|
||||
const getNumber = (value: unknown): number | null => {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
const parsed = Number(value);
|
||||
if (Number.isFinite(parsed)) return parsed;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const getStringArray = (value: unknown): string[] => {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value
|
||||
.map((item) => (typeof item === 'string' ? item.trim() : ''))
|
||||
.filter(Boolean);
|
||||
};
|
||||
|
||||
const normalizeResult = (
|
||||
raw: Record<string, unknown>,
|
||||
language: Language,
|
||||
): IdentificationResult | null => {
|
||||
const name = getString(raw.name);
|
||||
const botanicalName = getString(raw.botanicalName);
|
||||
const description = getString(raw.description);
|
||||
const confidenceRaw = getNumber(raw.confidence);
|
||||
const careInfoRaw = raw.careInfo;
|
||||
|
||||
if (!name || !botanicalName || !careInfoRaw || typeof careInfoRaw !== 'object' || Array.isArray(careInfoRaw)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const careInfoObj = careInfoRaw as Record<string, unknown>;
|
||||
const waterIntervalRaw = getNumber(careInfoObj.waterIntervalDays);
|
||||
const light = getString(careInfoObj.light);
|
||||
const temp = getString(careInfoObj.temp);
|
||||
|
||||
if (waterIntervalRaw == null || !light || !temp) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fallbackDescription = language === 'de'
|
||||
? `${name} wurde per KI erkannt. Pflegehinweise sind unten aufgefuehrt.`
|
||||
: language === 'es'
|
||||
? `${name} se detecto con IA. Debajo veras recomendaciones de cuidado.`
|
||||
: `${name} was identified with AI. Care guidance is shown below.`;
|
||||
|
||||
return {
|
||||
name,
|
||||
botanicalName,
|
||||
confidence: clamp(confidenceRaw ?? 0.72, 0.05, 0.99),
|
||||
description: description || fallbackDescription,
|
||||
careInfo: {
|
||||
waterIntervalDays: Math.round(clamp(waterIntervalRaw, 1, 45)),
|
||||
light,
|
||||
temp,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const getLanguageLabel = (language: Language): string => {
|
||||
if (language === 'de') return 'German';
|
||||
if (language === 'es') return 'Spanish';
|
||||
return 'English';
|
||||
};
|
||||
|
||||
const buildPrompt = (language: Language, mode: OpenAiScanMode): string => {
|
||||
const reviewInstruction = mode === 'review'
|
||||
? 'Re-check your first hypothesis with stricter botanical accuracy and correct any mismatch.'
|
||||
: 'Identify the most likely houseplant species from this image with conservative confidence.';
|
||||
|
||||
const nameLanguageInstruction = language === 'en'
|
||||
? '- "name" must be an English common name only. Never return a German or other non-English common name. If no reliable English common name is known, use "botanicalName" as "name" instead of inventing or translating.'
|
||||
: `- "name" must be strictly written in ${getLanguageLabel(language)}. If a reliable common name in that language is not known, use "botanicalName" as "name" instead of inventing a localized name.`;
|
||||
|
||||
return [
|
||||
`${reviewInstruction}`,
|
||||
`Return strict JSON only in this shape:`,
|
||||
`{"name":"...","botanicalName":"...","confidence":0.0,"description":"...","careInfo":{"waterIntervalDays":7,"light":"...","temp":"..."}}`,
|
||||
`Rules:`,
|
||||
nameLanguageInstruction,
|
||||
`- "description" and "careInfo.light" must be written in ${getLanguageLabel(language)}.`,
|
||||
`- "botanicalName" must use accepted Latin scientific naming and must not be invented or misspelled.`,
|
||||
`- If species is uncertain, prefer genus-level naming (for example: "Calathea sp.").`,
|
||||
`- "confidence" must be between 0 and 1.`,
|
||||
`- Keep confidence <= 0.55 when the image is ambiguous, blurred, or partially visible.`,
|
||||
`- "waterIntervalDays" must be an integer between 1 and 45.`,
|
||||
`- Do not include markdown, explanations, or extra keys.`,
|
||||
].join('\n');
|
||||
};
|
||||
|
||||
const buildHealthPrompt = (
|
||||
language: Language,
|
||||
plantContext?: {
|
||||
name: string;
|
||||
botanicalName: string;
|
||||
careInfo: CareInfo;
|
||||
description?: string;
|
||||
},
|
||||
): string => {
|
||||
const contextLines = plantContext
|
||||
? [
|
||||
`Plant context:`,
|
||||
`- name: ${plantContext.name}`,
|
||||
`- botanicalName: ${plantContext.botanicalName}`,
|
||||
`- care.light: ${plantContext.careInfo.light}`,
|
||||
`- care.temp: ${plantContext.careInfo.temp}`,
|
||||
`- care.waterIntervalDays: ${plantContext.careInfo.waterIntervalDays}`,
|
||||
`- description: ${plantContext.description || 'n/a'}`,
|
||||
]
|
||||
: ['Plant context: not provided'];
|
||||
|
||||
return [
|
||||
`Analyze this plant photo for real health condition signs with focus on yellowing leaves, watering stress, pests, and light stress.`,
|
||||
`Return strict JSON only in this shape:`,
|
||||
`{"overallHealthScore":72,"status":"watch","likelyIssues":[{"title":"...","confidence":0.64,"details":"..."}],"actionsNow":["..."],"plan7Days":["..."]}`,
|
||||
`Rules:`,
|
||||
`- "overallHealthScore" must be an integer between 0 and 100.`,
|
||||
`- "status" must be one of: "healthy", "watch", "critical".`,
|
||||
`- "likelyIssues" must contain 1 to 4 items sorted by confidence descending.`,
|
||||
`- "confidence" must be between 0 and 1.`,
|
||||
`- "title", "details", "actionsNow", and "plan7Days" must be written in ${getLanguageLabel(language)}.`,
|
||||
`- "actionsNow" should be immediate steps for the next 24 hours.`,
|
||||
`- "plan7Days" should be short actionable steps for the next week.`,
|
||||
`- Do not include markdown, explanations, or extra keys.`,
|
||||
...contextLines,
|
||||
].join('\n');
|
||||
};
|
||||
|
||||
const buildFallbackHealthAnalysis = (
|
||||
language: Language,
|
||||
plantContext?: {
|
||||
name: string;
|
||||
botanicalName: string;
|
||||
careInfo: CareInfo;
|
||||
description?: string;
|
||||
},
|
||||
): OpenAiHealthAnalysis => {
|
||||
if (language === 'de') {
|
||||
return {
|
||||
overallHealthScore: 58,
|
||||
status: 'watch',
|
||||
likelyIssues: [
|
||||
{
|
||||
title: 'Eingeschraenkte KI-Analyse',
|
||||
confidence: 0.42,
|
||||
details: `${plantContext?.name || 'Die Pflanze'} konnte wegen instabiler Antwort nicht vollstaendig bewertet werden.`,
|
||||
},
|
||||
],
|
||||
actionsNow: [
|
||||
'Neues Foto bei hellem, indirektem Licht aufnehmen.',
|
||||
'Blaetter auf Flecken, Schaedlinge und trockene Raender pruefen.',
|
||||
'Erst giessen, wenn die oberen 2-3 cm Erde trocken sind.',
|
||||
],
|
||||
plan7Days: [
|
||||
'In 2 Tagen mit neuem Foto erneut pruefen.',
|
||||
'Farbe und Blattspannung taeglich beobachten.',
|
||||
'Bei Verschlechterung Standort und Giessrhythmus anpassen.',
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
if (language === 'es') {
|
||||
return {
|
||||
overallHealthScore: 58,
|
||||
status: 'watch',
|
||||
likelyIssues: [
|
||||
{
|
||||
title: 'Analisis de IA limitado',
|
||||
confidence: 0.42,
|
||||
details: `${plantContext?.name || 'La planta'} no pudo evaluarse por completo por una respuesta inestable.`,
|
||||
},
|
||||
],
|
||||
actionsNow: [
|
||||
'Tomar una foto nueva con luz brillante e indirecta.',
|
||||
'Revisar hojas por manchas, plagas y bordes secos.',
|
||||
'Regar solo si los 2-3 cm superiores del sustrato estan secos.',
|
||||
],
|
||||
plan7Days: [
|
||||
'Revisar otra vez en 2 dias con una foto nueva.',
|
||||
'Observar color y firmeza de hojas cada dia.',
|
||||
'Si empeora, ajustar ubicacion y frecuencia de riego.',
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
overallHealthScore: 58,
|
||||
status: 'watch',
|
||||
likelyIssues: [
|
||||
{
|
||||
title: 'Limited AI analysis',
|
||||
confidence: 0.42,
|
||||
details: `${plantContext?.name || 'This plant'} could not be fully assessed due to an unstable provider response.`,
|
||||
},
|
||||
],
|
||||
actionsNow: [
|
||||
'Capture a new photo in bright indirect light.',
|
||||
'Inspect leaves for spots, pests, and dry edges.',
|
||||
'Water only if the top 2-3 cm of soil is dry.',
|
||||
],
|
||||
plan7Days: [
|
||||
'Re-check in 2 days with a new photo.',
|
||||
'Track leaf color and firmness daily.',
|
||||
'If symptoms worsen, adjust placement and watering cadence.',
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeHealthAnalysis = (
|
||||
raw: Record<string, unknown>,
|
||||
language: Language,
|
||||
): OpenAiHealthAnalysis | null => {
|
||||
const scoreRaw = getNumber(raw.overallHealthScore);
|
||||
const statusRaw = getString(raw.status);
|
||||
const issuesRaw = raw.likelyIssues;
|
||||
const actionsNowRaw = getStringArray(raw.actionsNow).slice(0, 6);
|
||||
const plan7DaysRaw = getStringArray(raw.plan7Days).slice(0, 7);
|
||||
|
||||
if (scoreRaw == null || !statusRaw || !Array.isArray(issuesRaw)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const status: OpenAiHealthAnalysis['status'] = statusRaw === 'healthy' || statusRaw === 'watch' || statusRaw === 'critical'
|
||||
? statusRaw
|
||||
: 'watch';
|
||||
|
||||
const likelyIssues = issuesRaw
|
||||
.map((entry) => {
|
||||
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return null;
|
||||
const issueObj = entry as Record<string, unknown>;
|
||||
const title = getString(issueObj.title);
|
||||
const details = getString(issueObj.details);
|
||||
const confidenceRaw = getNumber(issueObj.confidence);
|
||||
if (!title || !details || confidenceRaw == null) return null;
|
||||
return {
|
||||
title,
|
||||
details,
|
||||
confidence: clamp(confidenceRaw, 0.05, 0.99),
|
||||
} as OpenAiHealthIssue;
|
||||
})
|
||||
.filter((entry): entry is OpenAiHealthIssue => Boolean(entry))
|
||||
.slice(0, 4);
|
||||
|
||||
if (likelyIssues.length === 0 || actionsNowRaw.length === 0 || plan7DaysRaw.length === 0) {
|
||||
const fallbackIssue = language === 'de'
|
||||
? 'Die KI konnte keine stabilen Gesundheitsmerkmale extrahieren.'
|
||||
: language === 'es'
|
||||
? 'La IA no pudo extraer senales de salud estables.'
|
||||
: 'AI could not extract stable health signals.';
|
||||
return {
|
||||
overallHealthScore: Math.round(clamp(scoreRaw, 0, 100)),
|
||||
status,
|
||||
likelyIssues: [
|
||||
{
|
||||
title: language === 'de'
|
||||
? 'Analyse unsicher'
|
||||
: language === 'es'
|
||||
? 'Analisis incierto'
|
||||
: 'Uncertain analysis',
|
||||
confidence: 0.35,
|
||||
details: fallbackIssue,
|
||||
},
|
||||
],
|
||||
actionsNow: actionsNowRaw.length > 0
|
||||
? actionsNowRaw
|
||||
: [language === 'de' ? 'Neues, schaerferes Foto aufnehmen.' : language === 'es' ? 'Tomar una foto nueva y mas nitida.' : 'Capture a new, sharper photo.'],
|
||||
plan7Days: plan7DaysRaw.length > 0
|
||||
? plan7DaysRaw
|
||||
: [language === 'de' ? 'In 2 Tagen erneut pruefen.' : language === 'es' ? 'Volver a revisar en 2 dias.' : 'Re-check in 2 days.'],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
overallHealthScore: Math.round(clamp(scoreRaw, 0, 100)),
|
||||
status,
|
||||
likelyIssues,
|
||||
actionsNow: actionsNowRaw,
|
||||
plan7Days: plan7DaysRaw,
|
||||
};
|
||||
};
|
||||
|
||||
const extractMessageContent = (payload: unknown): string => {
|
||||
const response = payload as {
|
||||
choices?: Array<{
|
||||
message?: {
|
||||
content?: string | Array<{ type?: string; text?: string }>;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
|
||||
const content = response.choices?.[0]?.message?.content;
|
||||
if (typeof content === 'string') return content;
|
||||
if (Array.isArray(content)) {
|
||||
return content
|
||||
.map((chunk) => (chunk?.type === 'text' ? chunk.text || '' : ''))
|
||||
.join('')
|
||||
.trim();
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
const postChatCompletion = async (
|
||||
modelChain: string[],
|
||||
imageUri: string,
|
||||
messages: Array<Record<string, unknown>>,
|
||||
): Promise<{ payload: Record<string, unknown> | null; modelUsed: string | null; attemptedModels: string[] }> => {
|
||||
const attemptedModels: string[] = [];
|
||||
|
||||
for (const model of modelChain) {
|
||||
attemptedModels.push(model);
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), OPENAI_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const response = await fetch(OPENAI_CHAT_COMPLETIONS_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${OPENAI_API_KEY}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
response_format: { type: 'json_object' },
|
||||
messages,
|
||||
}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text();
|
||||
console.warn('OpenAI request HTTP error.', {
|
||||
status: response.status,
|
||||
model,
|
||||
image: summarizeImageUri(imageUri),
|
||||
bodyPreview: body.slice(0, 300),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as Record<string, unknown>;
|
||||
return { payload, modelUsed: model, attemptedModels };
|
||||
} catch (error) {
|
||||
const isTimeoutAbort = error instanceof Error && error.name === 'AbortError';
|
||||
console.warn('OpenAI request failed.', {
|
||||
model,
|
||||
timeoutMs: OPENAI_TIMEOUT_MS,
|
||||
aborted: isTimeoutAbort,
|
||||
error: toErrorMessage(error),
|
||||
image: summarizeImageUri(imageUri),
|
||||
});
|
||||
continue;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
return { payload: null, modelUsed: null, attemptedModels };
|
||||
};
|
||||
|
||||
export const openAiScanService = {
|
||||
isConfigured: (): boolean => Boolean(OPENAI_API_KEY),
|
||||
|
||||
identifyPlant: async (
|
||||
imageUri: string,
|
||||
language: Language,
|
||||
mode: OpenAiScanMode = 'primary',
|
||||
plan: 'free' | 'pro' = 'free',
|
||||
): Promise<IdentificationResult | null> => {
|
||||
if (!OPENAI_API_KEY) return null;
|
||||
const modelChain = getScanModelChain(plan);
|
||||
const completion = await postChatCompletion(
|
||||
modelChain,
|
||||
imageUri,
|
||||
[
|
||||
{
|
||||
role: 'system',
|
||||
content: 'You are a plant identification assistant. Return strict JSON only.',
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: buildPrompt(language, mode) },
|
||||
{ type: 'image_url', image_url: { url: imageUri } },
|
||||
],
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
if (!completion.payload) return null;
|
||||
|
||||
const content = extractMessageContent(completion.payload);
|
||||
if (!content) {
|
||||
console.warn('OpenAI plant scan returned empty message content.', {
|
||||
model: completion.modelUsed || modelChain[0],
|
||||
mode,
|
||||
image: summarizeImageUri(imageUri),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = parseContentToJson(content);
|
||||
if (!parsed) {
|
||||
console.warn('OpenAI plant scan returned non-JSON content.', {
|
||||
model: completion.modelUsed || modelChain[0],
|
||||
mode,
|
||||
preview: content.slice(0, 220),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalized = normalizeResult(parsed, language);
|
||||
if (!normalized) {
|
||||
console.warn('OpenAI plant scan JSON did not match required schema.', {
|
||||
model: completion.modelUsed || modelChain[0],
|
||||
mode,
|
||||
keys: Object.keys(parsed),
|
||||
});
|
||||
}
|
||||
|
||||
return normalized;
|
||||
},
|
||||
|
||||
analyzePlantHealth: async (
|
||||
imageUri: string,
|
||||
language: Language,
|
||||
plantContext?: {
|
||||
name: string;
|
||||
botanicalName: string;
|
||||
careInfo: CareInfo;
|
||||
description?: string;
|
||||
},
|
||||
): Promise<OpenAiHealthAnalysis | null> => {
|
||||
if (!OPENAI_API_KEY) return null;
|
||||
const completion = await postChatCompletion(
|
||||
OPENAI_HEALTH_MODEL_CHAIN,
|
||||
imageUri,
|
||||
[
|
||||
{
|
||||
role: 'system',
|
||||
content: 'You are a plant health diagnosis assistant. Return strict JSON only.',
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: buildHealthPrompt(language, plantContext) },
|
||||
{ type: 'image_url', image_url: { url: imageUri } },
|
||||
],
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
if (!completion.payload) return buildFallbackHealthAnalysis(language, plantContext);
|
||||
|
||||
const content = extractMessageContent(completion.payload);
|
||||
if (!content) {
|
||||
console.warn('OpenAI health check returned empty content.', {
|
||||
model: completion.modelUsed || OPENAI_HEALTH_MODEL_CHAIN[0],
|
||||
image: summarizeImageUri(imageUri),
|
||||
});
|
||||
return buildFallbackHealthAnalysis(language, plantContext);
|
||||
}
|
||||
|
||||
const parsed = parseContentToJson(content);
|
||||
if (!parsed) {
|
||||
console.warn('OpenAI health check returned non-JSON content.', {
|
||||
model: completion.modelUsed || OPENAI_HEALTH_MODEL_CHAIN[0],
|
||||
preview: content.slice(0, 220),
|
||||
});
|
||||
return buildFallbackHealthAnalysis(language, plantContext);
|
||||
}
|
||||
|
||||
return normalizeHealthAnalysis(parsed, language) || buildFallbackHealthAnalysis(language, plantContext);
|
||||
},
|
||||
};
|
||||
17
services/backend/userIdentityService.ts
Normal file
17
services/backend/userIdentityService.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import * as SecureStore from 'expo-secure-store';
|
||||
|
||||
const SESSION_KEY = 'greenlens_session_v3';
|
||||
|
||||
export const getAuthToken = async (): Promise<string> => {
|
||||
try {
|
||||
const raw = await SecureStore.getItemAsync(SESSION_KEY);
|
||||
if (raw) {
|
||||
const session = JSON.parse(raw);
|
||||
if (typeof session?.token === 'string' && session.token) {
|
||||
return session.token;
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
// Return 'guest' instead of throwing to allow guest mode
|
||||
return 'guest';
|
||||
};
|
||||
344
services/database.ts
Normal file
344
services/database.ts
Normal file
@@ -0,0 +1,344 @@
|
||||
import * as SQLite from 'expo-sqlite';
|
||||
import { Plant, CareInfo, Language, AppearanceMode, ColorPalette } from '../types';
|
||||
|
||||
// ─── DB-Instanz ────────────────────────────────────────────────────────────────
|
||||
|
||||
let _db: SQLite.SQLiteDatabase | null = null;
|
||||
let _isDatabaseInitialized = false;
|
||||
|
||||
export const getDb = (): SQLite.SQLiteDatabase => {
|
||||
if (!_db) _db = SQLite.openDatabaseSync('greenlens.db');
|
||||
return _db;
|
||||
};
|
||||
|
||||
// ─── Schema ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export const initDatabase = (): void => {
|
||||
if (_isDatabaseInitialized) return;
|
||||
|
||||
const db = getDb();
|
||||
|
||||
try {
|
||||
db.execSync(`
|
||||
PRAGMA foreign_keys = ON;
|
||||
PRAGMA journal_mode = WAL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
email TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
password_hash TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_settings (
|
||||
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
language TEXT NOT NULL DEFAULT 'de',
|
||||
language_set INTEGER NOT NULL DEFAULT 0,
|
||||
appearance_mode TEXT NOT NULL DEFAULT 'system',
|
||||
color_palette TEXT NOT NULL DEFAULT 'forest',
|
||||
profile_image TEXT,
|
||||
onboarding_done INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS plants (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
botanical_name TEXT NOT NULL DEFAULT '',
|
||||
image_uri TEXT NOT NULL DEFAULT '',
|
||||
date_added TEXT NOT NULL,
|
||||
care_info TEXT NOT NULL DEFAULT '{}',
|
||||
last_watered TEXT NOT NULL,
|
||||
watering_history TEXT NOT NULL DEFAULT '[]',
|
||||
gallery TEXT NOT NULL DEFAULT '[]',
|
||||
description TEXT,
|
||||
notifications_enabled INTEGER NOT NULL DEFAULT 0,
|
||||
health_checks TEXT NOT NULL DEFAULT '[]'
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS lexicon_search_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
query TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app_meta (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
`);
|
||||
_isDatabaseInitialized = true;
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize SQLite schema.', error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Migration: add language_set column to existing databases
|
||||
try {
|
||||
db.runSync('ALTER TABLE user_settings ADD COLUMN language_set INTEGER NOT NULL DEFAULT 0');
|
||||
} catch (_) { /* column already exists */ }
|
||||
};
|
||||
|
||||
// ─── App Meta ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export const AppMetaDb = {
|
||||
get(key: string): string | null {
|
||||
const row = getDb().getFirstSync<{ value: string }>(
|
||||
'SELECT value FROM app_meta WHERE key = ?',
|
||||
[key],
|
||||
);
|
||||
return row?.value ?? null;
|
||||
},
|
||||
|
||||
set(key: string, value: string): void {
|
||||
getDb().runSync(
|
||||
'INSERT INTO app_meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value',
|
||||
[key, value],
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
// ─── Auth ──────────────────────────────────────────────────────────────────────
|
||||
// Credential management has moved to the server (server/lib/auth.js + JWT).
|
||||
// AuthDb only manages the local device user (id=1) used for plants/settings queries.
|
||||
|
||||
export interface DbUser {
|
||||
id: number;
|
||||
email: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export const AuthDb = {
|
||||
// Ensures a local device user exists. Maps email to a unique local ID.
|
||||
ensureLocalUser(email: string, name: string): { id: number } {
|
||||
const db = getDb();
|
||||
const normalizedEmail = email.trim().toLowerCase();
|
||||
|
||||
// Check if this specific email already has a local account
|
||||
const existing = db.getFirstSync<{ id: number }>('SELECT id FROM users WHERE email = ?', [normalizedEmail]);
|
||||
|
||||
if (existing) {
|
||||
// Update name just in case it changed on server
|
||||
db.runSync('UPDATE users SET name = ? WHERE id = ?', [name.trim(), existing.id]);
|
||||
return { id: existing.id };
|
||||
}
|
||||
|
||||
// Create a new local user if it doesn't exist
|
||||
const result = db.runSync(
|
||||
'INSERT INTO users (email, name, password_hash) VALUES (?, ?, ?)',
|
||||
[normalizedEmail, name.trim(), 'server-auth'],
|
||||
);
|
||||
|
||||
const newUserId = result.lastInsertRowId;
|
||||
db.runSync('INSERT OR IGNORE INTO user_settings (user_id) VALUES (?)', [newUserId]);
|
||||
|
||||
return { id: newUserId };
|
||||
},
|
||||
|
||||
getUserById(id: number): DbUser | null {
|
||||
const db = getDb();
|
||||
const user = db.getFirstSync<DbUser>(
|
||||
'SELECT id, email, name FROM users WHERE id = ?',
|
||||
[id],
|
||||
);
|
||||
return user || null;
|
||||
},
|
||||
};
|
||||
|
||||
// ─── Settings ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export const SettingsDb = {
|
||||
get(userId: number) {
|
||||
const db = getDb();
|
||||
db.runSync('INSERT OR IGNORE INTO user_settings (user_id) VALUES (?)', [userId]);
|
||||
return db.getFirstSync<{
|
||||
language: string;
|
||||
language_set: number;
|
||||
appearance_mode: string;
|
||||
color_palette: string;
|
||||
profile_image: string | null;
|
||||
onboarding_done: number;
|
||||
}>('SELECT * FROM user_settings WHERE user_id = ?', [userId])!;
|
||||
},
|
||||
|
||||
setLanguage(userId: number, lang: Language) {
|
||||
getDb().runSync('UPDATE user_settings SET language = ?, language_set = 1 WHERE user_id = ?', [lang, userId]);
|
||||
},
|
||||
|
||||
setAppearanceMode(userId: number, mode: AppearanceMode) {
|
||||
getDb().runSync('UPDATE user_settings SET appearance_mode = ? WHERE user_id = ?', [mode, userId]);
|
||||
},
|
||||
|
||||
setColorPalette(userId: number, palette: ColorPalette) {
|
||||
getDb().runSync('UPDATE user_settings SET color_palette = ? WHERE user_id = ?', [palette, userId]);
|
||||
},
|
||||
|
||||
setProfileImage(userId: number, uri: string | null) {
|
||||
getDb().runSync('UPDATE user_settings SET profile_image = ? WHERE user_id = ?', [uri, userId]);
|
||||
},
|
||||
|
||||
setOnboardingDone(userId: number, done: boolean) {
|
||||
getDb().runSync(
|
||||
'UPDATE user_settings SET onboarding_done = ? WHERE user_id = ?',
|
||||
[done ? 1 : 0, userId],
|
||||
);
|
||||
},
|
||||
|
||||
setName(userId: number, name: string) {
|
||||
getDb().runSync('UPDATE users SET name = ? WHERE id = ?', [name.trim(), userId]);
|
||||
},
|
||||
};
|
||||
|
||||
// ─── Plants ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const DEFAULT_CARE_INFO: CareInfo = {
|
||||
waterIntervalDays: 7,
|
||||
light: 'Bright indirect light',
|
||||
temp: '18-25 C',
|
||||
};
|
||||
|
||||
const safeJsonParse = <T,>(value: unknown, fallback: T, fieldName: string, plantId: string): T => {
|
||||
if (typeof value !== 'string' || !value.trim()) return fallback;
|
||||
|
||||
try {
|
||||
return JSON.parse(value) as T;
|
||||
} catch (error) {
|
||||
console.warn('Failed to parse plant JSON field. Falling back to defaults.', {
|
||||
plantId,
|
||||
fieldName,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return fallback;
|
||||
}
|
||||
};
|
||||
|
||||
const parsePlant = (row: any): Plant => ({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
botanicalName: row.botanical_name,
|
||||
imageUri: row.image_uri,
|
||||
dateAdded: row.date_added,
|
||||
careInfo: safeJsonParse<CareInfo>(row.care_info, DEFAULT_CARE_INFO, 'care_info', row.id),
|
||||
lastWatered: row.last_watered,
|
||||
wateringHistory: safeJsonParse<string[]>(row.watering_history, [], 'watering_history', row.id),
|
||||
gallery: safeJsonParse<string[]>(row.gallery, [], 'gallery', row.id),
|
||||
description: row.description ?? undefined,
|
||||
notificationsEnabled: row.notifications_enabled === 1,
|
||||
healthChecks: safeJsonParse(row.health_checks, [], 'health_checks', row.id),
|
||||
});
|
||||
|
||||
export const PlantsDb = {
|
||||
getAll(userId: number): Plant[] {
|
||||
const rows = getDb().getAllSync<any>(
|
||||
'SELECT * FROM plants WHERE user_id = ? ORDER BY date_added DESC',
|
||||
[userId],
|
||||
);
|
||||
return rows.map(parsePlant);
|
||||
},
|
||||
|
||||
insert(userId: number, plant: Plant): void {
|
||||
getDb().runSync(
|
||||
`INSERT INTO plants
|
||||
(id, user_id, name, botanical_name, image_uri, date_added,
|
||||
care_info, last_watered, watering_history, gallery,
|
||||
description, notifications_enabled, health_checks)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
plant.id,
|
||||
userId,
|
||||
plant.name,
|
||||
plant.botanicalName,
|
||||
plant.imageUri,
|
||||
plant.dateAdded,
|
||||
JSON.stringify(plant.careInfo),
|
||||
plant.lastWatered,
|
||||
JSON.stringify(plant.wateringHistory ?? []),
|
||||
JSON.stringify(plant.gallery ?? []),
|
||||
plant.description ?? null,
|
||||
plant.notificationsEnabled ? 1 : 0,
|
||||
JSON.stringify(plant.healthChecks ?? []),
|
||||
],
|
||||
);
|
||||
},
|
||||
|
||||
update(userId: number, plant: Plant): void {
|
||||
getDb().runSync(
|
||||
`UPDATE plants SET
|
||||
name = ?, botanical_name = ?, image_uri = ?,
|
||||
care_info = ?, last_watered = ?, watering_history = ?,
|
||||
gallery = ?, description = ?, notifications_enabled = ?, health_checks = ?
|
||||
WHERE id = ? AND user_id = ?`,
|
||||
[
|
||||
plant.name,
|
||||
plant.botanicalName,
|
||||
plant.imageUri,
|
||||
JSON.stringify(plant.careInfo),
|
||||
plant.lastWatered,
|
||||
JSON.stringify(plant.wateringHistory ?? []),
|
||||
JSON.stringify(plant.gallery ?? []),
|
||||
plant.description ?? null,
|
||||
plant.notificationsEnabled ? 1 : 0,
|
||||
JSON.stringify(plant.healthChecks ?? []),
|
||||
plant.id,
|
||||
userId,
|
||||
],
|
||||
);
|
||||
},
|
||||
|
||||
delete(userId: number, plantId: string): void {
|
||||
getDb().runSync('DELETE FROM plants WHERE id = ? AND user_id = ?', [plantId, userId]);
|
||||
},
|
||||
};
|
||||
|
||||
// ─── Lexicon Search History ────────────────────────────────────────────────────
|
||||
|
||||
const HISTORY_LIMIT = 10;
|
||||
|
||||
const normalize = (v: string) =>
|
||||
v.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '').trim().replace(/\s+/g, ' ');
|
||||
|
||||
export const LexiconHistoryDb = {
|
||||
getAll(userId: number): string[] {
|
||||
return getDb()
|
||||
.getAllSync<{ query: string }>(
|
||||
'SELECT query FROM lexicon_search_history WHERE user_id = ? ORDER BY created_at DESC LIMIT ?',
|
||||
[userId, HISTORY_LIMIT],
|
||||
)
|
||||
.map((r) => r.query);
|
||||
},
|
||||
|
||||
add(userId: number, query: string): void {
|
||||
const trimmed = query.trim();
|
||||
if (!trimmed) return;
|
||||
const db = getDb();
|
||||
// Duplikate entfernen
|
||||
const normalized = normalize(trimmed);
|
||||
const existing = db.getAllSync<{ id: number; query: string }>(
|
||||
'SELECT id, query FROM lexicon_search_history WHERE user_id = ?',
|
||||
[userId],
|
||||
);
|
||||
for (const row of existing) {
|
||||
if (normalize(row.query) === normalized) {
|
||||
db.runSync('DELETE FROM lexicon_search_history WHERE id = ?', [row.id]);
|
||||
}
|
||||
}
|
||||
db.runSync(
|
||||
'INSERT INTO lexicon_search_history (user_id, query) VALUES (?, ?)',
|
||||
[userId, trimmed],
|
||||
);
|
||||
// Limit halten
|
||||
const oldest = db.getAllSync<{ id: number }>(
|
||||
'SELECT id FROM lexicon_search_history WHERE user_id = ? ORDER BY created_at DESC LIMIT -1 OFFSET ?',
|
||||
[userId, HISTORY_LIMIT],
|
||||
);
|
||||
for (const row of oldest) {
|
||||
db.runSync('DELETE FROM lexicon_search_history WHERE id = ?', [row.id]);
|
||||
}
|
||||
},
|
||||
|
||||
clear(userId: number): void {
|
||||
getDb().runSync('DELETE FROM lexicon_search_history WHERE user_id = ?', [userId]);
|
||||
},
|
||||
};
|
||||
113
services/imageCacheService.ts
Normal file
113
services/imageCacheService.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import * as FileSystemLegacy from 'expo-file-system/legacy';
|
||||
|
||||
|
||||
const getCacheDir = (): string => {
|
||||
const baseDir = FileSystemLegacy.documentDirectory ?? FileSystemLegacy.cacheDirectory;
|
||||
if (!baseDir) {
|
||||
throw new Error('No writable file system directory is available for image caching.');
|
||||
}
|
||||
return `${baseDir}plant-images/`;
|
||||
};
|
||||
|
||||
const ensureCacheDir = async (): Promise<string> => {
|
||||
const cacheDir = getCacheDir();
|
||||
const dirInfo = await FileSystemLegacy.getInfoAsync(cacheDir);
|
||||
if (!dirInfo.exists) {
|
||||
await FileSystemLegacy.makeDirectoryAsync(cacheDir, { intermediates: true });
|
||||
}
|
||||
return cacheDir;
|
||||
};
|
||||
|
||||
const hashString = (value: string): string => {
|
||||
// FNV-1a 32-bit hash for stable cache file names.
|
||||
let hash = 2166136261;
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
hash ^= value.charCodeAt(index);
|
||||
hash = Math.imul(hash, 16777619);
|
||||
}
|
||||
return (hash >>> 0).toString(36);
|
||||
};
|
||||
|
||||
const getDataUriExtension = (uri: string): string => {
|
||||
const mimeMatch = uri.match(/^data:(image\/[a-zA-Z0-9.+-]+);base64,/i);
|
||||
const mimeType = mimeMatch?.[1]?.toLowerCase() || '';
|
||||
|
||||
if (mimeType.includes('png')) return 'png';
|
||||
if (mimeType.includes('webp')) return 'webp';
|
||||
if (mimeType.includes('gif')) return 'gif';
|
||||
if (mimeType.includes('heic')) return 'heic';
|
||||
if (mimeType.includes('heif')) return 'heif';
|
||||
return 'jpg';
|
||||
};
|
||||
|
||||
const getUriExtension = (uri: string): string => {
|
||||
const cleanPath = uri.split(/[?#]/)[0];
|
||||
const extensionMatch = cleanPath.match(/\.([a-zA-Z0-9]+)$/);
|
||||
return extensionMatch?.[1]?.toLowerCase() || 'jpg';
|
||||
};
|
||||
|
||||
const getFileName = (uri: string): string => {
|
||||
const extension = uri.startsWith('data:')
|
||||
? getDataUriExtension(uri)
|
||||
: getUriExtension(uri);
|
||||
return `${hashString(uri)}.${extension}`;
|
||||
};
|
||||
|
||||
export const ImageCacheService = {
|
||||
/**
|
||||
* Check if an image is already cached locally.
|
||||
*/
|
||||
isCached: async (uri: string): Promise<{ exists: boolean; localUri: string }> => {
|
||||
const cacheDir = await ensureCacheDir();
|
||||
const fileName = getFileName(uri);
|
||||
const localUri = `${cacheDir}${fileName}`;
|
||||
const info = await FileSystemLegacy.getInfoAsync(localUri);
|
||||
return { exists: info.exists, localUri };
|
||||
},
|
||||
|
||||
/**
|
||||
* Cache an image (base64 data URI or remote URL) and return the local file path.
|
||||
*/
|
||||
cacheImage: async (uri: string): Promise<string> => {
|
||||
const cacheDir = await ensureCacheDir();
|
||||
|
||||
const fileName = getFileName(uri);
|
||||
const localUri = `${cacheDir}${fileName}`;
|
||||
const info = await FileSystemLegacy.getInfoAsync(localUri);
|
||||
const exists = info.exists;
|
||||
if (exists) return localUri;
|
||||
|
||||
if (uri.startsWith('data:')) {
|
||||
// Extract base64 content after the comma
|
||||
const base64Data = uri.split(',')[1];
|
||||
if (!base64Data) throw new Error('Invalid base64 data URI');
|
||||
await FileSystemLegacy.writeAsStringAsync(localUri, base64Data, {
|
||||
encoding: FileSystemLegacy.EncodingType.Base64,
|
||||
});
|
||||
} else if (/^(file:\/\/|content:\/\/)/i.test(uri)) {
|
||||
await FileSystemLegacy.copyAsync({ from: uri, to: localUri });
|
||||
} else {
|
||||
// Remote URL - download it
|
||||
const downloadResult = await FileSystemLegacy.downloadAsync(uri, localUri);
|
||||
if (downloadResult.status !== 200) {
|
||||
throw new Error(`Failed to download image: HTTP ${downloadResult.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
return localUri;
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete a cached image by its local path.
|
||||
*/
|
||||
deleteCachedImage: async (localUri: string): Promise<void> => {
|
||||
try {
|
||||
const info = await FileSystemLegacy.getInfoAsync(localUri);
|
||||
if (info.exists) {
|
||||
await FileSystemLegacy.deleteAsync(localUri, { idempotent: true });
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to delete cached image', e);
|
||||
}
|
||||
},
|
||||
};
|
||||
43
services/notificationService.ts
Normal file
43
services/notificationService.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import * as Notifications from 'expo-notifications';
|
||||
import { Platform } from 'react-native';
|
||||
import { Plant } from '../types';
|
||||
|
||||
Notifications.setNotificationHandler({
|
||||
handleNotification: async () => ({
|
||||
shouldShowAlert: true,
|
||||
shouldShowBanner: true,
|
||||
shouldShowList: true,
|
||||
shouldPlaySound: true,
|
||||
shouldSetBadge: false,
|
||||
}),
|
||||
});
|
||||
|
||||
export async function requestPermissions(): Promise<boolean> {
|
||||
const { status: existing } = await Notifications.getPermissionsAsync();
|
||||
if (existing === 'granted') return true;
|
||||
|
||||
const { status } = await Notifications.requestPermissionsAsync();
|
||||
return status === 'granted';
|
||||
}
|
||||
|
||||
export async function scheduleWateringReminder(plant: Plant): Promise<void> {
|
||||
const intervalDays = plant.careInfo.waterIntervalDays;
|
||||
|
||||
await Notifications.scheduleNotificationAsync({
|
||||
identifier: `water-${plant.id}`,
|
||||
content: {
|
||||
title: 'Watering Reminder 💧',
|
||||
body: `Time to water your ${plant.name}!`,
|
||||
data: { plantId: plant.id },
|
||||
},
|
||||
trigger: {
|
||||
type: Notifications.SchedulableTriggerInputTypes.TIME_INTERVAL,
|
||||
seconds: intervalDays * 24 * 60 * 60,
|
||||
repeats: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function cancelReminder(plantId: string): Promise<void> {
|
||||
await Notifications.cancelScheduledNotificationAsync(`water-${plantId}`);
|
||||
}
|
||||
@@ -1,253 +1,132 @@
|
||||
|
||||
import { IdentificationResult, Language } from '../types';
|
||||
import { resolveImageUri, tryResolveImageUri } from '../utils/imageUri';
|
||||
import { getConfiguredApiBaseUrl } from '../utils/backendUrl';
|
||||
import { backendApiClient } from './backend/backendApiClient';
|
||||
import { BackendDatabaseEntry, isBackendApiError } from './backend/contracts';
|
||||
import { createIdempotencyKey } from '../utils/idempotency';
|
||||
import { getMockCatalog, searchMockCatalog } from './backend/mockCatalog';
|
||||
|
||||
interface DatabaseEntry extends IdentificationResult {
|
||||
imageUri: string; // Default image for the lexicon
|
||||
export interface DatabaseEntry extends IdentificationResult {
|
||||
imageUri: string;
|
||||
imageStatus?: 'ok' | 'missing' | 'invalid';
|
||||
categories: string[];
|
||||
}
|
||||
|
||||
const PLANT_DATABASE: Record<Language, DatabaseEntry[]> = {
|
||||
de: [
|
||||
{
|
||||
name: "Monstera",
|
||||
botanicalName: "Monstera deliciosa",
|
||||
confidence: 1.0,
|
||||
careInfo: { waterIntervalDays: 7, light: "Halbschatten", temp: "18-24°C" },
|
||||
description: "Die Monstera Deliciosa, auch Fensterblatt genannt, ist bekannt für ihre großen, geteilten Blätter. Sie ist pflegeleicht und reinigt die Luft in Innenräumen effektiv.",
|
||||
imageUri: "https://images.unsplash.com/photo-1614594975525-e45190c55d0b?q=80&w=400&auto=format&fit=crop",
|
||||
categories: ["easy", "low_light", "air_purifier"]
|
||||
},
|
||||
{
|
||||
name: "Birkenfeige",
|
||||
botanicalName: "Ficus benjamina",
|
||||
confidence: 1.0,
|
||||
careInfo: { waterIntervalDays: 5, light: "Hell", temp: "16-24°C" },
|
||||
description: "Die Birkenfeige ist eine beliebte Zimmerpflanze mit eleganten, überhängenden Zweigen und glänzenden Blättern. Sie reagiert empfindlich auf Standortwechsel.",
|
||||
imageUri: "https://images.unsplash.com/photo-1509223197845-458d87318791?q=80&w=400&auto=format&fit=crop",
|
||||
categories: ["tree", "bright_light"]
|
||||
},
|
||||
{
|
||||
name: "Echeveria",
|
||||
botanicalName: "Echeveria elegans",
|
||||
confidence: 1.0,
|
||||
careInfo: { waterIntervalDays: 14, light: "Sonnig", temp: "18-28°C" },
|
||||
description: "Diese Sukkulente bildet wunderschöne Rosetten und speichert Wasser in ihren dicken Blättern. Sie ist ideal für sonnige Fensterbänke und sehr pflegeleicht.",
|
||||
imageUri: "https://images.unsplash.com/photo-1520302669765-66b37fb890d7?q=80&w=400&auto=format&fit=crop",
|
||||
categories: ["succulent", "easy", "small"]
|
||||
},
|
||||
{
|
||||
name: "Bogenhanf",
|
||||
botanicalName: "Sansevieria trifasciata",
|
||||
confidence: 1.0,
|
||||
careInfo: { waterIntervalDays: 21, light: "Schatten bis Sonne", temp: "15-30°C" },
|
||||
description: "Der Bogenhanf ist fast unzerstörbar. Er kommt mit wenig Licht und Wasser aus und ist einer der besten Luftreiniger für das Schlafzimmer.",
|
||||
imageUri: "https://images.unsplash.com/photo-1620127530668-37c2275ae158?q=80&w=400&auto=format&fit=crop",
|
||||
categories: ["succulent", "easy", "low_light", "air_purifier"]
|
||||
},
|
||||
{
|
||||
name: "Echte Aloe",
|
||||
botanicalName: "Aloe vera",
|
||||
confidence: 1.0,
|
||||
careInfo: { waterIntervalDays: 14, light: "Sonnig", temp: "20-30°C" },
|
||||
description: "Eine Heilpflanze, deren Gel bei Sonnenbrand hilft. Sie benötigt einen sehr hellen Standort und wenig Wasser.",
|
||||
imageUri: "https://images.unsplash.com/photo-1567689265771-828557d4766c?q=80&w=400&auto=format&fit=crop",
|
||||
categories: ["succulent", "medicinal", "sun"]
|
||||
},
|
||||
{
|
||||
name: "Grünlilie",
|
||||
botanicalName: "Chlorophytum comosum",
|
||||
confidence: 1.0,
|
||||
careInfo: { waterIntervalDays: 7, light: "Halbschatten", temp: "15-23°C" },
|
||||
description: "Die Grünlilie ist extrem anpassungsfähig und bildet schnell Ableger. Sie verzeiht Gießfehler und ist ideal für Anfänger.",
|
||||
imageUri: "https://images.unsplash.com/photo-1616766649725-b44c698308eb?q=80&w=400&auto=format&fit=crop",
|
||||
categories: ["easy", "hanging", "pet_friendly"]
|
||||
},
|
||||
{
|
||||
name: "Einblatt",
|
||||
botanicalName: "Spathiphyllum",
|
||||
confidence: 1.0,
|
||||
careInfo: { waterIntervalDays: 5, light: "Halbschatten", temp: "18-25°C" },
|
||||
description: "Das Einblatt zeigt durch hängende Blätter an, wann es Wasser braucht. Es blüht auch bei weniger Licht wunderschön weiß.",
|
||||
imageUri: "https://images.unsplash.com/photo-1610496185876-06835a64627d?q=80&w=400&auto=format&fit=crop",
|
||||
categories: ["flowering", "low_light", "air_purifier"]
|
||||
},
|
||||
{
|
||||
name: "Korbmarante",
|
||||
botanicalName: "Calathea",
|
||||
confidence: 1.0,
|
||||
careInfo: { waterIntervalDays: 4, light: "Halbschatten", temp: "18-24°C" },
|
||||
description: "Calatheas sind bekannt für ihre gemusterten Blätter, die sich nachts zusammenfalten. Sie benötigen hohe Luftfeuchtigkeit.",
|
||||
imageUri: "https://images.unsplash.com/photo-1600869680373-b82fa72f8823?q=80&w=400&auto=format&fit=crop",
|
||||
categories: ["patterned", "pet_friendly", "high_humidity"]
|
||||
}
|
||||
],
|
||||
en: [
|
||||
{
|
||||
name: "Monstera",
|
||||
botanicalName: "Monstera deliciosa",
|
||||
confidence: 1.0,
|
||||
careInfo: { waterIntervalDays: 7, light: "Partial Shade", temp: "18-24°C" },
|
||||
description: "The Monstera Deliciosa, also known as the Swiss Cheese Plant, is known for its large, split leaves. It is easy to care for and effectively purifies indoor air.",
|
||||
imageUri: "https://images.unsplash.com/photo-1614594975525-e45190c55d0b?q=80&w=400&auto=format&fit=crop",
|
||||
categories: ["easy", "low_light", "air_purifier"]
|
||||
},
|
||||
{
|
||||
name: "Weeping Fig",
|
||||
botanicalName: "Ficus benjamina",
|
||||
confidence: 1.0,
|
||||
careInfo: { waterIntervalDays: 5, light: "Bright", temp: "16-24°C" },
|
||||
description: "The Weeping Fig is a popular houseplant with elegant, drooping branches and glossy leaves. It is sensitive to changes in location.",
|
||||
imageUri: "https://images.unsplash.com/photo-1509223197845-458d87318791?q=80&w=400&auto=format&fit=crop",
|
||||
categories: ["tree", "bright_light"]
|
||||
},
|
||||
{
|
||||
name: "Mexican Snowball",
|
||||
botanicalName: "Echeveria elegans",
|
||||
confidence: 1.0,
|
||||
careInfo: { waterIntervalDays: 14, light: "Sunny", temp: "18-28°C" },
|
||||
description: "This succulent forms beautiful rosettes and stores water in its thick leaves. It is ideal for sunny windowsills and very low maintenance.",
|
||||
imageUri: "https://images.unsplash.com/photo-1520302669765-66b37fb890d7?q=80&w=400&auto=format&fit=crop",
|
||||
categories: ["succulent", "easy", "small"]
|
||||
},
|
||||
{
|
||||
name: "Snake Plant",
|
||||
botanicalName: "Sansevieria trifasciata",
|
||||
confidence: 1.0,
|
||||
careInfo: { waterIntervalDays: 21, light: "Shade to Sun", temp: "15-30°C" },
|
||||
description: "The Snake Plant is nearly indestructible. It tolerates low light and drought and is one of the best air purifiers for the bedroom.",
|
||||
imageUri: "https://images.unsplash.com/photo-1620127530668-37c2275ae158?q=80&w=400&auto=format&fit=crop",
|
||||
categories: ["succulent", "easy", "low_light", "air_purifier"]
|
||||
},
|
||||
{
|
||||
name: "Aloe Vera",
|
||||
botanicalName: "Aloe vera",
|
||||
confidence: 1.0,
|
||||
careInfo: { waterIntervalDays: 14, light: "Sunny", temp: "20-30°C" },
|
||||
description: "A medicinal plant whose gel helps with sunburn. It requires a very bright spot and little water.",
|
||||
imageUri: "https://images.unsplash.com/photo-1567689265771-828557d4766c?q=80&w=400&auto=format&fit=crop",
|
||||
categories: ["succulent", "medicinal", "sun"]
|
||||
},
|
||||
{
|
||||
name: "Spider Plant",
|
||||
botanicalName: "Chlorophytum comosum",
|
||||
confidence: 1.0,
|
||||
careInfo: { waterIntervalDays: 7, light: "Partial Shade", temp: "15-23°C" },
|
||||
description: "The Spider Plant is extremely adaptable and quickly forms offshoots. It forgives watering mistakes and is ideal for beginners.",
|
||||
imageUri: "https://images.unsplash.com/photo-1616766649725-b44c698308eb?q=80&w=400&auto=format&fit=crop",
|
||||
categories: ["easy", "hanging", "pet_friendly"]
|
||||
},
|
||||
{
|
||||
name: "Peace Lily",
|
||||
botanicalName: "Spathiphyllum",
|
||||
confidence: 1.0,
|
||||
careInfo: { waterIntervalDays: 5, light: "Partial Shade", temp: "18-25°C" },
|
||||
description: "The Peace Lily shows when it needs water by drooping its leaves. It blooms beautifully white even in lower light.",
|
||||
imageUri: "https://images.unsplash.com/photo-1610496185876-06835a64627d?q=80&w=400&auto=format&fit=crop",
|
||||
categories: ["flowering", "low_light", "air_purifier"]
|
||||
},
|
||||
{
|
||||
name: "Calathea",
|
||||
botanicalName: "Calathea",
|
||||
confidence: 1.0,
|
||||
careInfo: { waterIntervalDays: 4, light: "Partial Shade", temp: "18-24°C" },
|
||||
description: "Calatheas are known for their patterned leaves that fold up at night. They require high humidity.",
|
||||
imageUri: "https://images.unsplash.com/photo-1600869680373-b82fa72f8823?q=80&w=400&auto=format&fit=crop",
|
||||
categories: ["patterned", "pet_friendly", "high_humidity"]
|
||||
}
|
||||
],
|
||||
es: [
|
||||
{
|
||||
name: "Costilla de Adán",
|
||||
botanicalName: "Monstera deliciosa",
|
||||
confidence: 1.0,
|
||||
careInfo: { waterIntervalDays: 7, light: "Sombra Parcial", temp: "18-24°C" },
|
||||
description: "La Monstera Deliciosa es conocida por sus grandes hojas divididas. Es fácil de cuidar y purifica el aire interior de manera efectiva.",
|
||||
imageUri: "https://images.unsplash.com/photo-1614594975525-e45190c55d0b?q=80&w=400&auto=format&fit=crop",
|
||||
categories: ["easy", "low_light", "air_purifier"]
|
||||
},
|
||||
{
|
||||
name: "Ficus Benjamina",
|
||||
botanicalName: "Ficus benjamina",
|
||||
confidence: 1.0,
|
||||
careInfo: { waterIntervalDays: 5, light: "Brillante", temp: "16-24°C" },
|
||||
description: "El Ficus Benjamina es una planta de interior popular con ramas elegantes y caídas y hojas brillantes. Es sensible a los cambios de ubicación.",
|
||||
imageUri: "https://images.unsplash.com/photo-1509223197845-458d87318791?q=80&w=400&auto=format&fit=crop",
|
||||
categories: ["tree", "bright_light"]
|
||||
},
|
||||
{
|
||||
name: "Rosa de Alabastro",
|
||||
botanicalName: "Echeveria elegans",
|
||||
confidence: 1.0,
|
||||
careInfo: { waterIntervalDays: 14, light: "Soleado", temp: "18-28°C" },
|
||||
description: "Esta suculenta forma hermosas rosetas y almacena agua en sus hojas gruesas. Es ideal para alféizares soleados y requiere muy poco mantenimiento.",
|
||||
imageUri: "https://images.unsplash.com/photo-1520302669765-66b37fb890d7?q=80&w=400&auto=format&fit=crop",
|
||||
categories: ["succulent", "easy", "small"]
|
||||
},
|
||||
{
|
||||
name: "Lengua de Suegra",
|
||||
botanicalName: "Sansevieria trifasciata",
|
||||
confidence: 1.0,
|
||||
careInfo: { waterIntervalDays: 21, light: "Sombra a Sol", temp: "15-30°C" },
|
||||
description: "La Sansevieria es casi indestructible. Tolera poca luz y sequía, y es uno de los mejores purificadores de aire para el dormitorio.",
|
||||
imageUri: "https://images.unsplash.com/photo-1620127530668-37c2275ae158?q=80&w=400&auto=format&fit=crop",
|
||||
categories: ["succulent", "easy", "low_light", "air_purifier"]
|
||||
},
|
||||
{
|
||||
name: "Aloe Vera",
|
||||
botanicalName: "Aloe vera",
|
||||
confidence: 1.0,
|
||||
careInfo: { waterIntervalDays: 14, light: "Soleado", temp: "20-30°C" },
|
||||
description: "Una planta medicinal cuyo gel ayuda con las quemaduras solares. Requiere un lugar muy luminoso y poca agua.",
|
||||
imageUri: "https://images.unsplash.com/photo-1567689265771-828557d4766c?q=80&w=400&auto=format&fit=crop",
|
||||
categories: ["succulent", "medicinal", "sun"]
|
||||
},
|
||||
{
|
||||
name: "Cinta",
|
||||
botanicalName: "Chlorophytum comosum",
|
||||
confidence: 1.0,
|
||||
careInfo: { waterIntervalDays: 7, light: "Sombra Parcial", temp: "15-23°C" },
|
||||
description: "La Cinta es extremadamente adaptable y forma retoños rápidamente. Perdona los errores de riego y es ideal para principiantes.",
|
||||
imageUri: "https://images.unsplash.com/photo-1616766649725-b44c698308eb?q=80&w=400&auto=format&fit=crop",
|
||||
categories: ["easy", "hanging", "pet_friendly"]
|
||||
},
|
||||
{
|
||||
name: "Cuna de Moisés",
|
||||
botanicalName: "Spathiphyllum",
|
||||
confidence: 1.0,
|
||||
careInfo: { waterIntervalDays: 5, light: "Sombra Parcial", temp: "18-25°C" },
|
||||
description: "La Cuna de Moisés muestra cuándo necesita agua al dejar caer sus hojas. Florece hermosamente en blanco incluso con poca luz.",
|
||||
imageUri: "https://images.unsplash.com/photo-1610496185876-06835a64627d?q=80&w=400&auto=format&fit=crop",
|
||||
categories: ["flowering", "low_light", "air_purifier"]
|
||||
},
|
||||
{
|
||||
name: "Calathea",
|
||||
botanicalName: "Calathea",
|
||||
confidence: 1.0,
|
||||
careInfo: { waterIntervalDays: 4, light: "Sombra Parcial", temp: "18-24°C" },
|
||||
description: "Las Calatheas son conocidas por sus hojas estampadas que se pliegan por la noche. Requieren alta humedad.",
|
||||
imageUri: "https://images.unsplash.com/photo-1600869680373-b82fa72f8823?q=80&w=400&auto=format&fit=crop",
|
||||
categories: ["patterned", "pet_friendly", "high_humidity"]
|
||||
}
|
||||
]
|
||||
interface SearchOptions {
|
||||
category?: string | null;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export type SemanticSearchStatus = 'success' | 'timeout' | 'provider_error' | 'no_results' | 'insufficient_credits';
|
||||
|
||||
export interface SemanticSearchResult {
|
||||
status: SemanticSearchStatus;
|
||||
results: DatabaseEntry[];
|
||||
}
|
||||
|
||||
const DEFAULT_SEARCH_LIMIT = 500;
|
||||
|
||||
const hasConfiguredPlantBackend = (): boolean => Boolean(
|
||||
String(
|
||||
process.env.EXPO_PUBLIC_API_URL
|
||||
|| process.env.EXPO_PUBLIC_BACKEND_URL
|
||||
|| process.env.EXPO_PUBLIC_PAYMENT_SERVER_URL
|
||||
|| '',
|
||||
).trim(),
|
||||
);
|
||||
|
||||
const normalizeImageStatus = (status?: string, imageUri?: string): 'ok' | 'missing' | 'invalid' => {
|
||||
if (status === 'ok' || status === 'missing' || status === 'invalid') return status;
|
||||
const resolved = tryResolveImageUri(imageUri || '');
|
||||
if (resolved) return 'ok';
|
||||
return imageUri && imageUri.trim() ? 'invalid' : 'missing';
|
||||
};
|
||||
|
||||
const mapBackendEntry = (entry: Partial<BackendDatabaseEntry> & { imageUri?: string | null }): DatabaseEntry => {
|
||||
const imageStatus = normalizeImageStatus(entry.imageStatus, entry.imageUri || undefined);
|
||||
const strictImageUri = tryResolveImageUri(entry.imageUri || undefined);
|
||||
const imageUri = imageStatus === 'ok'
|
||||
? (strictImageUri || resolveImageUri(entry.imageUri))
|
||||
: (typeof entry.imageUri === 'string' ? entry.imageUri.trim() : '');
|
||||
|
||||
return {
|
||||
name: entry.name || '',
|
||||
botanicalName: entry.botanicalName || '',
|
||||
confidence: typeof entry.confidence === 'number' ? entry.confidence : 0,
|
||||
description: entry.description || '',
|
||||
careInfo: entry.careInfo || { waterIntervalDays: 7, light: 'Unknown', temp: 'Unknown' },
|
||||
imageUri,
|
||||
imageStatus,
|
||||
categories: Array.isArray(entry.categories) ? entry.categories : [],
|
||||
};
|
||||
};
|
||||
|
||||
export const PlantDatabaseService = {
|
||||
getAllPlants: (lang: Language): DatabaseEntry[] => {
|
||||
return PLANT_DATABASE[lang] || PLANT_DATABASE['de'];
|
||||
async getAllPlants(lang: Language): Promise<DatabaseEntry[]> {
|
||||
if (!hasConfiguredPlantBackend()) {
|
||||
return getMockCatalog(lang).map(mapBackendEntry);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${getConfiguredApiBaseUrl()}/plants?lang=${lang}`);
|
||||
if (!response.ok) throw new Error('Network response was not ok');
|
||||
const data = await response.json();
|
||||
if (!Array.isArray(data)) return [];
|
||||
return data.map(mapBackendEntry);
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch plants', e);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
searchPlants: (query: string, lang: Language): DatabaseEntry[] => {
|
||||
const plants = PLANT_DATABASE[lang] || PLANT_DATABASE['de'];
|
||||
const lowerQuery = query.toLowerCase();
|
||||
|
||||
return plants.filter(p =>
|
||||
p.name.toLowerCase().includes(lowerQuery) ||
|
||||
p.botanicalName.toLowerCase().includes(lowerQuery)
|
||||
);
|
||||
async searchPlants(query: string, lang: Language, options: SearchOptions = {}): Promise<DatabaseEntry[]> {
|
||||
const { category, limit = DEFAULT_SEARCH_LIMIT } = options;
|
||||
|
||||
if (!hasConfiguredPlantBackend()) {
|
||||
let results = searchMockCatalog(query || '', lang, limit);
|
||||
if (category) {
|
||||
results = results.filter(r => r.categories.includes(category));
|
||||
}
|
||||
return results.map(mapBackendEntry);
|
||||
}
|
||||
|
||||
const url = new URL(`${getConfiguredApiBaseUrl()}/plants`);
|
||||
url.searchParams.append('lang', lang);
|
||||
if (query) url.searchParams.append('q', query);
|
||||
if (category) url.searchParams.append('category', category);
|
||||
if (limit) url.searchParams.append('limit', limit.toString());
|
||||
|
||||
try {
|
||||
const response = await fetch(url.toString());
|
||||
if (!response.ok) throw new Error('Network response was not ok');
|
||||
const data = await response.json();
|
||||
if (!Array.isArray(data)) return [];
|
||||
return data.map(mapBackendEntry);
|
||||
} catch (e) {
|
||||
console.error('Failed to search plants', e);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
async semanticSearchDetailed(query: string, lang: Language): Promise<SemanticSearchResult> {
|
||||
const idempotencyKey = createIdempotencyKey(`semantic-${query}-${lang}`);
|
||||
try {
|
||||
const response = await backendApiClient.semanticSearch({
|
||||
query,
|
||||
language: lang,
|
||||
idempotencyKey,
|
||||
});
|
||||
|
||||
const results: DatabaseEntry[] = (response.results as BackendDatabaseEntry[]).map(mapBackendEntry);
|
||||
return { status: results.length > 0 ? 'success' : 'no_results', results };
|
||||
} catch (error) {
|
||||
if (isBackendApiError(error)) {
|
||||
if (error.code === 'INSUFFICIENT_CREDITS') {
|
||||
return { status: 'insufficient_credits', results: [] };
|
||||
}
|
||||
return { status: 'provider_error', results: [] };
|
||||
}
|
||||
return { status: 'timeout', results: [] };
|
||||
}
|
||||
},
|
||||
|
||||
getRandomPlant: (lang: Language): DatabaseEntry => {
|
||||
const plants = PLANT_DATABASE[lang] || PLANT_DATABASE['de'];
|
||||
return plants[Math.floor(Math.random() * plants.length)];
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,85 +1,24 @@
|
||||
|
||||
import { IdentificationResult, Language } from '../types';
|
||||
import { GoogleGenAI, Type } from "@google/genai";
|
||||
import { PlantDatabaseService } from './plantDatabaseService';
|
||||
import { backendApiClient } from './backend/backendApiClient';
|
||||
import { createIdempotencyKey } from '../utils/idempotency';
|
||||
|
||||
// Helper to convert base64 data URL to raw base64 string
|
||||
const cleanBase64 = (dataUrl: string) => {
|
||||
return dataUrl.split(',')[1];
|
||||
};
|
||||
interface IdentifyOptions {
|
||||
idempotencyKey?: string;
|
||||
}
|
||||
|
||||
export const PlantRecognitionService = {
|
||||
identify: async (imageUri: string, lang: Language = 'de'): Promise<IdentificationResult> => {
|
||||
// 1. Check if we have an API Key. If so, use Gemini
|
||||
if (process.env.API_KEY) {
|
||||
try {
|
||||
const ai = new GoogleGenAI({ apiKey: process.env.API_KEY });
|
||||
|
||||
// Dynamic prompt based on language
|
||||
const promptLang = lang === 'de' ? 'German' : lang === 'es' ? 'Spanish' : 'English';
|
||||
const promptText = `Identify this plant. Provide the common ${promptLang} name, the botanical name, a description (2 sentences) in ${promptLang}, an estimated confidence (0-1), and care info (water interval in days, light in ${promptLang}, temp). Response must be JSON.`;
|
||||
|
||||
const response = await ai.models.generateContent({
|
||||
model: 'gemini-3-pro-preview',
|
||||
contents: {
|
||||
parts: [
|
||||
{
|
||||
inlineData: {
|
||||
mimeType: 'image/jpeg',
|
||||
data: cleanBase64(imageUri),
|
||||
},
|
||||
},
|
||||
{
|
||||
text: promptText
|
||||
}
|
||||
],
|
||||
},
|
||||
config: {
|
||||
responseMimeType: "application/json",
|
||||
responseSchema: {
|
||||
type: Type.OBJECT,
|
||||
properties: {
|
||||
name: { type: Type.STRING },
|
||||
botanicalName: { type: Type.STRING },
|
||||
description: { type: Type.STRING },
|
||||
confidence: { type: Type.NUMBER },
|
||||
careInfo: {
|
||||
type: Type.OBJECT,
|
||||
properties: {
|
||||
waterIntervalDays: { type: Type.NUMBER },
|
||||
light: { type: Type.STRING },
|
||||
temp: { type: Type.STRING },
|
||||
},
|
||||
required: ["waterIntervalDays", "light", "temp"]
|
||||
}
|
||||
},
|
||||
required: ["name", "botanicalName", "confidence", "careInfo", "description"]
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (response.text) {
|
||||
return JSON.parse(response.text) as IdentificationResult;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Gemini analysis failed, falling back to mock.", error);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Mock Process (Fallback)
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
// Use the centralized database service for consistent mock results
|
||||
const randomResult = PlantDatabaseService.getRandomPlant(lang);
|
||||
|
||||
// Create a clean IdentificationResult without categories/imageUri if we want to strictly adhere to that type,
|
||||
// though Typescript allows extra props.
|
||||
// We simulate that the recognition might not be 100% like the db
|
||||
resolve({
|
||||
...randomResult,
|
||||
confidence: 0.85 + Math.random() * 0.14
|
||||
});
|
||||
}, 2500);
|
||||
identify: async (
|
||||
imageUri: string,
|
||||
lang: Language = 'de',
|
||||
options: IdentifyOptions = {},
|
||||
): Promise<IdentificationResult> => {
|
||||
const idempotencyKey = options.idempotencyKey || createIdempotencyKey('scan');
|
||||
const response = await backendApiClient.scanPlant({
|
||||
idempotencyKey,
|
||||
imageUri,
|
||||
language: lang,
|
||||
});
|
||||
}
|
||||
|
||||
return response.result;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,12 +1,36 @@
|
||||
import { Plant, Language } from '../types';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { Plant, Language, AppearanceMode, ColorPalette } from '../types';
|
||||
|
||||
const STORAGE_KEY = 'greenlens_plants';
|
||||
const LANG_KEY = 'greenlens_language';
|
||||
const ONBOARDING_KEY = 'greenlens_onboarding_complete';
|
||||
const APPEARANCE_MODE_KEY = 'greenlens_appearance_mode';
|
||||
const COLOR_PALETTE_KEY = 'greenlens_color_palette';
|
||||
const PROFILE_IMAGE_KEY = 'greenlens_profile_image';
|
||||
const PROFILE_NAME_KEY = 'greenlens_profile_name';
|
||||
const LEXICON_SEARCH_HISTORY_KEY = 'greenlens_lexicon_search_history';
|
||||
const LEXICON_SEARCH_HISTORY_LIMIT = 10;
|
||||
const DEFAULT_PROFILE_NAME = 'GreenLens User';
|
||||
|
||||
const isAppearanceMode = (value: string | null): value is AppearanceMode =>
|
||||
value === 'system' || value === 'light' || value === 'dark';
|
||||
|
||||
const isColorPalette = (value: string | null): value is ColorPalette =>
|
||||
value === 'forest' || value === 'ocean' || value === 'sunset' || value === 'mono';
|
||||
|
||||
const normalizeSearchQuery = (value: string): string => {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.trim()
|
||||
.replace(/\s+/g, ' ');
|
||||
};
|
||||
|
||||
export const StorageService = {
|
||||
getPlants: (): Plant[] => {
|
||||
getPlants: async (): Promise<Plant[]> => {
|
||||
try {
|
||||
const json = localStorage.getItem(STORAGE_KEY);
|
||||
const json = await AsyncStorage.getItem(STORAGE_KEY);
|
||||
return json ? JSON.parse(json) : [];
|
||||
} catch (e) {
|
||||
console.error('Failed to load plants', e);
|
||||
@@ -14,37 +38,142 @@ export const StorageService = {
|
||||
}
|
||||
},
|
||||
|
||||
savePlant: (plant: Plant): void => {
|
||||
const plants = StorageService.getPlants();
|
||||
savePlant: async (plant: Plant): Promise<void> => {
|
||||
const plants = await StorageService.getPlants();
|
||||
const updatedPlants = [plant, ...plants];
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(updatedPlants));
|
||||
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(updatedPlants));
|
||||
},
|
||||
|
||||
deletePlant: (id: string): void => {
|
||||
const plants = StorageService.getPlants();
|
||||
deletePlant: async (id: string): Promise<void> => {
|
||||
const plants = await StorageService.getPlants();
|
||||
const updatedPlants = plants.filter(p => p.id !== id);
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(updatedPlants));
|
||||
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(updatedPlants));
|
||||
},
|
||||
|
||||
updatePlant: (updatedPlant: Plant): void => {
|
||||
const plants = StorageService.getPlants();
|
||||
updatePlant: async (updatedPlant: Plant): Promise<void> => {
|
||||
const plants = await StorageService.getPlants();
|
||||
const index = plants.findIndex(p => p.id === updatedPlant.id);
|
||||
if (index !== -1) {
|
||||
plants[index] = updatedPlant;
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(plants));
|
||||
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(plants));
|
||||
}
|
||||
},
|
||||
|
||||
getLanguage: (): Language => {
|
||||
getLanguage: async (): Promise<Language> => {
|
||||
try {
|
||||
const lang = localStorage.getItem(LANG_KEY);
|
||||
return (lang as Language) || 'de';
|
||||
const lang = await AsyncStorage.getItem(LANG_KEY);
|
||||
return (lang as Language) || 'en';
|
||||
} catch (e) {
|
||||
return 'de';
|
||||
return 'en';
|
||||
}
|
||||
},
|
||||
|
||||
saveLanguage: (lang: Language): void => {
|
||||
localStorage.setItem(LANG_KEY, lang);
|
||||
}
|
||||
};
|
||||
saveLanguage: async (lang: Language): Promise<void> => {
|
||||
await AsyncStorage.setItem(LANG_KEY, lang);
|
||||
},
|
||||
|
||||
getAppearanceMode: async (): Promise<AppearanceMode> => {
|
||||
try {
|
||||
const mode = await AsyncStorage.getItem(APPEARANCE_MODE_KEY);
|
||||
return isAppearanceMode(mode) ? mode : 'system';
|
||||
} catch (e) {
|
||||
return 'system';
|
||||
}
|
||||
},
|
||||
|
||||
saveAppearanceMode: async (mode: AppearanceMode): Promise<void> => {
|
||||
await AsyncStorage.setItem(APPEARANCE_MODE_KEY, mode);
|
||||
},
|
||||
|
||||
getColorPalette: async (): Promise<ColorPalette> => {
|
||||
try {
|
||||
const palette = await AsyncStorage.getItem(COLOR_PALETTE_KEY);
|
||||
return isColorPalette(palette) ? palette : 'forest';
|
||||
} catch (e) {
|
||||
return 'forest';
|
||||
}
|
||||
},
|
||||
|
||||
saveColorPalette: async (palette: ColorPalette): Promise<void> => {
|
||||
await AsyncStorage.setItem(COLOR_PALETTE_KEY, palette);
|
||||
},
|
||||
|
||||
getProfileImage: async (): Promise<string | null> => {
|
||||
try {
|
||||
const imageUri = await AsyncStorage.getItem(PROFILE_IMAGE_KEY);
|
||||
return imageUri || null;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
saveProfileImage: async (imageUri: string | null): Promise<void> => {
|
||||
if (!imageUri) {
|
||||
await AsyncStorage.removeItem(PROFILE_IMAGE_KEY);
|
||||
return;
|
||||
}
|
||||
await AsyncStorage.setItem(PROFILE_IMAGE_KEY, imageUri);
|
||||
},
|
||||
|
||||
getProfileName: async (): Promise<string> => {
|
||||
try {
|
||||
const profileName = await AsyncStorage.getItem(PROFILE_NAME_KEY);
|
||||
const normalized = profileName?.trim();
|
||||
return normalized || DEFAULT_PROFILE_NAME;
|
||||
} catch (e) {
|
||||
return DEFAULT_PROFILE_NAME;
|
||||
}
|
||||
},
|
||||
|
||||
saveProfileName: async (name: string): Promise<void> => {
|
||||
const normalized = name.trim();
|
||||
await AsyncStorage.setItem(PROFILE_NAME_KEY, normalized || DEFAULT_PROFILE_NAME);
|
||||
},
|
||||
|
||||
getOnboardingComplete: async (): Promise<boolean> => {
|
||||
try {
|
||||
const value = await AsyncStorage.getItem(ONBOARDING_KEY);
|
||||
return value === 'true';
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
setOnboardingComplete: async (complete: boolean): Promise<void> => {
|
||||
await AsyncStorage.setItem(ONBOARDING_KEY, complete ? 'true' : 'false');
|
||||
},
|
||||
|
||||
getLexiconSearchHistory: async (): Promise<string[]> => {
|
||||
try {
|
||||
const value = await AsyncStorage.getItem(LEXICON_SEARCH_HISTORY_KEY);
|
||||
if (!value) return [];
|
||||
|
||||
const parsed = JSON.parse(value);
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
|
||||
return parsed.filter((item): item is string => typeof item === 'string');
|
||||
} catch (e) {
|
||||
console.error('Failed to load lexicon search history', e);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
saveLexiconSearchQuery: async (query: string): Promise<void> => {
|
||||
const trimmed = query.trim();
|
||||
if (!trimmed) return;
|
||||
|
||||
const history = await StorageService.getLexiconSearchHistory();
|
||||
const normalized = normalizeSearchQuery(trimmed);
|
||||
|
||||
const deduped = history.filter(
|
||||
item => normalizeSearchQuery(item) !== normalized
|
||||
);
|
||||
|
||||
const updated = [trimmed, ...deduped].slice(0, LEXICON_SEARCH_HISTORY_LIMIT);
|
||||
await AsyncStorage.setItem(LEXICON_SEARCH_HISTORY_KEY, JSON.stringify(updated));
|
||||
},
|
||||
|
||||
clearLexiconSearchHistory: async (): Promise<void> => {
|
||||
await AsyncStorage.removeItem(LEXICON_SEARCH_HISTORY_KEY);
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user