This commit is contained in:
2026-03-29 10:26:38 -05:00
parent 05d4f6e78b
commit b1c99893a6
1628 changed files with 67782 additions and 60143 deletions

View File

@@ -1,137 +1,137 @@
import * as SecureStore from 'expo-secure-store';
import { AuthDb } from './database';
const SESSION_KEY = 'greenlens_session_v3';
const BACKEND_URL = (
process.env.EXPO_PUBLIC_BACKEND_URL ||
process.env.EXPO_PUBLIC_PAYMENT_SERVER_URL ||
''
).trim();
export interface AuthSession {
userId: number; // local SQLite id (for plants/settings queries)
serverUserId: string; // server-side user id (in JWT)
email: string;
name: string;
token: string; // JWT from server
loggedInAt: string;
}
// ─── Internal helpers ──────────────────────────────────────────────────────
const clearStoredSession = async (): Promise<void> => {
await SecureStore.deleteItemAsync(SESSION_KEY);
};
const authPost = async (path: string, body: object): Promise<{ userId: string; email: string; name: string; token: string }> => {
const hasBackendUrl = Boolean(BACKEND_URL);
const url = hasBackendUrl ? `${BACKEND_URL}${path}` : path;
let response: Response;
try {
response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
} catch (e) {
if (!hasBackendUrl) {
throw new Error('BACKEND_URL_MISSING');
}
throw new Error('NETWORK_ERROR');
}
const data = await response.json().catch(() => ({}));
if (!response.ok) {
const code = (data as any).code || 'AUTH_ERROR';
const msg = (data as any).message || '';
console.warn(`[Auth] ${path} failed:`, response.status, code, msg);
throw new Error(code);
}
return data as any;
};
const buildSession = (data: { userId: string; email: string; name: string; token: string }): AuthSession => {
const localUser = AuthDb.ensureLocalUser(data.email, data.name);
return {
userId: localUser.id,
serverUserId: data.userId,
email: data.email,
name: data.name,
token: data.token,
loggedInAt: new Date().toISOString(),
};
};
// ─── AuthService ───────────────────────────────────────────────────────────
export const AuthService = {
async getSession(): Promise<AuthSession | null> {
try {
const raw = await SecureStore.getItemAsync(SESSION_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as Partial<AuthSession>;
if (!parsed.token || !parsed.serverUserId || !parsed.userId) {
await clearStoredSession();
return null;
}
return parsed as AuthSession;
} catch {
await clearStoredSession();
return null;
}
},
async signUp(email: string, name: string, password: string): Promise<AuthSession> {
const data = await authPost('/auth/signup', { email, name, password });
const session = buildSession(data);
await SecureStore.setItemAsync(SESSION_KEY, JSON.stringify(session));
return session;
},
async login(email: string, password: string): Promise<AuthSession> {
const data = await authPost('/auth/login', { email, password });
const session = buildSession(data);
await SecureStore.setItemAsync(SESSION_KEY, JSON.stringify(session));
return session;
},
async logout(): Promise<void> {
await clearStoredSession();
},
async updateSessionName(name: string): Promise<void> {
const session = await this.getSession();
if (!session) return;
await SecureStore.setItemAsync(SESSION_KEY, JSON.stringify({ ...session, name }));
},
async validateWithServer(): Promise<'valid' | 'invalid' | 'unreachable'> {
const session = await this.getSession();
if (!session) return 'invalid';
if (!BACKEND_URL) return 'unreachable';
try {
const response = await fetch(`${BACKEND_URL}/v1/billing/summary`, {
headers: { Authorization: `Bearer ${session.token}` },
});
if (response.status === 401 || response.status === 403) return 'invalid';
return 'valid';
} catch {
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.
},
};
import * as SecureStore from 'expo-secure-store';
import { AuthDb } from './database';
const SESSION_KEY = 'greenlens_session_v3';
const BACKEND_URL = (
process.env.EXPO_PUBLIC_BACKEND_URL ||
process.env.EXPO_PUBLIC_PAYMENT_SERVER_URL ||
''
).trim();
export interface AuthSession {
userId: number; // local SQLite id (for plants/settings queries)
serverUserId: string; // server-side user id (in JWT)
email: string;
name: string;
token: string; // JWT from server
loggedInAt: string;
}
// ─── Internal helpers ──────────────────────────────────────────────────────
const clearStoredSession = async (): Promise<void> => {
await SecureStore.deleteItemAsync(SESSION_KEY);
};
const authPost = async (path: string, body: object): Promise<{ userId: string; email: string; name: string; token: string }> => {
const hasBackendUrl = Boolean(BACKEND_URL);
const url = hasBackendUrl ? `${BACKEND_URL}${path}` : path;
let response: Response;
try {
response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
} catch (e) {
if (!hasBackendUrl) {
throw new Error('BACKEND_URL_MISSING');
}
throw new Error('NETWORK_ERROR');
}
const data = await response.json().catch(() => ({}));
if (!response.ok) {
const code = (data as any).code || 'AUTH_ERROR';
const msg = (data as any).message || '';
console.warn(`[Auth] ${path} failed:`, response.status, code, msg);
throw new Error(code);
}
return data as any;
};
const buildSession = (data: { userId: string; email: string; name: string; token: string }): AuthSession => {
const localUser = AuthDb.ensureLocalUser(data.email, data.name);
return {
userId: localUser.id,
serverUserId: data.userId,
email: data.email,
name: data.name,
token: data.token,
loggedInAt: new Date().toISOString(),
};
};
// ─── AuthService ───────────────────────────────────────────────────────────
export const AuthService = {
async getSession(): Promise<AuthSession | null> {
try {
const raw = await SecureStore.getItemAsync(SESSION_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as Partial<AuthSession>;
if (!parsed.token || !parsed.serverUserId || !parsed.userId) {
await clearStoredSession();
return null;
}
return parsed as AuthSession;
} catch {
await clearStoredSession();
return null;
}
},
async signUp(email: string, name: string, password: string): Promise<AuthSession> {
const data = await authPost('/auth/signup', { email, name, password });
const session = buildSession(data);
await SecureStore.setItemAsync(SESSION_KEY, JSON.stringify(session));
return session;
},
async login(email: string, password: string): Promise<AuthSession> {
const data = await authPost('/auth/login', { email, password });
const session = buildSession(data);
await SecureStore.setItemAsync(SESSION_KEY, JSON.stringify(session));
return session;
},
async logout(): Promise<void> {
await clearStoredSession();
},
async updateSessionName(name: string): Promise<void> {
const session = await this.getSession();
if (!session) return;
await SecureStore.setItemAsync(SESSION_KEY, JSON.stringify({ ...session, name }));
},
async validateWithServer(): Promise<'valid' | 'invalid' | 'unreachable'> {
const session = await this.getSession();
if (!session) return 'invalid';
if (!BACKEND_URL) return 'unreachable';
try {
const response = await fetch(`${BACKEND_URL}/v1/billing/summary`, {
headers: { Authorization: `Bearer ${session.token}` },
});
if (response.status === 401 || response.status === 403) return 'invalid';
return 'valid';
} catch {
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.
},
};

View File

@@ -4,274 +4,303 @@ import {
BillingSummary,
HealthCheckResponse,
PurchaseProductId,
RevenueCatCustomerInfo,
ScanPlantResponse,
SemanticSearchResponse,
ServiceHealthResponse,
SimulatedWebhookEvent,
SimulatePurchaseResponse,
SimulateWebhookResponse,
SyncRevenueCatStateResponse,
} 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,
});
},
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('NETWORK_ERROR', 'No network connection.', 0, {
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);
}
const token = await getAuthToken();
if (!BACKEND_BASE_URL) {
return mockBackendService.getBillingSummary(token);
}
return makeRequest<BillingSummary>('/v1/billing/summary', {
method: 'GET',
token,
});
},
syncRevenueCatState: async (params: {
customerInfo: RevenueCatCustomerInfo;
}): Promise<SyncRevenueCatStateResponse> => {
const token = await getAuthToken();
if (!BACKEND_BASE_URL) {
return mockBackendService.syncRevenueCatState({
userId: token,
customerInfo: params.customerInfo,
});
}
return makeRequest<SyncRevenueCatStateResponse>('/v1/billing/sync-revenuecat', {
method: 'POST',
token,
body: {
customerInfo: params.customerInfo,
},
});
},
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';
};
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';
};
export const isNetworkError = (error: unknown): boolean => {
return (
error instanceof BackendApiError &&
(error.code === 'NETWORK_ERROR' || error.code === 'TIMEOUT')
);
};

View File

@@ -1,156 +1,186 @@
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;
}
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 RevenueCatEntitlementInfo {
productIdentifier?: string;
expirationDate?: string | null;
expiresDate?: string | null;
}
export interface ScanPlantResponse {
result: IdentificationResult;
creditsCharged: number;
modelPath: string[];
modelUsed?: string | null;
modelFallbackCount?: number;
billing: BillingSummary;
export interface RevenueCatNonSubscriptionTransaction {
productIdentifier?: string;
transactionIdentifier?: string;
transactionId?: string;
purchaseDate?: string | null;
}
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 RevenueCatCustomerInfo {
appUserId?: string | null;
originalAppUserId?: string | null;
entitlements: {
active: Record<string, RevenueCatEntitlementInfo>;
};
nonSubscriptions?: Record<string, RevenueCatNonSubscriptionTransaction[]>;
allPurchasedProductIdentifiers?: string[];
latestExpirationDate?: string | null;
}
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 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 interface SyncRevenueCatStateResponse {
billing: BillingSummary;
syncedAt: string;
}
export const isBackendApiError = (error: unknown): error is BackendApiError => {
return error instanceof BackendApiError;
};
export type BackendErrorCode =
| 'INSUFFICIENT_CREDITS'
| 'UNAUTHORIZED'
| 'TIMEOUT'
| 'NETWORK_ERROR'
| '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;
};

File diff suppressed because it is too large Load Diff

View File

@@ -1,299 +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);
};
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);
};

File diff suppressed because it is too large Load Diff

View File

@@ -1,17 +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';
};
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';
};

View File

@@ -1,344 +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]);
},
};
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]);
},
};

View File

@@ -1,113 +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);
}
},
};
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);
}
},
};

View File

@@ -1,43 +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}`);
}
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}`);
}

View File

@@ -1,132 +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';
export interface DatabaseEntry extends IdentificationResult {
imageUri: string;
imageStatus?: 'ok' | 'missing' | 'invalid';
categories: string[];
}
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 = {
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 [];
}
},
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: [] };
}
},
};
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';
export interface DatabaseEntry extends IdentificationResult {
imageUri: string;
imageStatus?: 'ok' | 'missing' | 'invalid';
categories: string[];
}
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 = {
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 [];
}
},
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: [] };
}
},
};

View File

@@ -1,24 +1,24 @@
import { IdentificationResult, Language } from '../types';
import { backendApiClient } from './backend/backendApiClient';
import { createIdempotencyKey } from '../utils/idempotency';
interface IdentifyOptions {
idempotencyKey?: string;
}
export const PlantRecognitionService = {
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;
},
};
import { IdentificationResult, Language } from '../types';
import { backendApiClient } from './backend/backendApiClient';
import { createIdempotencyKey } from '../utils/idempotency';
interface IdentifyOptions {
idempotencyKey?: string;
}
export const PlantRecognitionService = {
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;
},
};

View File

@@ -1,179 +1,179 @@
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: async (): Promise<Plant[]> => {
try {
const json = await AsyncStorage.getItem(STORAGE_KEY);
return json ? JSON.parse(json) : [];
} catch (e) {
console.error('Failed to load plants', e);
return [];
}
},
savePlant: async (plant: Plant): Promise<void> => {
const plants = await StorageService.getPlants();
const updatedPlants = [plant, ...plants];
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(updatedPlants));
},
deletePlant: async (id: string): Promise<void> => {
const plants = await StorageService.getPlants();
const updatedPlants = plants.filter(p => p.id !== id);
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(updatedPlants));
},
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;
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(plants));
}
},
getLanguage: async (): Promise<Language> => {
try {
const lang = await AsyncStorage.getItem(LANG_KEY);
return (lang as Language) || 'en';
} catch (e) {
return 'en';
}
},
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);
},
};
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: async (): Promise<Plant[]> => {
try {
const json = await AsyncStorage.getItem(STORAGE_KEY);
return json ? JSON.parse(json) : [];
} catch (e) {
console.error('Failed to load plants', e);
return [];
}
},
savePlant: async (plant: Plant): Promise<void> => {
const plants = await StorageService.getPlants();
const updatedPlants = [plant, ...plants];
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(updatedPlants));
},
deletePlant: async (id: string): Promise<void> => {
const plants = await StorageService.getPlants();
const updatedPlants = plants.filter(p => p.id !== id);
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(updatedPlants));
},
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;
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(plants));
}
},
getLanguage: async (): Promise<Language> => {
try {
const lang = await AsyncStorage.getItem(LANG_KEY);
return (lang as Language) || 'en';
} catch (e) {
return 'en';
}
},
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);
},
};