Onboarding
This commit is contained in:
@@ -22,7 +22,7 @@ import { ThemeBackdrop } from '../../components/ThemeBackdrop';
|
|||||||
import { SafeImage } from '../../components/SafeImage';
|
import { SafeImage } from '../../components/SafeImage';
|
||||||
import { Plant } from '../../types';
|
import { Plant } from '../../types';
|
||||||
import { useCoachMarks } from '../../context/CoachMarksContext';
|
import { useCoachMarks } from '../../context/CoachMarksContext';
|
||||||
import { OnboardingProgressService } from '../../services/onboardingProgressService';
|
import { OnboardingProgressService, PersistentOnboardingStep } from '../../services/onboardingProgressService';
|
||||||
|
|
||||||
const { width: SCREEN_W, height: SCREEN_H } = Dimensions.get('window');
|
const { width: SCREEN_W, height: SCREEN_H } = Dimensions.get('window');
|
||||||
|
|
||||||
@@ -40,6 +40,10 @@ function OnboardingChecklist({
|
|||||||
colorPalette,
|
colorPalette,
|
||||||
lexiconExplored,
|
lexiconExplored,
|
||||||
customizationDone,
|
customizationDone,
|
||||||
|
scanDone,
|
||||||
|
reminderDone,
|
||||||
|
collectionDone,
|
||||||
|
userId,
|
||||||
colors,
|
colors,
|
||||||
router,
|
router,
|
||||||
t,
|
t,
|
||||||
@@ -52,6 +56,10 @@ function OnboardingChecklist({
|
|||||||
colorPalette: string;
|
colorPalette: string;
|
||||||
lexiconExplored: boolean;
|
lexiconExplored: boolean;
|
||||||
customizationDone: boolean;
|
customizationDone: boolean;
|
||||||
|
scanDone: boolean;
|
||||||
|
reminderDone: boolean;
|
||||||
|
collectionDone: boolean;
|
||||||
|
userId: number | null;
|
||||||
colors: any;
|
colors: any;
|
||||||
router: any;
|
router: any;
|
||||||
t: any;
|
t: any;
|
||||||
@@ -61,17 +69,52 @@ function OnboardingChecklist({
|
|||||||
}) {
|
}) {
|
||||||
const cardRef = useRef<View>(null);
|
const cardRef = useRef<View>(null);
|
||||||
const previousProgressRef = useRef<number | null>(null);
|
const previousProgressRef = useRef<number | null>(null);
|
||||||
|
const persistedStepsRef = useRef<Set<string>>(new Set());
|
||||||
const lexiconHistoryCount = getLexiconSearchHistory().length;
|
const lexiconHistoryCount = getLexiconSearchHistory().length;
|
||||||
const plantsCount = plants.length;
|
const plantsCount = plants.length;
|
||||||
const reminderReady = plants.some((plant) => Boolean(plant.notificationsEnabled));
|
const reminderLive = plants.some((plant) => Boolean(plant.notificationsEnabled));
|
||||||
const themeCustomized = customizationDone || appearanceMode !== 'system' || colorPalette !== 'forest';
|
const themeLive = appearanceMode !== 'system' || colorPalette !== 'forest';
|
||||||
const lexiconCompleted = lexiconExplored || lexiconHistoryCount > 0;
|
const lexiconLive = lexiconHistoryCount > 0;
|
||||||
|
const scanLive = plantsCount > 0;
|
||||||
|
const collectionLive = plantsCount >= 3;
|
||||||
|
|
||||||
|
// Once a step is reached live, persist it so it never reverts (e.g. deleting a plant
|
||||||
|
// or disabling notifications previously flipped the checklist backward).
|
||||||
|
useEffect(() => {
|
||||||
|
if (!userId) return;
|
||||||
|
const toPersist: Array<[PersistentOnboardingStep, boolean]> = [
|
||||||
|
['scan', scanLive && !scanDone],
|
||||||
|
['reminder', reminderLive && !reminderDone],
|
||||||
|
['customize', themeLive && !customizationDone],
|
||||||
|
['lexicon', lexiconLive && !lexiconExplored],
|
||||||
|
['collection', collectionLive && !collectionDone],
|
||||||
|
];
|
||||||
|
for (const [step, shouldPersist] of toPersist) {
|
||||||
|
if (shouldPersist && !persistedStepsRef.current.has(step)) {
|
||||||
|
persistedStepsRef.current.add(step);
|
||||||
|
OnboardingProgressService.completeStep(userId, step);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
userId,
|
||||||
|
scanLive,
|
||||||
|
scanDone,
|
||||||
|
reminderLive,
|
||||||
|
reminderDone,
|
||||||
|
themeLive,
|
||||||
|
customizationDone,
|
||||||
|
lexiconLive,
|
||||||
|
lexiconExplored,
|
||||||
|
collectionLive,
|
||||||
|
collectionDone,
|
||||||
|
]);
|
||||||
|
|
||||||
const checklist = [
|
const checklist = [
|
||||||
{ id: 'scan' as const, label: t.stepScan, completed: plantsCount > 0, icon: 'camera-outline' },
|
{ id: 'scan' as const, label: t.stepScan, completed: scanDone || scanLive, icon: 'camera-outline' },
|
||||||
{ id: 'reminder' as const, label: t.stepReminder, completed: reminderReady, icon: 'notifications-outline' },
|
{ id: 'reminder' as const, label: t.stepReminder, completed: reminderDone || reminderLive, icon: 'notifications-outline' },
|
||||||
{ id: 'lexicon' as const, label: t.stepLexicon, completed: lexiconCompleted, icon: 'search-outline' },
|
{ id: 'lexicon' as const, label: t.stepLexicon, completed: lexiconExplored || lexiconLive, icon: 'search-outline' },
|
||||||
{ id: 'theme' as const, label: t.stepTheme, completed: themeCustomized, icon: 'color-palette-outline' },
|
{ id: 'theme' as const, label: t.stepTheme, completed: customizationDone || themeLive, icon: 'color-palette-outline' },
|
||||||
{ id: 'collection' as const, label: t.stepCollection, completed: plantsCount >= 3, icon: 'albums-outline' },
|
{ id: 'collection' as const, label: t.stepCollection, completed: collectionDone || collectionLive, icon: 'albums-outline' },
|
||||||
];
|
];
|
||||||
const completedCount = checklist.filter((item) => item.completed).length;
|
const completedCount = checklist.filter((item) => item.completed).length;
|
||||||
const progressRatio = completedCount / checklist.length;
|
const progressRatio = completedCount / checklist.length;
|
||||||
@@ -229,6 +272,9 @@ export default function HomeScreen() {
|
|||||||
const [onboardingSignals, setOnboardingSignals] = useState({
|
const [onboardingSignals, setOnboardingSignals] = useState({
|
||||||
lexiconExplored: false,
|
lexiconExplored: false,
|
||||||
customizationDone: false,
|
customizationDone: false,
|
||||||
|
scanDone: false,
|
||||||
|
reminderDone: false,
|
||||||
|
collectionDone: false,
|
||||||
});
|
});
|
||||||
const { layouts, registerLayout, startTour } = useCoachMarks();
|
const { layouts, registerLayout, startTour } = useCoachMarks();
|
||||||
const fabRef = useRef<View>(null);
|
const fabRef = useRef<View>(null);
|
||||||
@@ -241,6 +287,9 @@ export default function HomeScreen() {
|
|||||||
setOnboardingSignals({
|
setOnboardingSignals({
|
||||||
lexiconExplored: false,
|
lexiconExplored: false,
|
||||||
customizationDone: false,
|
customizationDone: false,
|
||||||
|
scanDone: false,
|
||||||
|
reminderDone: false,
|
||||||
|
collectionDone: false,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -500,6 +549,10 @@ export default function HomeScreen() {
|
|||||||
colorPalette={colorPalette}
|
colorPalette={colorPalette}
|
||||||
lexiconExplored={onboardingSignals.lexiconExplored}
|
lexiconExplored={onboardingSignals.lexiconExplored}
|
||||||
customizationDone={onboardingSignals.customizationDone}
|
customizationDone={onboardingSignals.customizationDone}
|
||||||
|
scanDone={onboardingSignals.scanDone}
|
||||||
|
reminderDone={onboardingSignals.reminderDone}
|
||||||
|
collectionDone={onboardingSignals.collectionDone}
|
||||||
|
userId={session?.userId ?? null}
|
||||||
colors={colors}
|
colors={colors}
|
||||||
router={router}
|
router={router}
|
||||||
t={t}
|
t={t}
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user