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 { 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; // Actions setAppearanceMode: (mode: AppearanceMode) => void; setColorPalette: (palette: ColorPalette) => void; setProfileName: (name: string) => Promise; setProfileImage: (imageUri: string | null) => Promise; changeLanguage: (lang: Language) => void; savePlant: (result: IdentificationResult, imageUri: string, overrideSession?: AuthSession) => Promise; deletePlant: (id: string) => Promise; updatePlant: (plant: Plant) => void; refreshPlants: () => void; refreshBillingSummary: () => Promise; simulatePurchase: (productId: PurchaseProductId) => Promise; simulateWebhookEvent: (event: SimulatedWebhookEvent, payload?: { credits?: number }) => Promise; getLexiconSearchHistory: () => string[]; saveLexiconSearchQuery: (query: string) => void; clearLexiconSearchHistory: () => void; hydrateSession: (session: AuthSession) => Promise; signOut: () => Promise; setPendingPlant: (result: IdentificationResult, imageUri: string) => void; getPendingPlant: () => { result: IdentificationResult; imageUri: string } | null; guestScanCount: number; incrementGuestScanCount: () => void; } const AppContext = createContext(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); }); return () => subscription.remove(); }, []); const [session, setSession] = useState(null); const [plants, setPlants] = useState([]); const [language, setLanguage] = useState(getDeviceLanguage()); const [appearanceMode, setAppearanceModeState] = useState('system'); const [colorPalette, setColorPaletteState] = useState('forest'); const [profileName, setProfileNameState] = useState(''); const [profileImageUri, setProfileImageUri] = useState(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(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 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 ( {children} ); };