[claude] Mobile first-launch onboarding walkthrough (#35) #79

Merged
Rockachopa merged 1 commits from claude/issue-35 into main 2026-03-23 14:31:48 +00:00
3 changed files with 293 additions and 2 deletions

View File

@@ -5,24 +5,50 @@ import {
Inter_700Bold,
useFonts,
} from "@expo-google-fonts/inter";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { Stack } from "expo-router";
import { Stack, router, useSegments } from "expo-router";
import * as SplashScreen from "expo-splash-screen";
import React, { useEffect } from "react";
import React, { useEffect, useState } from "react";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { KeyboardProvider } from "react-native-keyboard-controller";
import { SafeAreaProvider } from "react-native-safe-area-context";
import { ErrorBoundary } from "@/components/ErrorBoundary";
import { TimmyProvider } from "@/context/TimmyContext";
import { ONBOARDING_COMPLETED_KEY } from "@/constants/storage-keys";
SplashScreen.preventAutoHideAsync();
const queryClient = new QueryClient();
function RootLayoutNav() {
const segments = useSegments();
const [onboardingChecked, setOnboardingChecked] = useState(false);
const [needsOnboarding, setNeedsOnboarding] = useState(false);
useEffect(() => {
AsyncStorage.getItem(ONBOARDING_COMPLETED_KEY).then((value) => {
setNeedsOnboarding(value !== "true");
setOnboardingChecked(true);
});
}, []);
useEffect(() => {
if (!onboardingChecked) return;
const inOnboarding = segments[0] === "onboarding";
if (needsOnboarding && !inOnboarding) {
router.replace("/onboarding");
} else if (!needsOnboarding && inOnboarding) {
router.replace("/(tabs)");
}
}, [onboardingChecked, needsOnboarding, segments]);
return (
<Stack screenOptions={{ headerBackTitle: "Back" }}>
<Stack.Screen name="onboarding" options={{ headerShown: false, animation: "none" }} />
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
</Stack>
);

View File

@@ -0,0 +1,264 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
import { Ionicons } from "@expo/vector-icons";
import { router } from "expo-router";
import React, { useCallback, useRef, useState } from "react";
import {
Dimensions,
FlatList,
Platform,
Pressable,
StyleSheet,
Text,
View,
type ViewToken,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { TimmyFace } from "@/components/TimmyFace";
import { Colors } from "@/constants/colors";
import { ONBOARDING_COMPLETED_KEY } from "@/constants/storage-keys";
const C = Colors.dark;
const { width: SCREEN_WIDTH } = Dimensions.get("window");
type Slide = {
id: string;
icon: React.ReactNode;
title: string;
description: string;
};
const slides: Slide[] = [
{
id: "welcome",
icon: <TimmyFace mood="speaking" size={140} />,
title: "Meet Timmy",
description:
"Your AI wizard powered by Lightning.\nAsk questions, get answers — pay only for what you use.",
},
{
id: "voice",
icon: (
<View style={slideStyles.iconCircle}>
<Ionicons name="mic" size={64} color={C.accentGlow} />
</View>
),
title: "Talk, Don't Type",
description:
"Tap the mic and speak naturally.\nTimmy listens, thinks, and responds out loud.",
},
{
id: "lightning",
icon: (
<View style={slideStyles.iconCircle}>
<Ionicons name="flash" size={64} color="#F59E0B" />
</View>
),
title: "Lightning Fast Payments",
description:
"Pay per request with Bitcoin Lightning.\nNo accounts, no subscriptions — just sats.",
},
];
function Dot({ active }: { active: boolean }) {
return (
<View
style={[
styles.dot,
active ? styles.dotActive : styles.dotInactive,
]}
/>
);
}
export default function OnboardingScreen() {
const insets = useSafeAreaInsets();
const [currentIndex, setCurrentIndex] = useState(0);
const flatListRef = useRef<FlatList<Slide>>(null);
const onViewableItemsChanged = useRef(
({ viewableItems }: { viewableItems: ViewToken[] }) => {
if (viewableItems.length > 0 && viewableItems[0].index != null) {
setCurrentIndex(viewableItems[0].index);
}
}
).current;
const viewabilityConfig = useRef({ viewAreaCoveragePercentThreshold: 50 }).current;
const isLastSlide = currentIndex === slides.length - 1;
const handleNext = useCallback(() => {
if (isLastSlide) {
completeOnboarding();
} else {
flatListRef.current?.scrollToIndex({ index: currentIndex + 1, animated: true });
}
}, [currentIndex, isLastSlide]);
const handleSkip = useCallback(() => {
completeOnboarding();
}, []);
const completeOnboarding = async () => {
await AsyncStorage.setItem(ONBOARDING_COMPLETED_KEY, "true");
router.replace("/(tabs)");
};
const renderSlide = ({ item }: { item: Slide }) => (
<View style={[styles.slide, { width: SCREEN_WIDTH }]}>
<View style={styles.slideIconArea}>{item.icon}</View>
<Text style={styles.slideTitle}>{item.title}</Text>
<Text style={styles.slideDescription}>{item.description}</Text>
</View>
);
return (
<View style={[styles.container, { paddingTop: insets.top, paddingBottom: insets.bottom }]}>
{/* Skip button */}
{!isLastSlide && (
<Pressable style={styles.skipButton} onPress={handleSkip}>
<Text style={styles.skipText}>Skip</Text>
</Pressable>
)}
{/* Slides */}
<FlatList
ref={flatListRef}
data={slides}
renderItem={renderSlide}
keyExtractor={(item) => item.id}
horizontal
pagingEnabled
showsHorizontalScrollIndicator={false}
bounces={false}
onViewableItemsChanged={onViewableItemsChanged}
viewabilityConfig={viewabilityConfig}
style={styles.flatList}
/>
{/* Dots + Next/Get Started */}
<View style={styles.footer}>
<View style={styles.dots}>
{slides.map((s, i) => (
<Dot key={s.id} active={i === currentIndex} />
))}
</View>
<Pressable style={styles.nextButton} onPress={handleNext}>
<Text style={styles.nextButtonText}>
{isLastSlide ? "Get Started" : "Next"}
</Text>
{!isLastSlide && (
<Ionicons name="arrow-forward" size={18} color="#fff" />
)}
</Pressable>
</View>
</View>
);
}
const slideStyles = StyleSheet.create({
iconCircle: {
width: 140,
height: 140,
borderRadius: 70,
backgroundColor: C.surfaceElevated,
borderWidth: 1,
borderColor: C.border,
alignItems: "center",
justifyContent: "center",
},
});
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: C.background,
},
skipButton: {
position: "absolute",
top: Platform.OS === "web" ? 20 : 56,
right: 24,
zIndex: 10,
padding: 8,
},
skipText: {
fontSize: 15,
fontFamily: "Inter_500Medium",
color: C.textSecondary,
},
flatList: {
flex: 1,
},
slide: {
flex: 1,
alignItems: "center",
justifyContent: "center",
paddingHorizontal: 40,
},
slideIconArea: {
marginBottom: 40,
alignItems: "center",
justifyContent: "center",
shadowColor: C.accent,
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.25,
shadowRadius: 30,
elevation: 0,
},
slideTitle: {
fontSize: 28,
fontFamily: "Inter_700Bold",
color: C.text,
textAlign: "center",
marginBottom: 16,
letterSpacing: -0.5,
},
slideDescription: {
fontSize: 16,
fontFamily: "Inter_400Regular",
color: C.textSecondary,
textAlign: "center",
lineHeight: 24,
},
footer: {
paddingHorizontal: 24,
paddingBottom: 24,
gap: 24,
alignItems: "center",
},
dots: {
flexDirection: "row",
gap: 8,
},
dot: {
height: 8,
borderRadius: 4,
},
dotActive: {
width: 24,
backgroundColor: C.accent,
},
dotInactive: {
width: 8,
backgroundColor: C.textMuted,
},
nextButton: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: 8,
backgroundColor: C.accent,
paddingVertical: 16,
paddingHorizontal: 32,
borderRadius: 16,
width: "100%",
maxWidth: 320,
},
nextButtonText: {
fontSize: 17,
fontFamily: "Inter_600SemiBold",
color: "#fff",
},
});

View File

@@ -0,0 +1 @@
export const ONBOARDING_COMPLETED_KEY = "app.onboarding_completed";