Onboarding

This commit is contained in:
2026-07-16 17:09:00 +02:00
parent 5c09c50af9
commit 52c9dfb472
5 changed files with 935 additions and 809 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -8,6 +8,7 @@ import {
Modal, Modal,
Alert, Alert,
Share, Share,
Linking,
} from 'react-native'; } from 'react-native';
import { useLocalSearchParams, useRouter } from 'expo-router'; import { useLocalSearchParams, useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
@@ -57,23 +58,23 @@ const HEALTH_CHECK_CREDIT_COST = 2;
const getHealthCopy = (language: 'de' | 'en' | 'es') => { const getHealthCopy = (language: 'de' | 'en' | 'es') => {
if (language === 'de') { if (language === 'de') {
return { return {
title: 'Health Check', title: 'Health Check',
action: 'Health-Scan starten', action: 'Health-Scan starten',
running: 'Neues Foto wird analysiert...', running: 'Neues Foto wird analysiert...',
cost: `Kosten: ${HEALTH_CHECK_CREDIT_COST} Credits`, cost: `Kosten: ${HEALTH_CHECK_CREDIT_COST} Credits`,
intro: 'Fotografiere die ganze Pflanze plus auffaellige Blaetter. Danach bekommst du Diagnose, Dringlichkeit und einen konkreten Pflegeplan.', intro: 'Fotografiere die ganze Pflanze plus auffaellige Blaetter. Danach bekommst du Diagnose, Dringlichkeit und einen konkreten Pflegeplan.',
creditsLabel: 'Credits', creditsLabel: 'Credits',
managePlan: 'Plan verwalten', managePlan: 'Plan verwalten',
noCreditsTitle: 'Nicht genug Credits', noCreditsTitle: 'Nicht genug Credits',
noCreditsMessage: `Du brauchst ${HEALTH_CHECK_CREDIT_COST} Credits fuer den Health-Check.`, noCreditsMessage: `Du brauchst ${HEALTH_CHECK_CREDIT_COST} Credits fuer den Health-Check.`,
insufficientInline: 'Nicht genug Credits fuer den Health-Check.', insufficientInline: 'Nicht genug Credits fuer den Health-Check.',
timeoutInline: 'Health-Check Timeout. Bitte erneut versuchen.', timeoutInline: 'Health-Check Timeout. Bitte erneut versuchen.',
providerInline: 'Health-Check ist gerade nicht verfuegbar.', providerInline: 'Health-Check ist gerade nicht verfuegbar.',
analysisTitle: 'Analyse', analysisTitle: 'Analyse',
analysisFallback: 'Die Pflanze wirkt insgesamt beurteilbar, aber die gespeicherte Analyse enthaelt noch keine ausformulierte Zusammenfassung. Orientiere dich deshalb an Score, Ursachen und Sofortmassnahmen. Pruefe zuerst die auffaelligsten Blaetter, danach Substratfeuchte und Standort. Wenn die Blaetter innerhalb von 48 Stunden weiter haengen, gelb werden oder Flecken ausbreiten, solltest du ein neues Foto bei hellem indirektem Licht aufnehmen. Ein neuer Health-Scan kann dann genauer zwischen Wasserstress, Lichtstress, Schaedlingen und normaler Blattalterung unterscheiden.', analysisFallback: 'Die Pflanze wirkt insgesamt beurteilbar, aber die gespeicherte Analyse enthaelt noch keine ausformulierte Zusammenfassung. Orientiere dich deshalb an Score, Ursachen und Sofortmassnahmen. Pruefe zuerst die auffaelligsten Blaetter, danach Substratfeuchte und Standort. Wenn die Blaetter innerhalb von 48 Stunden weiter haengen, gelb werden oder Flecken ausbreiten, solltest du ein neues Foto bei hellem indirektem Licht aufnehmen. Ein neuer Health-Scan kann dann genauer zwischen Wasserstress, Lichtstress, Schaedlingen und normaler Blattalterung unterscheiden.',
issuesTitle: 'Wahrscheinlichste Ursachen', issuesTitle: 'Wahrscheinlichste Ursachen',
actionsTitle: 'Sofortmassnahmen', actionsTitle: 'Sofortmassnahmen',
planTitle: '7-Tage-Plan', planTitle: '7-Tage-Plan',
scoreLabel: 'Gesundheits-Score', scoreLabel: 'Gesundheits-Score',
healthy: 'Stabil', healthy: 'Stabil',
watch: 'Beobachten', watch: 'Beobachten',
@@ -84,21 +85,21 @@ const getHealthCopy = (language: 'de' | 'en' | 'es') => {
if (language === 'es') { if (language === 'es') {
return { return {
title: 'Health Check', title: 'Health Check',
action: 'Iniciar health-scan', action: 'Iniciar health-scan',
running: 'Analizando foto nueva...', running: 'Analizando foto nueva...',
cost: `Costo: ${HEALTH_CHECK_CREDIT_COST} creditos`, cost: `Costo: ${HEALTH_CHECK_CREDIT_COST} creditos`,
intro: 'Fotografia la planta completa y las hojas llamativas. Luego recibes diagnostico, urgencia y un plan de cuidado concreto.', intro: 'Fotografia la planta completa y las hojas llamativas. Luego recibes diagnostico, urgencia y un plan de cuidado concreto.',
creditsLabel: 'Creditos', creditsLabel: 'Creditos',
managePlan: 'Gestionar plan', managePlan: 'Gestionar plan',
noCreditsTitle: 'Creditos insuficientes', noCreditsTitle: 'Creditos insuficientes',
noCreditsMessage: `Necesitas ${HEALTH_CHECK_CREDIT_COST} creditos para el health-check.`, noCreditsMessage: `Necesitas ${HEALTH_CHECK_CREDIT_COST} creditos para el health-check.`,
insufficientInline: 'No hay creditos suficientes para el health-check.', insufficientInline: 'No hay creditos suficientes para el health-check.',
timeoutInline: 'Health-check agotado por tiempo. Intenta de nuevo.', timeoutInline: 'Health-check agotado por tiempo. Intenta de nuevo.',
providerInline: 'Health-check no disponible ahora.', providerInline: 'Health-check no disponible ahora.',
analysisTitle: 'Analisis', analysisTitle: 'Analisis',
analysisFallback: 'La planta se puede evaluar en general, pero este chequeo guardado todavia no contiene un resumen completo. Usa el puntaje, las causas y las acciones inmediatas como guia principal. Revisa primero las hojas mas llamativas, despues la humedad del sustrato y la ubicacion. Si las hojas empeoran en 48 horas, amarillean o las manchas se expanden, toma una foto nueva con luz indirecta clara. Un nuevo health-scan podra diferenciar mejor entre exceso o falta de agua, luz, plagas y envejecimiento normal.', analysisFallback: 'La planta se puede evaluar en general, pero este chequeo guardado todavia no contiene un resumen completo. Usa el puntaje, las causas y las acciones inmediatas como guia principal. Revisa primero las hojas mas llamativas, despues la humedad del sustrato y la ubicacion. Si las hojas empeoran en 48 horas, amarillean o las manchas se expanden, toma una foto nueva con luz indirecta clara. Un nuevo health-scan podra diferenciar mejor entre exceso o falta de agua, luz, plagas y envejecimiento normal.',
issuesTitle: 'Causas mas probables', issuesTitle: 'Causas mas probables',
actionsTitle: 'Acciones inmediatas', actionsTitle: 'Acciones inmediatas',
planTitle: 'Plan de 7 dias', planTitle: 'Plan de 7 dias',
scoreLabel: 'Puntaje de salud', scoreLabel: 'Puntaje de salud',
@@ -110,11 +111,11 @@ const getHealthCopy = (language: 'de' | 'en' | 'es') => {
} }
return { return {
title: 'Health Check', title: 'Health Check',
action: 'Start health scan', action: 'Start health scan',
running: 'Analyzing new photo...', running: 'Analyzing new photo...',
cost: `Cost: ${HEALTH_CHECK_CREDIT_COST} credits`, cost: `Cost: ${HEALTH_CHECK_CREDIT_COST} credits`,
intro: 'Photograph the full plant plus any suspicious leaves. You will get a diagnosis, urgency level, and a concrete care plan.', intro: 'Photograph the full plant plus any suspicious leaves. You will get a diagnosis, urgency level, and a concrete care plan.',
creditsLabel: 'Credits', creditsLabel: 'Credits',
managePlan: 'Manage plan', managePlan: 'Manage plan',
noCreditsTitle: 'Not enough credits', noCreditsTitle: 'Not enough credits',
@@ -122,9 +123,9 @@ const getHealthCopy = (language: 'de' | 'en' | 'es') => {
insufficientInline: 'Not enough credits for the health check.', insufficientInline: 'Not enough credits for the health check.',
timeoutInline: 'Health check timed out. Please try again.', timeoutInline: 'Health check timed out. Please try again.',
providerInline: 'Health check is unavailable right now.', providerInline: 'Health check is unavailable right now.',
analysisTitle: 'Analysis', analysisTitle: 'Analysis',
analysisFallback: 'The plant is still assessable, but this saved check does not include a full written summary yet. Use the score, likely causes, and immediate actions as the primary guide. Start by inspecting the most unusual leaves, then check soil moisture and placement. If leaves droop further, yellowing spreads, or spots expand within 48 hours, take a new photo in bright indirect light. A fresh health scan can separate watering stress, light stress, pests, and normal leaf aging more accurately.', analysisFallback: 'The plant is still assessable, but this saved check does not include a full written summary yet. Use the score, likely causes, and immediate actions as the primary guide. Start by inspecting the most unusual leaves, then check soil moisture and placement. If leaves droop further, yellowing spreads, or spots expand within 48 hours, take a new photo in bright indirect light. A fresh health scan can separate watering stress, light stress, pests, and normal leaf aging more accurately.',
issuesTitle: 'Most likely causes', issuesTitle: 'Most likely causes',
actionsTitle: 'Actions now', actionsTitle: 'Actions now',
planTitle: '7-day plan', planTitle: '7-day plan',
scoreLabel: 'Health score', scoreLabel: 'Health score',
@@ -243,7 +244,7 @@ export default function PlantDetailScreen() {
: colors.dangerSoft : colors.dangerSoft
) )
: colors.surfaceMuted; : colors.surfaceMuted;
const latestStatusColor = latestHealthCheck const latestStatusColor = latestHealthCheck
? ( ? (
latestHealthCheck.status === 'healthy' latestHealthCheck.status === 'healthy'
? colors.success ? colors.success
@@ -251,10 +252,10 @@ export default function PlantDetailScreen() {
? colors.warning ? colors.warning
: colors.danger : colors.danger
) )
: colors.textMuted; : colors.textMuted;
const latestAnalysisSummary = latestHealthCheck const latestAnalysisSummary = latestHealthCheck
? latestHealthCheck.analysisSummary || healthCopy.analysisFallback ? latestHealthCheck.analysisSummary || healthCopy.analysisFallback
: ''; : '';
const timelineEntries = useMemo(() => { const timelineEntries = useMemo(() => {
const history = plant.wateringHistory && plant.wateringHistory.length > 0 const history = plant.wateringHistory && plant.wateringHistory.length > 0
@@ -462,7 +463,10 @@ export default function PlantDetailScreen() {
if (!plant.notificationsEnabled) { if (!plant.notificationsEnabled) {
const granted = await requestPermissions(); const granted = await requestPermissions();
if (!granted) { if (!granted) {
Alert.alert(t.reminder, t.reminderPermissionNeeded); Alert.alert(t.reminder, t.reminderPermissionNeeded, [
{ text: t.cancel, style: 'cancel' },
{ text: t.openSettings, onPress: () => Linking.openSettings() },
]);
return; return;
} }
await scheduleWateringReminder(plant); await scheduleWateringReminder(plant);
@@ -588,9 +592,9 @@ export default function PlantDetailScreen() {
<View style={[styles.healthActionCard, { backgroundColor: colors.surface, borderColor: colors.border }]}> <View style={[styles.healthActionCard, { backgroundColor: colors.surface, borderColor: colors.border }]}>
<View style={styles.healthActionRow}> <View style={styles.healthActionRow}>
<View style={styles.healthActionInfo}> <View style={styles.healthActionInfo}>
<Text style={[styles.healthActionTitle, { color: textOnSurface }]}>{healthCopy.title}</Text> <Text style={[styles.healthActionTitle, { color: textOnSurface }]}>{healthCopy.title}</Text>
<Text style={[styles.healthActionMeta, { color: colors.textMuted }]}>{healthCopy.intro}</Text> <Text style={[styles.healthActionMeta, { color: colors.textMuted }]}>{healthCopy.intro}</Text>
</View> </View>
<TouchableOpacity <TouchableOpacity
style={[ style={[
styles.healthActionBtn, styles.healthActionBtn,
@@ -650,20 +654,20 @@ export default function PlantDetailScreen() {
</View> </View>
</View> </View>
<Text style={[styles.healthTimestamp, { color: colors.textMuted }]}> <Text style={[styles.healthTimestamp, { color: colors.textMuted }]}>
{healthCopy.lastCheck}: {new Date(latestHealthCheck.generatedAt).toLocaleString(locale)} {healthCopy.lastCheck}: {new Date(latestHealthCheck.generatedAt).toLocaleString(locale)}
</Text> </Text>
{latestAnalysisSummary ? ( {latestAnalysisSummary ? (
<View style={[styles.healthAnalysisBox, { backgroundColor: colors.surfaceMuted, borderColor: colors.border }]}> <View style={[styles.healthAnalysisBox, { backgroundColor: colors.surfaceMuted, borderColor: colors.border }]}>
<Text style={[styles.healthListTitle, { color: textOnSurface }]}>{healthCopy.analysisTitle}</Text> <Text style={[styles.healthListTitle, { color: textOnSurface }]}>{healthCopy.analysisTitle}</Text>
<Text style={[styles.healthAnalysisText, { color: colors.textSecondary }]}> <Text style={[styles.healthAnalysisText, { color: colors.textSecondary }]}>
{latestAnalysisSummary} {latestAnalysisSummary}
</Text> </Text>
</View> </View>
) : null} ) : null}
<View style={styles.healthListBlock}> <View style={styles.healthListBlock}>
<Text style={[styles.healthListTitle, { color: textOnSurface }]}>{healthCopy.issuesTitle}</Text> <Text style={[styles.healthListTitle, { color: textOnSurface }]}>{healthCopy.issuesTitle}</Text>
{latestHealthCheck.likelyIssues.map((issue, index) => ( {latestHealthCheck.likelyIssues.map((issue, index) => (
<View key={`${issue.title}-${index}`} style={styles.healthIssueWrap}> <View key={`${issue.title}-${index}`} style={styles.healthIssueWrap}>
@@ -1131,20 +1135,20 @@ const styles = StyleSheet.create({
healthTimestamp: { healthTimestamp: {
fontSize: 11, fontSize: 11,
}, },
healthListBlock: { healthListBlock: {
gap: 8, gap: 8,
}, },
healthAnalysisBox: { healthAnalysisBox: {
borderRadius: 16, borderRadius: 16,
borderWidth: 1, borderWidth: 1,
padding: 12, padding: 12,
gap: 6, gap: 6,
}, },
healthAnalysisText: { healthAnalysisText: {
fontSize: 12, fontSize: 12,
lineHeight: 19, lineHeight: 19,
}, },
healthListTitle: { healthListTitle: {
fontSize: 13, fontSize: 13,
fontWeight: '700', fontWeight: '700',
}, },

View File

@@ -69,6 +69,9 @@ export const initDatabase = (): void => {
experience_level TEXT, experience_level TEXT,
lexicon_explored INTEGER NOT NULL DEFAULT 0, lexicon_explored INTEGER NOT NULL DEFAULT 0,
customization_done INTEGER NOT NULL DEFAULT 0, customization_done INTEGER NOT NULL DEFAULT 0,
scan_done INTEGER NOT NULL DEFAULT 0,
reminder_done INTEGER NOT NULL DEFAULT 0,
collection_done INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')), created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')) updated_at TEXT NOT NULL DEFAULT (datetime('now'))
); );
@@ -116,6 +119,17 @@ export const initDatabase = (): void => {
db.runSync("ALTER TABLE plants ADD COLUMN health_checks TEXT NOT NULL DEFAULT '[]'"); db.runSync("ALTER TABLE plants ADD COLUMN health_checks TEXT NOT NULL DEFAULT '[]'");
} catch (_) { /* column already exists */ } } catch (_) { /* column already exists */ }
// Migration: add persistent onboarding checklist columns to existing databases
try {
db.runSync('ALTER TABLE user_onboarding_profile ADD COLUMN scan_done INTEGER NOT NULL DEFAULT 0');
} catch (_) { /* column already exists */ }
try {
db.runSync('ALTER TABLE user_onboarding_profile ADD COLUMN reminder_done INTEGER NOT NULL DEFAULT 0');
} catch (_) { /* column already exists */ }
try {
db.runSync('ALTER TABLE user_onboarding_profile ADD COLUMN collection_done INTEGER NOT NULL DEFAULT 0');
} catch (_) { /* column already exists */ }
repairForeignKeyState(db); repairForeignKeyState(db);
_isDatabaseInitialized = true; _isDatabaseInitialized = true;
@@ -252,6 +266,9 @@ export const OnboardingProfileDb = {
experience_level: string | null; experience_level: string | null;
lexicon_explored: number; lexicon_explored: number;
customization_done: number; customization_done: number;
scan_done: number;
reminder_done: number;
collection_done: number;
}>('SELECT * FROM user_onboarding_profile WHERE user_id = ?', [userId])!; }>('SELECT * FROM user_onboarding_profile WHERE user_id = ?', [userId])!;
}, },
@@ -314,6 +331,42 @@ export const OnboardingProfileDb = {
[userId, done ? 1 : 0], [userId, done ? 1 : 0],
); );
}, },
setScanDone(userId: number, done: boolean) {
const db = getDb();
db.runSync(
`INSERT INTO user_onboarding_profile (user_id, scan_done, updated_at)
VALUES (?, ?, datetime('now'))
ON CONFLICT(user_id) DO UPDATE SET
scan_done = excluded.scan_done,
updated_at = datetime('now')`,
[userId, done ? 1 : 0],
);
},
setReminderDone(userId: number, done: boolean) {
const db = getDb();
db.runSync(
`INSERT INTO user_onboarding_profile (user_id, reminder_done, updated_at)
VALUES (?, ?, datetime('now'))
ON CONFLICT(user_id) DO UPDATE SET
reminder_done = excluded.reminder_done,
updated_at = datetime('now')`,
[userId, done ? 1 : 0],
);
},
setCollectionDone(userId: number, done: boolean) {
const db = getDb();
db.runSync(
`INSERT INTO user_onboarding_profile (user_id, collection_done, updated_at)
VALUES (?, ?, datetime('now'))
ON CONFLICT(user_id) DO UPDATE SET
collection_done = excluded.collection_done,
updated_at = datetime('now')`,
[userId, done ? 1 : 0],
);
},
}; };
const DEFAULT_CARE_INFO: CareInfo = { const DEFAULT_CARE_INFO: CareInfo = {

View File

@@ -1,21 +1,31 @@
import { OnboardingProfileDb } from './database'; import { OnboardingProfileDb } from './database';
export type PersistentOnboardingStep = 'lexicon' | 'customize'; export type PersistentOnboardingStep = 'lexicon' | 'customize' | 'scan' | 'reminder' | 'collection';
const STEP_COLUMN: Record<PersistentOnboardingStep, 'lexicon_explored' | 'customization_done' | 'scan_done' | 'reminder_done' | 'collection_done'> = {
lexicon: 'lexicon_explored',
customize: 'customization_done',
scan: 'scan_done',
reminder: 'reminder_done',
collection: 'collection_done',
};
const STEP_SETTER: Record<PersistentOnboardingStep, (userId: number, done: boolean) => void> = {
lexicon: OnboardingProfileDb.setLexiconExplored,
customize: OnboardingProfileDb.setCustomizationDone,
scan: OnboardingProfileDb.setScanDone,
reminder: OnboardingProfileDb.setReminderDone,
collection: OnboardingProfileDb.setCollectionDone,
};
export const OnboardingProgressService = { export const OnboardingProgressService = {
isStepCompleted(userId: number, step: PersistentOnboardingStep): boolean { isStepCompleted(userId: number, step: PersistentOnboardingStep): boolean {
const profile = OnboardingProfileDb.get(userId); const profile = OnboardingProfileDb.get(userId);
return step === 'lexicon' return profile[STEP_COLUMN[step]] === 1;
? profile.lexicon_explored === 1
: profile.customization_done === 1;
}, },
completeStep(userId: number, step: PersistentOnboardingStep): void { completeStep(userId: number, step: PersistentOnboardingStep): void {
if (step === 'lexicon') { STEP_SETTER[step](userId, true);
OnboardingProfileDb.setLexiconExplored(userId, true);
return;
}
OnboardingProfileDb.setCustomizationDone(userId, true);
}, },
getSignals(userId: number) { getSignals(userId: number) {
@@ -23,6 +33,9 @@ export const OnboardingProgressService = {
return { return {
lexiconExplored: profile.lexicon_explored === 1, lexiconExplored: profile.lexicon_explored === 1,
customizationDone: profile.customization_done === 1, customizationDone: profile.customization_done === 1,
scanDone: profile.scan_done === 1,
reminderDone: profile.reminder_done === 1,
collectionDone: profile.collection_done === 1,
}; };
}, },

View File

@@ -215,6 +215,7 @@ export const translations = {
reminderOn: "Aktiviert", reminderOn: "Aktiviert",
reminderOff: "Deaktiviert", reminderOff: "Deaktiviert",
reminderPermissionNeeded: "Berechtigung für Benachrichtigungen erforderlich.", reminderPermissionNeeded: "Berechtigung für Benachrichtigungen erforderlich.",
openSettings: "Zu den Einstellungen",
// Gallery // Gallery
galleryTitle: "Galerie", galleryTitle: "Galerie",
@@ -487,6 +488,7 @@ registerToSave: "Sign up to save",
reminderOn: "Enabled", reminderOn: "Enabled",
reminderOff: "Disabled", reminderOff: "Disabled",
reminderPermissionNeeded: "Notification permission required.", reminderPermissionNeeded: "Notification permission required.",
openSettings: "Open Settings",
// Gallery // Gallery
galleryTitle: "Gallery", galleryTitle: "Gallery",
@@ -759,6 +761,7 @@ registerToSave: "Regístrate para guardar",
reminderOn: "Activado", reminderOn: "Activado",
reminderOff: "Desactivado", reminderOff: "Desactivado",
reminderPermissionNeeded: "Permiso de notificación requerido.", reminderPermissionNeeded: "Permiso de notificación requerido.",
openSettings: "Abrir Ajustes",
// Gallery // Gallery
galleryTitle: "Galería", galleryTitle: "Galería",