Launch
This commit is contained in:
@@ -1,476 +1,512 @@
|
||||
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
|
||||
import { useColorScheme, Appearance } from 'react-native';
|
||||
import {
|
||||
Plant,
|
||||
IdentificationResult,
|
||||
Language,
|
||||
AppearanceMode,
|
||||
AppColorScheme,
|
||||
ColorPalette,
|
||||
} from '../types';
|
||||
import { ImageCacheService } from '../services/imageCacheService';
|
||||
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
|
||||
import { useColorScheme, Appearance } from 'react-native';
|
||||
import { getLocales } from 'expo-localization';
|
||||
import {
|
||||
Plant,
|
||||
IdentificationResult,
|
||||
Language,
|
||||
AppearanceMode,
|
||||
AppColorScheme,
|
||||
ColorPalette,
|
||||
} from '../types';
|
||||
import { ImageCacheService } from '../services/imageCacheService';
|
||||
import { getTranslation } from '../utils/translations';
|
||||
import { backendApiClient } from '../services/backend/backendApiClient';
|
||||
import { BillingSummary, PurchaseProductId, SimulatedWebhookEvent } from '../services/backend/contracts';
|
||||
import { createIdempotencyKey } from '../utils/idempotency';
|
||||
import { AuthService, AuthSession } from '../services/authService';
|
||||
import { PlantsDb, SettingsDb, LexiconHistoryDb, AppMetaDb } from '../services/database';
|
||||
|
||||
interface AppState {
|
||||
session: AuthSession | null;
|
||||
plants: Plant[];
|
||||
language: Language;
|
||||
appearanceMode: AppearanceMode;
|
||||
colorPalette: ColorPalette;
|
||||
profileName: string;
|
||||
profileImageUri: string | null;
|
||||
billingSummary: BillingSummary | null;
|
||||
resolvedScheme: AppColorScheme;
|
||||
isDarkMode: boolean;
|
||||
isInitializing: boolean;
|
||||
isLoadingPlants: boolean;
|
||||
isLoadingBilling: boolean;
|
||||
t: ReturnType<typeof getTranslation>;
|
||||
// Actions
|
||||
setAppearanceMode: (mode: AppearanceMode) => void;
|
||||
setColorPalette: (palette: ColorPalette) => void;
|
||||
setProfileName: (name: string) => Promise<void>;
|
||||
setProfileImage: (imageUri: string | null) => Promise<void>;
|
||||
changeLanguage: (lang: Language) => void;
|
||||
savePlant: (result: IdentificationResult, imageUri: string, overrideSession?: AuthSession) => Promise<void>;
|
||||
deletePlant: (id: string) => Promise<void>;
|
||||
import { BillingSummary, PurchaseProductId, RevenueCatCustomerInfo, SimulatedWebhookEvent } from '../services/backend/contracts';
|
||||
import { createIdempotencyKey } from '../utils/idempotency';
|
||||
import { AuthService, AuthSession } from '../services/authService';
|
||||
import { PlantsDb, SettingsDb, LexiconHistoryDb, AppMetaDb } from '../services/database';
|
||||
|
||||
interface AppState {
|
||||
session: AuthSession | null;
|
||||
plants: Plant[];
|
||||
language: Language;
|
||||
appearanceMode: AppearanceMode;
|
||||
colorPalette: ColorPalette;
|
||||
profileName: string;
|
||||
profileImageUri: string | null;
|
||||
billingSummary: BillingSummary | null;
|
||||
resolvedScheme: AppColorScheme;
|
||||
isDarkMode: boolean;
|
||||
isInitializing: boolean;
|
||||
isLoadingPlants: boolean;
|
||||
isLoadingBilling: boolean;
|
||||
t: ReturnType<typeof getTranslation>;
|
||||
// Actions
|
||||
setAppearanceMode: (mode: AppearanceMode) => void;
|
||||
setColorPalette: (palette: ColorPalette) => void;
|
||||
setProfileName: (name: string) => Promise<void>;
|
||||
setProfileImage: (imageUri: string | null) => Promise<void>;
|
||||
changeLanguage: (lang: Language) => void;
|
||||
savePlant: (result: IdentificationResult, imageUri: string, overrideSession?: AuthSession) => Promise<void>;
|
||||
deletePlant: (id: string) => Promise<void>;
|
||||
updatePlant: (plant: Plant) => void;
|
||||
refreshPlants: () => void;
|
||||
refreshBillingSummary: () => Promise<void>;
|
||||
syncRevenueCatState: (customerInfo: RevenueCatCustomerInfo) => Promise<BillingSummary | null>;
|
||||
simulatePurchase: (productId: PurchaseProductId) => Promise<void>;
|
||||
simulateWebhookEvent: (event: SimulatedWebhookEvent, payload?: { credits?: number }) => Promise<void>;
|
||||
getLexiconSearchHistory: () => string[];
|
||||
saveLexiconSearchQuery: (query: string) => void;
|
||||
clearLexiconSearchHistory: () => void;
|
||||
hydrateSession: (session: AuthSession) => Promise<void>;
|
||||
signOut: () => Promise<void>;
|
||||
setPendingPlant: (result: IdentificationResult, imageUri: string) => void;
|
||||
getPendingPlant: () => { result: IdentificationResult; imageUri: string } | null;
|
||||
guestScanCount: number;
|
||||
incrementGuestScanCount: () => void;
|
||||
}
|
||||
simulateWebhookEvent: (event: SimulatedWebhookEvent, payload?: { credits?: number }) => Promise<void>;
|
||||
getLexiconSearchHistory: () => string[];
|
||||
saveLexiconSearchQuery: (query: string) => void;
|
||||
clearLexiconSearchHistory: () => void;
|
||||
hydrateSession: (session: AuthSession) => Promise<void>;
|
||||
signOut: () => Promise<void>;
|
||||
setPendingPlant: (result: IdentificationResult, imageUri: string) => void;
|
||||
getPendingPlant: () => { result: IdentificationResult; imageUri: string } | null;
|
||||
guestScanCount: number;
|
||||
incrementGuestScanCount: () => void;
|
||||
}
|
||||
|
||||
const AppContext = createContext<AppState | null>(null);
|
||||
|
||||
const toErrorMessage = (error: unknown): string => {
|
||||
if (error instanceof Error) return error.message;
|
||||
return String(error);
|
||||
};
|
||||
|
||||
export const useApp = () => {
|
||||
const ctx = useContext(AppContext);
|
||||
if (!ctx) throw new Error('useApp must be used within AppProvider');
|
||||
return ctx;
|
||||
};
|
||||
|
||||
const isAppearanceMode = (v: string): v is AppearanceMode =>
|
||||
v === 'system' || v === 'light' || v === 'dark';
|
||||
const isColorPalette = (v: string): v is ColorPalette =>
|
||||
v === 'forest' || v === 'ocean' || v === 'sunset' || v === 'mono';
|
||||
const isLanguage = (v: string): v is Language => v === 'de' || v === 'en' || v === 'es';
|
||||
|
||||
const getDeviceLanguage = (): Language => {
|
||||
try {
|
||||
const locale = getLocales()[0];
|
||||
const lang = (locale?.languageCode || locale?.languageTag || '').split('-')[0].toLowerCase();
|
||||
if (lang === 'de') return 'de';
|
||||
if (lang === 'es') return 'es';
|
||||
return 'en';
|
||||
} catch {
|
||||
return 'en';
|
||||
}
|
||||
};
|
||||
|
||||
export const AppProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [systemColorScheme, setSystemColorScheme] = useState(Appearance.getColorScheme());
|
||||
|
||||
useEffect(() => {
|
||||
const subscription = Appearance.addChangeListener(({ colorScheme }) => {
|
||||
setSystemColorScheme(colorScheme);
|
||||
});
|
||||
return () => subscription.remove();
|
||||
}, []);
|
||||
|
||||
const [session, setSession] = useState<AuthSession | null>(null);
|
||||
const [plants, setPlants] = useState<Plant[]>([]);
|
||||
const [language, setLanguage] = useState<Language>(getDeviceLanguage());
|
||||
const [appearanceMode, setAppearanceModeState] = useState<AppearanceMode>('system');
|
||||
const [colorPalette, setColorPaletteState] = useState<ColorPalette>('forest');
|
||||
const [profileName, setProfileNameState] = useState('');
|
||||
const [profileImageUri, setProfileImageUri] = useState<string | null>(null);
|
||||
const [pendingPlant, setPendingPlantState] = useState<{ result: IdentificationResult; imageUri: string } | null>(null);
|
||||
const [guestScanCount, setGuestScanCount] = useState(0);
|
||||
const [isInitializing, setIsInitializing] = useState(true);
|
||||
const [isLoadingPlants, setIsLoadingPlants] = useState(true);
|
||||
const [billingSummary, setBillingSummary] = useState<BillingSummary | null>(null);
|
||||
const [isLoadingBilling, setIsLoadingBilling] = useState(true);
|
||||
|
||||
const resolvedScheme: AppColorScheme =
|
||||
appearanceMode === 'system'
|
||||
? (systemColorScheme ?? 'dark') === 'dark' ? 'dark' : 'light'
|
||||
: appearanceMode;
|
||||
const isDarkMode = resolvedScheme === 'dark';
|
||||
const t = getTranslation(language);
|
||||
|
||||
const refreshBillingSummary = useCallback(async () => {
|
||||
setIsLoadingBilling(true);
|
||||
try {
|
||||
const summary = await backendApiClient.getBillingSummary();
|
||||
setBillingSummary(summary);
|
||||
} catch (e) {
|
||||
console.error('Failed to refresh billing summary', e);
|
||||
} finally {
|
||||
setIsLoadingBilling(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const resetStateForSignedOutUser = useCallback(() => {
|
||||
setSession(null);
|
||||
setPlants([]);
|
||||
setLanguage(getDeviceLanguage());
|
||||
setAppearanceModeState('system');
|
||||
setColorPaletteState('forest');
|
||||
setProfileNameState('');
|
||||
setProfileImageUri(null);
|
||||
setIsLoadingPlants(false);
|
||||
// Fetch guest billing summary instead of setting it to null
|
||||
refreshBillingSummary();
|
||||
}, [refreshBillingSummary]);
|
||||
|
||||
const refreshPlants = useCallback(() => {
|
||||
if (!session) return;
|
||||
try {
|
||||
setPlants(PlantsDb.getAll(session.userId));
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh plants list.', {
|
||||
userId: session.userId,
|
||||
error: toErrorMessage(error),
|
||||
});
|
||||
}
|
||||
}, [session]);
|
||||
|
||||
const savePlant = useCallback(async (result: IdentificationResult, imageUri: string, overrideSession?: AuthSession) => {
|
||||
const activeSession = overrideSession || session;
|
||||
if (!activeSession) {
|
||||
console.warn('Ignoring savePlant request: no active user session.');
|
||||
return;
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
let finalImageUri = imageUri;
|
||||
|
||||
try {
|
||||
finalImageUri = await ImageCacheService.cacheImage(imageUri);
|
||||
} catch (error) {
|
||||
console.error('Failed to cache plant image before save.', {
|
||||
userId: activeSession.userId,
|
||||
error: toErrorMessage(error),
|
||||
});
|
||||
}
|
||||
|
||||
const newPlant: Plant = {
|
||||
id: Math.random().toString(36).substr(2, 9),
|
||||
name: result.name,
|
||||
botanicalName: result.botanicalName,
|
||||
imageUri: finalImageUri,
|
||||
dateAdded: now,
|
||||
careInfo: result.careInfo,
|
||||
lastWatered: now,
|
||||
wateringHistory: [now],
|
||||
description: result.description,
|
||||
notificationsEnabled: false,
|
||||
};
|
||||
|
||||
try {
|
||||
PlantsDb.insert(activeSession.userId, newPlant);
|
||||
} catch (error) {
|
||||
console.error('Failed to insert plant into SQLite.', {
|
||||
userId: activeSession.userId,
|
||||
plantId: newPlant.id,
|
||||
plantName: newPlant.name,
|
||||
error: toErrorMessage(error),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
|
||||
try {
|
||||
const reloadedPlants = PlantsDb.getAll(activeSession.userId);
|
||||
const insertedPlantExists = reloadedPlants.some((plant) => plant.id === newPlant.id);
|
||||
|
||||
if (!insertedPlantExists) {
|
||||
console.warn('Plant was inserted but not found in immediate reload. Applying optimistic list update.', {
|
||||
userId: activeSession.userId,
|
||||
plantId: newPlant.id,
|
||||
});
|
||||
setPlants(prev => [newPlant, ...prev.filter((plant) => plant.id !== newPlant.id)]);
|
||||
return;
|
||||
}
|
||||
|
||||
setPlants(reloadedPlants);
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh plants after insert. Applying optimistic fallback.', {
|
||||
userId: activeSession.userId,
|
||||
plantId: newPlant.id,
|
||||
error: toErrorMessage(error),
|
||||
});
|
||||
setPlants(prev => [newPlant, ...prev.filter((plant) => plant.id !== newPlant.id)]);
|
||||
}
|
||||
}, [session]);
|
||||
|
||||
const deletePlant = useCallback(async (id: string) => {
|
||||
if (!session) return;
|
||||
const plant = plants.find(p => p.id === id);
|
||||
if (plant?.imageUri) {
|
||||
await ImageCacheService.deleteCachedImage(plant.imageUri);
|
||||
}
|
||||
PlantsDb.delete(session.userId, id);
|
||||
setPlants(prev => prev.filter(p => p.id !== id));
|
||||
}, [session, plants]);
|
||||
|
||||
const updatePlant = useCallback((updatedPlant: Plant) => {
|
||||
if (!session) return;
|
||||
PlantsDb.update(session.userId, updatedPlant);
|
||||
setPlants(prev => prev.map(p => p.id === updatedPlant.id ? updatedPlant : p));
|
||||
}, [session]);
|
||||
|
||||
const hydrateSession = useCallback(async (nextSession: AuthSession) => {
|
||||
setSession(nextSession);
|
||||
setProfileNameState(nextSession.name);
|
||||
setIsLoadingPlants(true);
|
||||
setIsLoadingBilling(true);
|
||||
|
||||
// Settings aus SQLite
|
||||
try {
|
||||
const settings = SettingsDb.get(nextSession.userId);
|
||||
if (settings.language_set === 1 && isLanguage(settings.language)) setLanguage(settings.language as Language);
|
||||
if (isAppearanceMode(settings.appearance_mode)) setAppearanceModeState(settings.appearance_mode as AppearanceMode);
|
||||
if (isColorPalette(settings.color_palette)) setColorPaletteState(settings.color_palette as ColorPalette);
|
||||
setProfileImageUri(settings.profile_image);
|
||||
} catch (e) {
|
||||
console.error('Failed to load settings from SQLite', e);
|
||||
}
|
||||
|
||||
// Pflanzen laden
|
||||
try {
|
||||
setPlants(PlantsDb.getAll(nextSession.userId));
|
||||
} catch (error) {
|
||||
console.error('Failed to load plants during app bootstrap.', {
|
||||
userId: nextSession.userId,
|
||||
error: toErrorMessage(error),
|
||||
});
|
||||
setPlants([]);
|
||||
} finally {
|
||||
setIsLoadingPlants(false);
|
||||
}
|
||||
|
||||
// Billing laden
|
||||
try {
|
||||
await refreshBillingSummary();
|
||||
} catch (e) {
|
||||
console.error('Initial billing summary check failed', e);
|
||||
setIsLoadingBilling(false);
|
||||
// Einmaliger Retry nach 2s
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
await refreshBillingSummary();
|
||||
} catch {
|
||||
// silent — user can retry manually
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
// Check for pending plant to save after login/signup
|
||||
if (pendingPlant) {
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
// Inside hydrateSession, the state 'session' might not be updated yet
|
||||
// but we can pass nextSession to savePlant if we modify it,
|
||||
// but savePlant uses the 'session' from the outer scope.
|
||||
// However, by the time this timeout runs, the session state SHOULD be set.
|
||||
await savePlant(pendingPlant.result, pendingPlant.imageUri, nextSession);
|
||||
setPendingPlantState(null);
|
||||
} catch (e) {
|
||||
console.error('Failed to save pending plant after hydration', e);
|
||||
}
|
||||
}, 800);
|
||||
}
|
||||
}, [refreshBillingSummary, pendingPlant, savePlant]);
|
||||
|
||||
const signOut = useCallback(async () => {
|
||||
await AuthService.logout();
|
||||
resetStateForSignedOutUser();
|
||||
}, [resetStateForSignedOutUser]);
|
||||
|
||||
// Session + Settings laden (inkl. Server-Validierung)
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
// Load guest scan count from DB
|
||||
const savedCount = AppMetaDb.get('guest_scan_count');
|
||||
if (savedCount) {
|
||||
setGuestScanCount(parseInt(savedCount, 10) || 0);
|
||||
}
|
||||
|
||||
const s = await AuthService.getSession();
|
||||
if (!s) {
|
||||
resetStateForSignedOutUser();
|
||||
return;
|
||||
}
|
||||
// Token validieren bevor Session gesetzt wird — verhindert kurzes Dashboard-Flash
|
||||
const validity = await AuthService.validateWithServer();
|
||||
if (validity === 'invalid') {
|
||||
await AuthService.logout();
|
||||
resetStateForSignedOutUser();
|
||||
return;
|
||||
}
|
||||
await hydrateSession(s);
|
||||
} catch (error) {
|
||||
console.error('Critical failure during AppContext initialization', error);
|
||||
resetStateForSignedOutUser();
|
||||
} finally {
|
||||
setIsInitializing(false);
|
||||
}
|
||||
})();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const applyRevenueCatCustomerInfoLocally = useCallback((customerInfo: RevenueCatCustomerInfo) => {
|
||||
const entitlementId = (process.env.EXPO_PUBLIC_REVENUECAT_PRO_ENTITLEMENT_ID || 'pro').trim() || 'pro';
|
||||
const activeEntitlements = customerInfo?.entitlements?.active || {};
|
||||
const proEntitlement = activeEntitlements[entitlementId];
|
||||
const isPro = Boolean(proEntitlement);
|
||||
|
||||
const AppContext = createContext<AppState | null>(null);
|
||||
|
||||
const toErrorMessage = (error: unknown): string => {
|
||||
if (error instanceof Error) return error.message;
|
||||
return String(error);
|
||||
};
|
||||
|
||||
export const useApp = () => {
|
||||
const ctx = useContext(AppContext);
|
||||
if (!ctx) throw new Error('useApp must be used within AppProvider');
|
||||
return ctx;
|
||||
};
|
||||
|
||||
const isAppearanceMode = (v: string): v is AppearanceMode =>
|
||||
v === 'system' || v === 'light' || v === 'dark';
|
||||
const isColorPalette = (v: string): v is ColorPalette =>
|
||||
v === 'forest' || v === 'ocean' || v === 'sunset' || v === 'mono';
|
||||
const isLanguage = (v: string): v is Language => v === 'de' || v === 'en' || v === 'es';
|
||||
|
||||
const getDeviceLanguage = (): Language => {
|
||||
try {
|
||||
const locale = Intl.DateTimeFormat().resolvedOptions().locale || '';
|
||||
const lang = locale.split('-')[0].toLowerCase();
|
||||
if (lang === 'de') return 'de';
|
||||
if (lang === 'es') return 'es';
|
||||
return 'en';
|
||||
} catch {
|
||||
return 'en';
|
||||
}
|
||||
};
|
||||
|
||||
export const AppProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [systemColorScheme, setSystemColorScheme] = useState(Appearance.getColorScheme());
|
||||
|
||||
useEffect(() => {
|
||||
const subscription = Appearance.addChangeListener(({ colorScheme }) => {
|
||||
setSystemColorScheme(colorScheme);
|
||||
setBillingSummary((prev) => {
|
||||
if (!prev) return prev;
|
||||
return {
|
||||
...prev,
|
||||
entitlement: {
|
||||
...prev.entitlement,
|
||||
plan: isPro ? 'pro' : 'free',
|
||||
provider: 'revenuecat',
|
||||
status: isPro ? 'active' : 'inactive',
|
||||
renewsAt: proEntitlement?.expirationDate || proEntitlement?.expiresDate || null,
|
||||
},
|
||||
};
|
||||
});
|
||||
return () => subscription.remove();
|
||||
}, []);
|
||||
|
||||
const [session, setSession] = useState<AuthSession | null>(null);
|
||||
const [plants, setPlants] = useState<Plant[]>([]);
|
||||
const [language, setLanguage] = useState<Language>(getDeviceLanguage());
|
||||
const [appearanceMode, setAppearanceModeState] = useState<AppearanceMode>('system');
|
||||
const [colorPalette, setColorPaletteState] = useState<ColorPalette>('forest');
|
||||
const [profileName, setProfileNameState] = useState('');
|
||||
const [profileImageUri, setProfileImageUri] = useState<string | null>(null);
|
||||
const [pendingPlant, setPendingPlantState] = useState<{ result: IdentificationResult; imageUri: string } | null>(null);
|
||||
const [guestScanCount, setGuestScanCount] = useState(0);
|
||||
const [isInitializing, setIsInitializing] = useState(true);
|
||||
const [isLoadingPlants, setIsLoadingPlants] = useState(true);
|
||||
const [billingSummary, setBillingSummary] = useState<BillingSummary | null>(null);
|
||||
const [isLoadingBilling, setIsLoadingBilling] = useState(true);
|
||||
|
||||
const resolvedScheme: AppColorScheme =
|
||||
appearanceMode === 'system'
|
||||
? (systemColorScheme ?? 'dark') === 'dark' ? 'dark' : 'light'
|
||||
: appearanceMode;
|
||||
const isDarkMode = resolvedScheme === 'dark';
|
||||
const t = getTranslation(language);
|
||||
|
||||
const refreshBillingSummary = useCallback(async () => {
|
||||
setIsLoadingBilling(true);
|
||||
const syncRevenueCatState = useCallback(async (customerInfo: RevenueCatCustomerInfo) => {
|
||||
applyRevenueCatCustomerInfoLocally(customerInfo);
|
||||
try {
|
||||
const summary = await backendApiClient.getBillingSummary();
|
||||
setBillingSummary(summary);
|
||||
} catch (e) {
|
||||
console.error('Failed to refresh billing summary', e);
|
||||
} finally {
|
||||
setIsLoadingBilling(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const resetStateForSignedOutUser = useCallback(() => {
|
||||
setSession(null);
|
||||
setPlants([]);
|
||||
setLanguage(getDeviceLanguage());
|
||||
setAppearanceModeState('system');
|
||||
setColorPaletteState('forest');
|
||||
setProfileNameState('');
|
||||
setProfileImageUri(null);
|
||||
setIsLoadingPlants(false);
|
||||
// Fetch guest billing summary instead of setting it to null
|
||||
refreshBillingSummary();
|
||||
}, [refreshBillingSummary]);
|
||||
|
||||
const refreshPlants = useCallback(() => {
|
||||
if (!session) return;
|
||||
try {
|
||||
setPlants(PlantsDb.getAll(session.userId));
|
||||
const response = await backendApiClient.syncRevenueCatState({ customerInfo });
|
||||
setBillingSummary(response.billing);
|
||||
return response.billing;
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh plants list.', {
|
||||
userId: session.userId,
|
||||
error: toErrorMessage(error),
|
||||
});
|
||||
console.error('Failed to sync RevenueCat state with backend', error);
|
||||
return null;
|
||||
}
|
||||
}, [session]);
|
||||
|
||||
const savePlant = useCallback(async (result: IdentificationResult, imageUri: string, overrideSession?: AuthSession) => {
|
||||
const activeSession = overrideSession || session;
|
||||
if (!activeSession) {
|
||||
console.warn('Ignoring savePlant request: no active user session.');
|
||||
return;
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
let finalImageUri = imageUri;
|
||||
|
||||
try {
|
||||
finalImageUri = await ImageCacheService.cacheImage(imageUri);
|
||||
} catch (error) {
|
||||
console.error('Failed to cache plant image before save.', {
|
||||
userId: activeSession.userId,
|
||||
error: toErrorMessage(error),
|
||||
});
|
||||
}
|
||||
|
||||
const newPlant: Plant = {
|
||||
id: Math.random().toString(36).substr(2, 9),
|
||||
name: result.name,
|
||||
botanicalName: result.botanicalName,
|
||||
imageUri: finalImageUri,
|
||||
dateAdded: now,
|
||||
careInfo: result.careInfo,
|
||||
lastWatered: now,
|
||||
wateringHistory: [now],
|
||||
description: result.description,
|
||||
notificationsEnabled: false,
|
||||
};
|
||||
|
||||
try {
|
||||
PlantsDb.insert(activeSession.userId, newPlant);
|
||||
} catch (error) {
|
||||
console.error('Failed to insert plant into SQLite.', {
|
||||
userId: activeSession.userId,
|
||||
plantId: newPlant.id,
|
||||
plantName: newPlant.name,
|
||||
error: toErrorMessage(error),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
|
||||
try {
|
||||
const reloadedPlants = PlantsDb.getAll(activeSession.userId);
|
||||
const insertedPlantExists = reloadedPlants.some((plant) => plant.id === newPlant.id);
|
||||
|
||||
if (!insertedPlantExists) {
|
||||
console.warn('Plant was inserted but not found in immediate reload. Applying optimistic list update.', {
|
||||
userId: activeSession.userId,
|
||||
plantId: newPlant.id,
|
||||
});
|
||||
setPlants(prev => [newPlant, ...prev.filter((plant) => plant.id !== newPlant.id)]);
|
||||
return;
|
||||
}
|
||||
|
||||
setPlants(reloadedPlants);
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh plants after insert. Applying optimistic fallback.', {
|
||||
userId: activeSession.userId,
|
||||
plantId: newPlant.id,
|
||||
error: toErrorMessage(error),
|
||||
});
|
||||
setPlants(prev => [newPlant, ...prev.filter((plant) => plant.id !== newPlant.id)]);
|
||||
}
|
||||
}, [session]);
|
||||
|
||||
const deletePlant = useCallback(async (id: string) => {
|
||||
if (!session) return;
|
||||
const plant = plants.find(p => p.id === id);
|
||||
if (plant?.imageUri) {
|
||||
await ImageCacheService.deleteCachedImage(plant.imageUri);
|
||||
}
|
||||
PlantsDb.delete(session.userId, id);
|
||||
setPlants(prev => prev.filter(p => p.id !== id));
|
||||
}, [session, plants]);
|
||||
|
||||
const updatePlant = useCallback((updatedPlant: Plant) => {
|
||||
if (!session) return;
|
||||
PlantsDb.update(session.userId, updatedPlant);
|
||||
setPlants(prev => prev.map(p => p.id === updatedPlant.id ? updatedPlant : p));
|
||||
}, [session]);
|
||||
|
||||
const hydrateSession = useCallback(async (nextSession: AuthSession) => {
|
||||
setSession(nextSession);
|
||||
setProfileNameState(nextSession.name);
|
||||
setIsLoadingPlants(true);
|
||||
setIsLoadingBilling(true);
|
||||
|
||||
// Settings aus SQLite
|
||||
try {
|
||||
const settings = SettingsDb.get(nextSession.userId);
|
||||
if (settings.language_set === 1 && isLanguage(settings.language)) setLanguage(settings.language as Language);
|
||||
if (isAppearanceMode(settings.appearance_mode)) setAppearanceModeState(settings.appearance_mode as AppearanceMode);
|
||||
if (isColorPalette(settings.color_palette)) setColorPaletteState(settings.color_palette as ColorPalette);
|
||||
setProfileImageUri(settings.profile_image);
|
||||
} catch (e) {
|
||||
console.error('Failed to load settings from SQLite', e);
|
||||
}
|
||||
|
||||
// Pflanzen laden
|
||||
try {
|
||||
setPlants(PlantsDb.getAll(nextSession.userId));
|
||||
} catch (error) {
|
||||
console.error('Failed to load plants during app bootstrap.', {
|
||||
userId: nextSession.userId,
|
||||
error: toErrorMessage(error),
|
||||
});
|
||||
setPlants([]);
|
||||
} finally {
|
||||
setIsLoadingPlants(false);
|
||||
}
|
||||
|
||||
// Billing laden
|
||||
try {
|
||||
await refreshBillingSummary();
|
||||
} catch (e) {
|
||||
console.error('Initial billing summary check failed', e);
|
||||
setIsLoadingBilling(false);
|
||||
// Einmaliger Retry nach 2s
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
await refreshBillingSummary();
|
||||
} catch {
|
||||
// silent — user can retry manually
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
// Check for pending plant to save after login/signup
|
||||
if (pendingPlant) {
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
// Inside hydrateSession, the state 'session' might not be updated yet
|
||||
// but we can pass nextSession to savePlant if we modify it,
|
||||
// but savePlant uses the 'session' from the outer scope.
|
||||
// However, by the time this timeout runs, the session state SHOULD be set.
|
||||
await savePlant(pendingPlant.result, pendingPlant.imageUri, nextSession);
|
||||
setPendingPlantState(null);
|
||||
} catch (e) {
|
||||
console.error('Failed to save pending plant after hydration', e);
|
||||
}
|
||||
}, 800);
|
||||
}
|
||||
}, [refreshBillingSummary, pendingPlant, savePlant]);
|
||||
|
||||
const signOut = useCallback(async () => {
|
||||
await AuthService.logout();
|
||||
resetStateForSignedOutUser();
|
||||
}, [resetStateForSignedOutUser]);
|
||||
|
||||
// Session + Settings laden (inkl. Server-Validierung)
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
// Load guest scan count from DB
|
||||
const savedCount = AppMetaDb.get('guest_scan_count');
|
||||
if (savedCount) {
|
||||
setGuestScanCount(parseInt(savedCount, 10) || 0);
|
||||
}
|
||||
|
||||
const s = await AuthService.getSession();
|
||||
if (!s) {
|
||||
resetStateForSignedOutUser();
|
||||
return;
|
||||
}
|
||||
// Token validieren bevor Session gesetzt wird — verhindert kurzes Dashboard-Flash
|
||||
const validity = await AuthService.validateWithServer();
|
||||
if (validity === 'invalid') {
|
||||
await AuthService.logout();
|
||||
resetStateForSignedOutUser();
|
||||
return;
|
||||
}
|
||||
await hydrateSession(s);
|
||||
} catch (error) {
|
||||
console.error('Critical failure during AppContext initialization', error);
|
||||
resetStateForSignedOutUser();
|
||||
} finally {
|
||||
setIsInitializing(false);
|
||||
}
|
||||
})();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
}, [applyRevenueCatCustomerInfoLocally]);
|
||||
|
||||
const simulatePurchase = useCallback(async (productId: PurchaseProductId) => {
|
||||
const response = await backendApiClient.simulatePurchase({
|
||||
idempotencyKey: createIdempotencyKey('purchase', productId),
|
||||
productId,
|
||||
});
|
||||
setBillingSummary(response.billing);
|
||||
}, []);
|
||||
|
||||
const simulateWebhookEvent = useCallback(async (
|
||||
event: SimulatedWebhookEvent,
|
||||
payload?: { credits?: number },
|
||||
) => {
|
||||
const response = await backendApiClient.simulateWebhook({
|
||||
idempotencyKey: createIdempotencyKey('webhook', event),
|
||||
event,
|
||||
payload,
|
||||
});
|
||||
setBillingSummary(response.billing);
|
||||
}, []);
|
||||
|
||||
const setAppearanceMode = useCallback((mode: AppearanceMode) => {
|
||||
setAppearanceModeState(mode);
|
||||
if (session) SettingsDb.setAppearanceMode(session.userId, mode);
|
||||
}, [session]);
|
||||
|
||||
const setColorPalette = useCallback((palette: ColorPalette) => {
|
||||
setColorPaletteState(palette);
|
||||
if (session) SettingsDb.setColorPalette(session.userId, palette);
|
||||
}, [session]);
|
||||
|
||||
const setProfileName = useCallback(async (name: string) => {
|
||||
const normalized = name.trim() || session?.name || 'GreenLens User';
|
||||
setProfileNameState(normalized);
|
||||
if (session) {
|
||||
SettingsDb.setName(session.userId, normalized);
|
||||
await AuthService.updateSessionName(normalized);
|
||||
}
|
||||
}, [session]);
|
||||
|
||||
const setProfileImage = useCallback(async (imageUri: string | null) => {
|
||||
let nextUri = imageUri;
|
||||
if (imageUri) {
|
||||
try {
|
||||
nextUri = await ImageCacheService.cacheImage(imageUri);
|
||||
} catch (e) {
|
||||
console.error('Failed to cache profile image', e);
|
||||
}
|
||||
}
|
||||
if (profileImageUri && profileImageUri !== nextUri) {
|
||||
await ImageCacheService.deleteCachedImage(profileImageUri);
|
||||
}
|
||||
setProfileImageUri(nextUri);
|
||||
if (session) SettingsDb.setProfileImage(session.userId, nextUri);
|
||||
}, [session, profileImageUri]);
|
||||
|
||||
const changeLanguage = useCallback((lang: Language) => {
|
||||
setLanguage(lang);
|
||||
if (session) SettingsDb.setLanguage(session.userId, lang);
|
||||
}, [session]);
|
||||
|
||||
|
||||
// Lexicon history — synchron (SQLite sync API)
|
||||
const getLexiconSearchHistory = useCallback((): string[] => {
|
||||
if (!session) return [];
|
||||
return LexiconHistoryDb.getAll(session.userId);
|
||||
}, [session]);
|
||||
|
||||
const saveLexiconSearchQuery = useCallback((query: string) => {
|
||||
if (!session) return;
|
||||
LexiconHistoryDb.add(session.userId, query);
|
||||
}, [session]);
|
||||
|
||||
const clearLexiconSearchHistory = useCallback(() => {
|
||||
if (!session) return;
|
||||
LexiconHistoryDb.clear(session.userId);
|
||||
}, [session]);
|
||||
|
||||
const setPendingPlant = useCallback((result: IdentificationResult, imageUri: string) => {
|
||||
setPendingPlantState({ result, imageUri });
|
||||
}, []);
|
||||
|
||||
const getPendingPlant = useCallback(() => {
|
||||
return pendingPlant;
|
||||
}, [pendingPlant]);
|
||||
|
||||
const incrementGuestScanCount = useCallback(() => {
|
||||
setGuestScanCount(prev => {
|
||||
const next = prev + 1;
|
||||
AppMetaDb.set('guest_scan_count', next.toString());
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AppContext.Provider value={{
|
||||
session,
|
||||
plants,
|
||||
language,
|
||||
appearanceMode,
|
||||
colorPalette,
|
||||
profileName,
|
||||
profileImageUri,
|
||||
billingSummary,
|
||||
resolvedScheme,
|
||||
isDarkMode,
|
||||
isInitializing,
|
||||
isLoadingPlants,
|
||||
isLoadingBilling,
|
||||
t,
|
||||
setAppearanceMode,
|
||||
setColorPalette,
|
||||
setProfileName,
|
||||
setProfileImage,
|
||||
changeLanguage,
|
||||
savePlant,
|
||||
deletePlant,
|
||||
const response = await backendApiClient.simulatePurchase({
|
||||
idempotencyKey: createIdempotencyKey('purchase', productId),
|
||||
productId,
|
||||
});
|
||||
setBillingSummary(response.billing);
|
||||
}, []);
|
||||
|
||||
const simulateWebhookEvent = useCallback(async (
|
||||
event: SimulatedWebhookEvent,
|
||||
payload?: { credits?: number },
|
||||
) => {
|
||||
const response = await backendApiClient.simulateWebhook({
|
||||
idempotencyKey: createIdempotencyKey('webhook', event),
|
||||
event,
|
||||
payload,
|
||||
});
|
||||
setBillingSummary(response.billing);
|
||||
}, []);
|
||||
|
||||
const setAppearanceMode = useCallback((mode: AppearanceMode) => {
|
||||
setAppearanceModeState(mode);
|
||||
if (session) SettingsDb.setAppearanceMode(session.userId, mode);
|
||||
}, [session]);
|
||||
|
||||
const setColorPalette = useCallback((palette: ColorPalette) => {
|
||||
setColorPaletteState(palette);
|
||||
if (session) SettingsDb.setColorPalette(session.userId, palette);
|
||||
}, [session]);
|
||||
|
||||
const setProfileName = useCallback(async (name: string) => {
|
||||
const normalized = name.trim() || session?.name || 'GreenLens User';
|
||||
setProfileNameState(normalized);
|
||||
if (session) {
|
||||
SettingsDb.setName(session.userId, normalized);
|
||||
await AuthService.updateSessionName(normalized);
|
||||
}
|
||||
}, [session]);
|
||||
|
||||
const setProfileImage = useCallback(async (imageUri: string | null) => {
|
||||
let nextUri = imageUri;
|
||||
if (imageUri) {
|
||||
try {
|
||||
nextUri = await ImageCacheService.cacheImage(imageUri);
|
||||
} catch (e) {
|
||||
console.error('Failed to cache profile image', e);
|
||||
}
|
||||
}
|
||||
if (profileImageUri && profileImageUri !== nextUri) {
|
||||
await ImageCacheService.deleteCachedImage(profileImageUri);
|
||||
}
|
||||
setProfileImageUri(nextUri);
|
||||
if (session) SettingsDb.setProfileImage(session.userId, nextUri);
|
||||
}, [session, profileImageUri]);
|
||||
|
||||
const changeLanguage = useCallback((lang: Language) => {
|
||||
setLanguage(lang);
|
||||
if (session) SettingsDb.setLanguage(session.userId, lang);
|
||||
}, [session]);
|
||||
|
||||
|
||||
// Lexicon history — synchron (SQLite sync API)
|
||||
const getLexiconSearchHistory = useCallback((): string[] => {
|
||||
if (!session) return [];
|
||||
return LexiconHistoryDb.getAll(session.userId);
|
||||
}, [session]);
|
||||
|
||||
const saveLexiconSearchQuery = useCallback((query: string) => {
|
||||
if (!session) return;
|
||||
LexiconHistoryDb.add(session.userId, query);
|
||||
}, [session]);
|
||||
|
||||
const clearLexiconSearchHistory = useCallback(() => {
|
||||
if (!session) return;
|
||||
LexiconHistoryDb.clear(session.userId);
|
||||
}, [session]);
|
||||
|
||||
const setPendingPlant = useCallback((result: IdentificationResult, imageUri: string) => {
|
||||
setPendingPlantState({ result, imageUri });
|
||||
}, []);
|
||||
|
||||
const getPendingPlant = useCallback(() => {
|
||||
return pendingPlant;
|
||||
}, [pendingPlant]);
|
||||
|
||||
const incrementGuestScanCount = useCallback(() => {
|
||||
setGuestScanCount(prev => {
|
||||
const next = prev + 1;
|
||||
AppMetaDb.set('guest_scan_count', next.toString());
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AppContext.Provider value={{
|
||||
session,
|
||||
plants,
|
||||
language,
|
||||
appearanceMode,
|
||||
colorPalette,
|
||||
profileName,
|
||||
profileImageUri,
|
||||
billingSummary,
|
||||
resolvedScheme,
|
||||
isDarkMode,
|
||||
isInitializing,
|
||||
isLoadingPlants,
|
||||
isLoadingBilling,
|
||||
t,
|
||||
setAppearanceMode,
|
||||
setColorPalette,
|
||||
setProfileName,
|
||||
setProfileImage,
|
||||
changeLanguage,
|
||||
savePlant,
|
||||
deletePlant,
|
||||
updatePlant,
|
||||
refreshPlants,
|
||||
refreshBillingSummary,
|
||||
syncRevenueCatState,
|
||||
simulatePurchase,
|
||||
simulateWebhookEvent,
|
||||
getLexiconSearchHistory,
|
||||
saveLexiconSearchQuery,
|
||||
clearLexiconSearchHistory,
|
||||
hydrateSession,
|
||||
signOut,
|
||||
setPendingPlant,
|
||||
getPendingPlant,
|
||||
guestScanCount,
|
||||
incrementGuestScanCount,
|
||||
}}>
|
||||
{children}
|
||||
</AppContext.Provider>
|
||||
);
|
||||
};
|
||||
simulateWebhookEvent,
|
||||
getLexiconSearchHistory,
|
||||
saveLexiconSearchQuery,
|
||||
clearLexiconSearchHistory,
|
||||
hydrateSession,
|
||||
signOut,
|
||||
setPendingPlant,
|
||||
getPendingPlant,
|
||||
guestScanCount,
|
||||
incrementGuestScanCount,
|
||||
}}>
|
||||
{children}
|
||||
</AppContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,77 +1,77 @@
|
||||
import React, { createContext, useContext, useState, useCallback, useRef } from 'react';
|
||||
import { LayoutRectangle } from 'react-native';
|
||||
|
||||
export interface CoachStep {
|
||||
elementKey: string;
|
||||
title: string;
|
||||
description: string;
|
||||
tooltipSide: 'above' | 'below' | 'left' | 'right';
|
||||
}
|
||||
|
||||
interface CoachMarksState {
|
||||
isActive: boolean;
|
||||
currentStep: number;
|
||||
steps: CoachStep[];
|
||||
layouts: Record<string, LayoutRectangle>;
|
||||
registerLayout: (key: string, layout: LayoutRectangle) => void;
|
||||
startTour: (steps: CoachStep[]) => void;
|
||||
next: () => void;
|
||||
skip: () => void;
|
||||
}
|
||||
|
||||
const CoachMarksContext = createContext<CoachMarksState | null>(null);
|
||||
|
||||
export const useCoachMarks = () => {
|
||||
const ctx = useContext(CoachMarksContext);
|
||||
if (!ctx) throw new Error('useCoachMarks must be within CoachMarksProvider');
|
||||
return ctx;
|
||||
};
|
||||
|
||||
export const CoachMarksProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [isActive, setIsActive] = useState(false);
|
||||
const [currentStep, setCurrentStep] = useState(0);
|
||||
const [steps, setSteps] = useState<CoachStep[]>([]);
|
||||
const layouts = useRef<Record<string, LayoutRectangle>>({});
|
||||
const [, forceRender] = useState(0);
|
||||
|
||||
const registerLayout = useCallback((key: string, layout: LayoutRectangle) => {
|
||||
layouts.current[key] = layout;
|
||||
forceRender(n => n + 1);
|
||||
}, []);
|
||||
|
||||
const startTour = useCallback((newSteps: CoachStep[]) => {
|
||||
setSteps(newSteps);
|
||||
setCurrentStep(0);
|
||||
setIsActive(true);
|
||||
}, []);
|
||||
|
||||
const next = useCallback(() => {
|
||||
setCurrentStep(prev => {
|
||||
if (prev + 1 >= steps.length) {
|
||||
setIsActive(false);
|
||||
return 0;
|
||||
}
|
||||
return prev + 1;
|
||||
});
|
||||
}, [steps.length]);
|
||||
|
||||
const skip = useCallback(() => {
|
||||
setIsActive(false);
|
||||
setCurrentStep(0);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<CoachMarksContext.Provider value={{
|
||||
isActive,
|
||||
currentStep,
|
||||
steps,
|
||||
layouts: layouts.current,
|
||||
registerLayout,
|
||||
startTour,
|
||||
next,
|
||||
skip,
|
||||
}}>
|
||||
{children}
|
||||
</CoachMarksContext.Provider>
|
||||
);
|
||||
};
|
||||
import React, { createContext, useContext, useState, useCallback, useRef } from 'react';
|
||||
import { LayoutRectangle } from 'react-native';
|
||||
|
||||
export interface CoachStep {
|
||||
elementKey: string;
|
||||
title: string;
|
||||
description: string;
|
||||
tooltipSide: 'above' | 'below' | 'left' | 'right';
|
||||
}
|
||||
|
||||
interface CoachMarksState {
|
||||
isActive: boolean;
|
||||
currentStep: number;
|
||||
steps: CoachStep[];
|
||||
layouts: Record<string, LayoutRectangle>;
|
||||
registerLayout: (key: string, layout: LayoutRectangle) => void;
|
||||
startTour: (steps: CoachStep[]) => void;
|
||||
next: () => void;
|
||||
skip: () => void;
|
||||
}
|
||||
|
||||
const CoachMarksContext = createContext<CoachMarksState | null>(null);
|
||||
|
||||
export const useCoachMarks = () => {
|
||||
const ctx = useContext(CoachMarksContext);
|
||||
if (!ctx) throw new Error('useCoachMarks must be within CoachMarksProvider');
|
||||
return ctx;
|
||||
};
|
||||
|
||||
export const CoachMarksProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [isActive, setIsActive] = useState(false);
|
||||
const [currentStep, setCurrentStep] = useState(0);
|
||||
const [steps, setSteps] = useState<CoachStep[]>([]);
|
||||
const layouts = useRef<Record<string, LayoutRectangle>>({});
|
||||
const [, forceRender] = useState(0);
|
||||
|
||||
const registerLayout = useCallback((key: string, layout: LayoutRectangle) => {
|
||||
layouts.current[key] = layout;
|
||||
forceRender(n => n + 1);
|
||||
}, []);
|
||||
|
||||
const startTour = useCallback((newSteps: CoachStep[]) => {
|
||||
setSteps(newSteps);
|
||||
setCurrentStep(0);
|
||||
setIsActive(true);
|
||||
}, []);
|
||||
|
||||
const next = useCallback(() => {
|
||||
setCurrentStep(prev => {
|
||||
if (prev + 1 >= steps.length) {
|
||||
setIsActive(false);
|
||||
return 0;
|
||||
}
|
||||
return prev + 1;
|
||||
});
|
||||
}, [steps.length]);
|
||||
|
||||
const skip = useCallback(() => {
|
||||
setIsActive(false);
|
||||
setCurrentStep(0);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<CoachMarksContext.Provider value={{
|
||||
isActive,
|
||||
currentStep,
|
||||
steps,
|
||||
layouts: layouts.current,
|
||||
registerLayout,
|
||||
startTour,
|
||||
next,
|
||||
skip,
|
||||
}}>
|
||||
{children}
|
||||
</CoachMarksContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user