[claude] Mobile first-launch onboarding walkthrough (#35) #79
@@ -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>
|
||||
);
|
||||
|
||||
264
artifacts/mobile/app/onboarding.tsx
Normal file
264
artifacts/mobile/app/onboarding.tsx
Normal 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",
|
||||
},
|
||||
});
|
||||
1
artifacts/mobile/constants/storage-keys.ts
Normal file
1
artifacts/mobile/constants/storage-keys.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const ONBOARDING_COMPLETED_KEY = "app.onboarding_completed";
|
||||
Reference in New Issue
Block a user