Onboarding

This commit is contained in:
2026-04-22 21:37:52 +02:00
parent c16fee77af
commit 3e9f863121
21 changed files with 2524 additions and 184 deletions

View File

@@ -10,55 +10,167 @@ import {
View,
Dimensions,
} from 'react-native';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { useApp } from '../../context/AppContext';
import { useColors } from '../../constants/Colors';
import { ThemeBackdrop } from '../../components/ThemeBackdrop';
import { SafeImage } from '../../components/SafeImage';
import { Plant } from '../../types';
import { useCoachMarks } from '../../context/CoachMarksContext';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router';
import { useFocusEffect } from '@react-navigation/native';
import { Ionicons } from '@expo/vector-icons';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { usePostHog } from 'posthog-react-native';
import { useApp } from '../../context/AppContext';
import { useColors } from '../../constants/Colors';
import { ThemeBackdrop } from '../../components/ThemeBackdrop';
import { SafeImage } from '../../components/SafeImage';
import { Plant } from '../../types';
import { useCoachMarks } from '../../context/CoachMarksContext';
import { OnboardingProgressService } from '../../services/onboardingProgressService';
const { width: SCREEN_W, height: SCREEN_H } = Dimensions.get('window');
type FilterKey = 'all' | 'today' | 'week' | 'healthy' | 'dormant';
const DAY_MS = 24 * 60 * 60 * 1000;
const CONTENT_BOTTOM_PADDING = 12;
const FAB_BOTTOM_OFFSET = 16;
function OnboardingChecklist({ plantsCount, colors, router, t }: { plantsCount: number; colors: any; router: any; t: any }) {
const checklist = [
{ id: 'scan', label: t.stepScan, completed: plantsCount > 0, icon: 'camera-outline', route: '/scanner' },
{ id: 'lexicon', label: t.stepLexicon, completed: false, icon: 'search-outline', route: '/lexicon' },
{ id: 'theme', label: t.stepTheme, completed: false, icon: 'color-palette-outline', route: '/profile/preferences' },
];
return (
<View style={[styles.checklistCard, { backgroundColor: colors.surface, borderColor: colors.border }]}>
<Text style={[styles.checklistTitle, { color: colors.text }]}>{t.nextStepsTitle}</Text>
<View style={styles.checklistGrid}>
{checklist.map((item) => (
<TouchableOpacity
key={item.id}
style={styles.checklistItem}
onPress={() => {
if (item.id === 'theme') {
router.push('/profile/preferences');
} else if (item.id === 'scan') {
router.push('/scanner');
} else if (item.id === 'lexicon') {
router.push('/lexicon');
} else {
router.push(item.route);
}
}}
disabled={item.completed}
>
<View style={[styles.checkIcon, { backgroundColor: item.completed ? colors.successSoft : colors.surfaceMuted }]}>
<Ionicons
const DAY_MS = 24 * 60 * 60 * 1000;
const CONTENT_BOTTOM_PADDING = 12;
const FAB_BOTTOM_OFFSET = 16;
type OnboardingStepId = 'scan' | 'reminder' | 'lexicon' | 'theme' | 'collection';
function OnboardingChecklist({
plants,
appearanceMode,
colorPalette,
lexiconExplored,
customizationDone,
colors,
router,
t,
getLexiconSearchHistory,
registerLayout,
posthog,
}: {
plants: Plant[];
appearanceMode: string;
colorPalette: string;
lexiconExplored: boolean;
customizationDone: boolean;
colors: any;
router: any;
t: any;
getLexiconSearchHistory: () => string[];
registerLayout: (key: string, layout: { x: number; y: number; width: number; height: number }) => void;
posthog: any;
}) {
const cardRef = useRef<View>(null);
const previousProgressRef = useRef<number | null>(null);
const lexiconHistoryCount = getLexiconSearchHistory().length;
const plantsCount = plants.length;
const reminderReady = plants.some((plant) => Boolean(plant.notificationsEnabled));
const themeCustomized = customizationDone || appearanceMode !== 'system' || colorPalette !== 'forest';
const lexiconCompleted = lexiconExplored || lexiconHistoryCount > 0;
const checklist = [
{ id: 'scan' as const, label: t.stepScan, completed: plantsCount > 0, icon: 'camera-outline' },
{ id: 'reminder' as const, label: t.stepReminder, completed: reminderReady, icon: 'notifications-outline' },
{ id: 'lexicon' as const, label: t.stepLexicon, completed: lexiconCompleted, icon: 'search-outline' },
{ id: 'theme' as const, label: t.stepTheme, completed: themeCustomized, icon: 'color-palette-outline' },
{ id: 'collection' as const, label: t.stepCollection, completed: plantsCount >= 3, icon: 'albums-outline' },
];
const completedCount = checklist.filter((item) => item.completed).length;
const progressRatio = completedCount / checklist.length;
const progressWidth: `${number}%` = completedCount === 0 ? '0%' : `${Math.max(progressRatio * 100, 12)}%`;
const nextItem = checklist.find((item) => !item.completed) ?? null;
useEffect(() => {
if (previousProgressRef.current === completedCount) return;
previousProgressRef.current = completedCount;
posthog.capture('onboarding_progress_updated', {
completed_count: completedCount,
total_count: checklist.length,
next_step_id: nextItem?.id ?? 'complete',
});
}, [completedCount, checklist.length, nextItem?.id, posthog]);
if (completedCount === checklist.length) {
return null;
}
const navigateToStep = (stepId: OnboardingStepId) => {
posthog.capture('onboarding_step_opened', {
step_id: stepId,
completed_count: completedCount,
});
if (stepId === 'scan' || stepId === 'collection') {
router.push('/scanner');
return;
}
if (stepId === 'reminder') {
if (plants[0]?.id) {
router.push(`/plant/${plants[0].id}`);
} else {
router.push('/scanner');
}
return;
}
if (stepId === 'lexicon') {
router.push('/lexicon');
return;
}
router.push('/onboarding/customize');
};
return (
<View
ref={cardRef}
style={[styles.checklistCard, { backgroundColor: colors.surface, borderColor: colors.border }]}
onLayout={() => {
cardRef.current?.measureInWindow((x, y, width, height) => {
registerLayout('onboarding_checklist', { x, y, width, height });
});
}}
>
<View style={styles.checklistHeader}>
<View style={styles.checklistHeaderCopy}>
<Text style={[styles.checklistEyebrow, { color: colors.primary }]}>
{t.onboardingChecklistIntro}
</Text>
<Text style={[styles.checklistTitle, { color: colors.text }]}>{t.onboardingChecklistTitle}</Text>
<Text style={[styles.checklistSubtitle, { color: colors.textSecondary }]}>
{(nextItem ? t.onboardingChecklistNextLabel : t.onboardingChecklistDone).replace('{0}', nextItem?.label ?? '')}
</Text>
</View>
<View style={[styles.checklistProgressPill, { backgroundColor: colors.primarySoft }]}>
<Text style={[styles.checklistProgressText, { color: colors.primaryDark }]}>
{t.onboardingChecklistProgress
.replace('{0}', completedCount.toString())
.replace('{1}', checklist.length.toString())}
</Text>
</View>
</View>
<View style={[styles.progressTrack, { backgroundColor: colors.surfaceMuted }]}>
<View
style={[
styles.progressFill,
{
backgroundColor: colors.primary,
width: progressWidth,
},
]}
/>
</View>
<View style={styles.checklistGrid}>
{checklist.map((item) => (
<TouchableOpacity
key={item.id}
style={styles.checklistItem}
onPress={() => navigateToStep(item.id)}
disabled={item.completed}
>
<View style={[styles.checkIcon, { backgroundColor: item.completed ? colors.successSoft : colors.surfaceMuted }]}>
<Ionicons
name={item.completed ? 'checkmark-circle' : item.icon as any}
size={18}
color={item.completed ? colors.success : colors.textMuted}
@@ -95,29 +207,50 @@ const getDaysUntilWatering = (plant: Plant): number => {
return Math.ceil(remainingMs / DAY_MS);
};
export default function HomeScreen() {
const {
plants,
isLoadingPlants,
profileImageUri,
profileName,
billingSummary,
isLoadingBilling,
language,
t,
isDarkMode,
colorPalette,
} = useApp();
const colors = useColors(isDarkMode, colorPalette);
const router = useRouter();
const insets = useSafeAreaInsets();
const [activeFilter, setActiveFilter] = useState<FilterKey>('all');
const { registerLayout, startTour } = useCoachMarks();
const fabRef = useRef<View>(null);
// Tour nach Registrierung starten
useEffect(() => {
const checkTour = async () => {
export default function HomeScreen() {
const {
session,
plants,
isLoadingPlants,
profileImageUri,
profileName,
billingSummary,
isLoadingBilling,
t,
isDarkMode,
appearanceMode,
colorPalette,
getLexiconSearchHistory,
} = useApp();
const colors = useColors(isDarkMode, colorPalette);
const router = useRouter();
const insets = useSafeAreaInsets();
const [activeFilter, setActiveFilter] = useState<FilterKey>('all');
const [onboardingSignals, setOnboardingSignals] = useState({
lexiconExplored: false,
customizationDone: false,
});
const { registerLayout, startTour } = useCoachMarks();
const fabRef = useRef<View>(null);
const posthog = usePostHog();
useFocusEffect(
React.useCallback(() => {
if (!session?.userId) {
setOnboardingSignals({
lexiconExplored: false,
customizationDone: false,
});
return;
}
setOnboardingSignals(OnboardingProgressService.getSignals(session.userId));
}, [session?.userId]),
);
// Tour nach Registrierung starten
useEffect(() => {
const checkTour = async () => {
const flag = await AsyncStorage.getItem('greenlens_show_tour');
if (flag !== 'true') return;
await AsyncStorage.removeItem('greenlens_show_tour');
@@ -143,17 +276,23 @@ export default function HomeScreen() {
description: t.tourSearchDesc,
tooltipSide: 'above',
},
{
elementKey: 'tab_profile',
title: t.tourProfileTitle,
description: t.tourProfileDesc,
tooltipSide: 'above',
},
]);
}, 1000);
};
checkTour();
}, []);
{
elementKey: 'tab_profile',
title: t.tourProfileTitle,
description: t.tourProfileDesc,
tooltipSide: 'above',
},
{
elementKey: 'onboarding_checklist',
title: t.tourChecklistTitle,
description: t.tourChecklistDesc,
tooltipSide: 'below',
},
]);
}, 1000);
};
checkTour();
}, [registerLayout, startTour, t.tourChecklistDesc, t.tourChecklistTitle, t.tourFabDesc, t.tourFabTitle, t.tourProfileDesc, t.tourProfileTitle, t.tourSearchDesc, t.tourSearchTitle]);
const copy = t;
const greetingText = useMemo(() => {
@@ -326,9 +465,19 @@ export default function HomeScreen() {
/>
</View>
{plants.length === 0 && (
<OnboardingChecklist plantsCount={plants.length} colors={colors} router={router} t={t} />
)}
<OnboardingChecklist
plants={plants}
appearanceMode={appearanceMode}
colorPalette={colorPalette}
lexiconExplored={onboardingSignals.lexiconExplored}
customizationDone={onboardingSignals.customizationDone}
colors={colors}
router={router}
t={t}
getLexiconSearchHistory={getLexiconSearchHistory}
registerLayout={registerLayout}
posthog={posthog}
/>
<ScrollView
horizontal
@@ -812,20 +961,59 @@ const styles = StyleSheet.create({
shadowOffset: { width: 0, height: 5 },
elevation: 9,
},
checklistCard: {
borderRadius: 24,
borderWidth: 1,
padding: 20,
marginBottom: 20,
},
checklistTitle: {
fontSize: 16,
fontWeight: '700',
marginBottom: 16,
},
checklistGrid: {
gap: 12,
},
checklistCard: {
borderRadius: 24,
borderWidth: 1,
padding: 20,
marginBottom: 20,
},
checklistHeader: {
flexDirection: 'row',
alignItems: 'flex-start',
justifyContent: 'space-between',
gap: 12,
marginBottom: 14,
},
checklistHeaderCopy: {
flex: 1,
gap: 4,
},
checklistEyebrow: {
fontSize: 12,
fontWeight: '700',
letterSpacing: 0.4,
textTransform: 'uppercase',
},
checklistTitle: {
fontSize: 16,
fontWeight: '700',
},
checklistSubtitle: {
fontSize: 13,
lineHeight: 18,
},
checklistProgressPill: {
borderRadius: 999,
paddingHorizontal: 10,
paddingVertical: 6,
},
checklistProgressText: {
fontSize: 12,
fontWeight: '700',
},
progressTrack: {
height: 8,
borderRadius: 999,
overflow: 'hidden',
marginBottom: 16,
},
progressFill: {
height: '100%',
borderRadius: 999,
},
checklistGrid: {
gap: 12,
},
checklistItem: {
flexDirection: 'row',
alignItems: 'center',