Onboarding
This commit is contained in:
1523
app/(tabs)/index.tsx
1523
app/(tabs)/index.tsx
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,7 @@ import {
|
||||
Modal,
|
||||
Alert,
|
||||
Share,
|
||||
Linking,
|
||||
} from 'react-native';
|
||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
@@ -57,23 +58,23 @@ const HEALTH_CHECK_CREDIT_COST = 2;
|
||||
const getHealthCopy = (language: 'de' | 'en' | 'es') => {
|
||||
if (language === 'de') {
|
||||
return {
|
||||
title: 'Health Check',
|
||||
action: 'Health-Scan starten',
|
||||
running: 'Neues Foto wird analysiert...',
|
||||
cost: `Kosten: ${HEALTH_CHECK_CREDIT_COST} Credits`,
|
||||
intro: 'Fotografiere die ganze Pflanze plus auffaellige Blaetter. Danach bekommst du Diagnose, Dringlichkeit und einen konkreten Pflegeplan.',
|
||||
creditsLabel: 'Credits',
|
||||
title: 'Health Check',
|
||||
action: 'Health-Scan starten',
|
||||
running: 'Neues Foto wird analysiert...',
|
||||
cost: `Kosten: ${HEALTH_CHECK_CREDIT_COST} Credits`,
|
||||
intro: 'Fotografiere die ganze Pflanze plus auffaellige Blaetter. Danach bekommst du Diagnose, Dringlichkeit und einen konkreten Pflegeplan.',
|
||||
creditsLabel: 'Credits',
|
||||
managePlan: 'Plan verwalten',
|
||||
noCreditsTitle: 'Nicht genug Credits',
|
||||
noCreditsMessage: `Du brauchst ${HEALTH_CHECK_CREDIT_COST} Credits fuer den Health-Check.`,
|
||||
insufficientInline: 'Nicht genug Credits fuer den Health-Check.',
|
||||
timeoutInline: 'Health-Check Timeout. Bitte erneut versuchen.',
|
||||
providerInline: 'Health-Check ist gerade nicht verfuegbar.',
|
||||
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.',
|
||||
issuesTitle: 'Wahrscheinlichste Ursachen',
|
||||
actionsTitle: 'Sofortmassnahmen',
|
||||
planTitle: '7-Tage-Plan',
|
||||
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.',
|
||||
issuesTitle: 'Wahrscheinlichste Ursachen',
|
||||
actionsTitle: 'Sofortmassnahmen',
|
||||
planTitle: '7-Tage-Plan',
|
||||
scoreLabel: 'Gesundheits-Score',
|
||||
healthy: 'Stabil',
|
||||
watch: 'Beobachten',
|
||||
@@ -84,21 +85,21 @@ const getHealthCopy = (language: 'de' | 'en' | 'es') => {
|
||||
|
||||
if (language === 'es') {
|
||||
return {
|
||||
title: 'Health Check',
|
||||
action: 'Iniciar health-scan',
|
||||
running: 'Analizando foto nueva...',
|
||||
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.',
|
||||
creditsLabel: 'Creditos',
|
||||
title: 'Health Check',
|
||||
action: 'Iniciar health-scan',
|
||||
running: 'Analizando foto nueva...',
|
||||
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.',
|
||||
creditsLabel: 'Creditos',
|
||||
managePlan: 'Gestionar plan',
|
||||
noCreditsTitle: 'Creditos insuficientes',
|
||||
noCreditsMessage: `Necesitas ${HEALTH_CHECK_CREDIT_COST} creditos para el health-check.`,
|
||||
insufficientInline: 'No hay creditos suficientes para el health-check.',
|
||||
timeoutInline: 'Health-check agotado por tiempo. Intenta de nuevo.',
|
||||
providerInline: 'Health-check no disponible ahora.',
|
||||
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.',
|
||||
issuesTitle: 'Causas mas probables',
|
||||
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.',
|
||||
issuesTitle: 'Causas mas probables',
|
||||
actionsTitle: 'Acciones inmediatas',
|
||||
planTitle: 'Plan de 7 dias',
|
||||
scoreLabel: 'Puntaje de salud',
|
||||
@@ -110,11 +111,11 @@ const getHealthCopy = (language: 'de' | 'en' | 'es') => {
|
||||
}
|
||||
|
||||
return {
|
||||
title: 'Health Check',
|
||||
action: 'Start health scan',
|
||||
running: 'Analyzing new photo...',
|
||||
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.',
|
||||
title: 'Health Check',
|
||||
action: 'Start health scan',
|
||||
running: 'Analyzing new photo...',
|
||||
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.',
|
||||
creditsLabel: 'Credits',
|
||||
managePlan: 'Manage plan',
|
||||
noCreditsTitle: 'Not enough credits',
|
||||
@@ -122,9 +123,9 @@ const getHealthCopy = (language: 'de' | 'en' | 'es') => {
|
||||
insufficientInline: 'Not enough credits for the health check.',
|
||||
timeoutInline: 'Health check timed out. Please try again.',
|
||||
providerInline: 'Health check is unavailable right now.',
|
||||
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.',
|
||||
issuesTitle: 'Most likely causes',
|
||||
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.',
|
||||
issuesTitle: 'Most likely causes',
|
||||
actionsTitle: 'Actions now',
|
||||
planTitle: '7-day plan',
|
||||
scoreLabel: 'Health score',
|
||||
@@ -243,7 +244,7 @@ export default function PlantDetailScreen() {
|
||||
: colors.dangerSoft
|
||||
)
|
||||
: colors.surfaceMuted;
|
||||
const latestStatusColor = latestHealthCheck
|
||||
const latestStatusColor = latestHealthCheck
|
||||
? (
|
||||
latestHealthCheck.status === 'healthy'
|
||||
? colors.success
|
||||
@@ -251,10 +252,10 @@ export default function PlantDetailScreen() {
|
||||
? colors.warning
|
||||
: colors.danger
|
||||
)
|
||||
: colors.textMuted;
|
||||
const latestAnalysisSummary = latestHealthCheck
|
||||
? latestHealthCheck.analysisSummary || healthCopy.analysisFallback
|
||||
: '';
|
||||
: colors.textMuted;
|
||||
const latestAnalysisSummary = latestHealthCheck
|
||||
? latestHealthCheck.analysisSummary || healthCopy.analysisFallback
|
||||
: '';
|
||||
|
||||
const timelineEntries = useMemo(() => {
|
||||
const history = plant.wateringHistory && plant.wateringHistory.length > 0
|
||||
@@ -462,7 +463,10 @@ export default function PlantDetailScreen() {
|
||||
if (!plant.notificationsEnabled) {
|
||||
const granted = await requestPermissions();
|
||||
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;
|
||||
}
|
||||
await scheduleWateringReminder(plant);
|
||||
@@ -588,9 +592,9 @@ export default function PlantDetailScreen() {
|
||||
<View style={[styles.healthActionCard, { backgroundColor: colors.surface, borderColor: colors.border }]}>
|
||||
<View style={styles.healthActionRow}>
|
||||
<View style={styles.healthActionInfo}>
|
||||
<Text style={[styles.healthActionTitle, { color: textOnSurface }]}>{healthCopy.title}</Text>
|
||||
<Text style={[styles.healthActionMeta, { color: colors.textMuted }]}>{healthCopy.intro}</Text>
|
||||
</View>
|
||||
<Text style={[styles.healthActionTitle, { color: textOnSurface }]}>{healthCopy.title}</Text>
|
||||
<Text style={[styles.healthActionMeta, { color: colors.textMuted }]}>{healthCopy.intro}</Text>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.healthActionBtn,
|
||||
@@ -650,20 +654,20 @@ export default function PlantDetailScreen() {
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Text style={[styles.healthTimestamp, { color: colors.textMuted }]}>
|
||||
{healthCopy.lastCheck}: {new Date(latestHealthCheck.generatedAt).toLocaleString(locale)}
|
||||
</Text>
|
||||
|
||||
{latestAnalysisSummary ? (
|
||||
<View style={[styles.healthAnalysisBox, { backgroundColor: colors.surfaceMuted, borderColor: colors.border }]}>
|
||||
<Text style={[styles.healthListTitle, { color: textOnSurface }]}>{healthCopy.analysisTitle}</Text>
|
||||
<Text style={[styles.healthAnalysisText, { color: colors.textSecondary }]}>
|
||||
{latestAnalysisSummary}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<View style={styles.healthListBlock}>
|
||||
<Text style={[styles.healthTimestamp, { color: colors.textMuted }]}>
|
||||
{healthCopy.lastCheck}: {new Date(latestHealthCheck.generatedAt).toLocaleString(locale)}
|
||||
</Text>
|
||||
|
||||
{latestAnalysisSummary ? (
|
||||
<View style={[styles.healthAnalysisBox, { backgroundColor: colors.surfaceMuted, borderColor: colors.border }]}>
|
||||
<Text style={[styles.healthListTitle, { color: textOnSurface }]}>{healthCopy.analysisTitle}</Text>
|
||||
<Text style={[styles.healthAnalysisText, { color: colors.textSecondary }]}>
|
||||
{latestAnalysisSummary}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<View style={styles.healthListBlock}>
|
||||
<Text style={[styles.healthListTitle, { color: textOnSurface }]}>{healthCopy.issuesTitle}</Text>
|
||||
{latestHealthCheck.likelyIssues.map((issue, index) => (
|
||||
<View key={`${issue.title}-${index}`} style={styles.healthIssueWrap}>
|
||||
@@ -1131,20 +1135,20 @@ const styles = StyleSheet.create({
|
||||
healthTimestamp: {
|
||||
fontSize: 11,
|
||||
},
|
||||
healthListBlock: {
|
||||
gap: 8,
|
||||
},
|
||||
healthAnalysisBox: {
|
||||
borderRadius: 16,
|
||||
borderWidth: 1,
|
||||
padding: 12,
|
||||
gap: 6,
|
||||
},
|
||||
healthAnalysisText: {
|
||||
fontSize: 12,
|
||||
lineHeight: 19,
|
||||
},
|
||||
healthListTitle: {
|
||||
healthListBlock: {
|
||||
gap: 8,
|
||||
},
|
||||
healthAnalysisBox: {
|
||||
borderRadius: 16,
|
||||
borderWidth: 1,
|
||||
padding: 12,
|
||||
gap: 6,
|
||||
},
|
||||
healthAnalysisText: {
|
||||
fontSize: 12,
|
||||
lineHeight: 19,
|
||||
},
|
||||
healthListTitle: {
|
||||
fontSize: 13,
|
||||
fontWeight: '700',
|
||||
},
|
||||
|
||||
@@ -69,6 +69,9 @@ export const initDatabase = (): void => {
|
||||
experience_level TEXT,
|
||||
lexicon_explored 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')),
|
||||
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 '[]'");
|
||||
} 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);
|
||||
|
||||
_isDatabaseInitialized = true;
|
||||
@@ -252,6 +266,9 @@ export const OnboardingProfileDb = {
|
||||
experience_level: string | null;
|
||||
lexicon_explored: number;
|
||||
customization_done: number;
|
||||
scan_done: number;
|
||||
reminder_done: number;
|
||||
collection_done: number;
|
||||
}>('SELECT * FROM user_onboarding_profile WHERE user_id = ?', [userId])!;
|
||||
},
|
||||
|
||||
@@ -314,6 +331,42 @@ export const OnboardingProfileDb = {
|
||||
[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 = {
|
||||
|
||||
@@ -1,21 +1,31 @@
|
||||
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 = {
|
||||
isStepCompleted(userId: number, step: PersistentOnboardingStep): boolean {
|
||||
const profile = OnboardingProfileDb.get(userId);
|
||||
return step === 'lexicon'
|
||||
? profile.lexicon_explored === 1
|
||||
: profile.customization_done === 1;
|
||||
return profile[STEP_COLUMN[step]] === 1;
|
||||
},
|
||||
|
||||
completeStep(userId: number, step: PersistentOnboardingStep): void {
|
||||
if (step === 'lexicon') {
|
||||
OnboardingProfileDb.setLexiconExplored(userId, true);
|
||||
return;
|
||||
}
|
||||
OnboardingProfileDb.setCustomizationDone(userId, true);
|
||||
STEP_SETTER[step](userId, true);
|
||||
},
|
||||
|
||||
getSignals(userId: number) {
|
||||
@@ -23,6 +33,9 @@ export const OnboardingProgressService = {
|
||||
return {
|
||||
lexiconExplored: profile.lexicon_explored === 1,
|
||||
customizationDone: profile.customization_done === 1,
|
||||
scanDone: profile.scan_done === 1,
|
||||
reminderDone: profile.reminder_done === 1,
|
||||
collectionDone: profile.collection_done === 1,
|
||||
};
|
||||
},
|
||||
|
||||
|
||||
@@ -215,6 +215,7 @@ export const translations = {
|
||||
reminderOn: "Aktiviert",
|
||||
reminderOff: "Deaktiviert",
|
||||
reminderPermissionNeeded: "Berechtigung für Benachrichtigungen erforderlich.",
|
||||
openSettings: "Zu den Einstellungen",
|
||||
|
||||
// Gallery
|
||||
galleryTitle: "Galerie",
|
||||
@@ -487,6 +488,7 @@ registerToSave: "Sign up to save",
|
||||
reminderOn: "Enabled",
|
||||
reminderOff: "Disabled",
|
||||
reminderPermissionNeeded: "Notification permission required.",
|
||||
openSettings: "Open Settings",
|
||||
|
||||
// Gallery
|
||||
galleryTitle: "Gallery",
|
||||
@@ -759,6 +761,7 @@ registerToSave: "Regístrate para guardar",
|
||||
reminderOn: "Activado",
|
||||
reminderOff: "Desactivado",
|
||||
reminderPermissionNeeded: "Permiso de notificación requerido.",
|
||||
openSettings: "Abrir Ajustes",
|
||||
|
||||
// Gallery
|
||||
galleryTitle: "Galería",
|
||||
|
||||
Reference in New Issue
Block a user