Onboarding
This commit is contained in:
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user