Greenlens/context/AppContext.tsx

477 lines
16 KiB
TypeScript

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<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>;
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;
}
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);
});
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 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,
updatePlant,
refreshPlants,
refreshBillingSummary,
simulatePurchase,
simulateWebhookEvent,
getLexiconSearchHistory,
saveLexiconSearchQuery,
clearLexiconSearchHistory,
hydrateSession,
signOut,
setPendingPlant,
getPendingPlant,
guestScanCount,
incrementGuestScanCount,
}}>
{children}
</AppContext.Provider>
);
};