477 lines
16 KiB
TypeScript
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>
|
|
);
|
|
};
|