diff --git a/artifacts/mobile/app/_layout.tsx b/artifacts/mobile/app/_layout.tsx
index e079227..53c26c3 100644
--- a/artifacts/mobile/app/_layout.tsx
+++ b/artifacts/mobile/app/_layout.tsx
@@ -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 (
+
);
diff --git a/artifacts/mobile/app/onboarding.tsx b/artifacts/mobile/app/onboarding.tsx
new file mode 100644
index 0000000..00652ce
--- /dev/null
+++ b/artifacts/mobile/app/onboarding.tsx
@@ -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: ,
+ title: "Meet Timmy",
+ description:
+ "Your AI wizard powered by Lightning.\nAsk questions, get answers — pay only for what you use.",
+ },
+ {
+ id: "voice",
+ icon: (
+
+
+
+ ),
+ title: "Talk, Don't Type",
+ description:
+ "Tap the mic and speak naturally.\nTimmy listens, thinks, and responds out loud.",
+ },
+ {
+ id: "lightning",
+ icon: (
+
+
+
+ ),
+ title: "Lightning Fast Payments",
+ description:
+ "Pay per request with Bitcoin Lightning.\nNo accounts, no subscriptions — just sats.",
+ },
+];
+
+function Dot({ active }: { active: boolean }) {
+ return (
+
+ );
+}
+
+export default function OnboardingScreen() {
+ const insets = useSafeAreaInsets();
+ const [currentIndex, setCurrentIndex] = useState(0);
+ const flatListRef = useRef>(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 }) => (
+
+ {item.icon}
+ {item.title}
+ {item.description}
+
+ );
+
+ return (
+
+ {/* Skip button */}
+ {!isLastSlide && (
+
+ Skip
+
+ )}
+
+ {/* Slides */}
+ item.id}
+ horizontal
+ pagingEnabled
+ showsHorizontalScrollIndicator={false}
+ bounces={false}
+ onViewableItemsChanged={onViewableItemsChanged}
+ viewabilityConfig={viewabilityConfig}
+ style={styles.flatList}
+ />
+
+ {/* Dots + Next/Get Started */}
+
+
+ {slides.map((s, i) => (
+
+ ))}
+
+
+
+
+ {isLastSlide ? "Get Started" : "Next"}
+
+ {!isLastSlide && (
+
+ )}
+
+
+
+ );
+}
+
+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",
+ },
+});
diff --git a/artifacts/mobile/constants/storage-keys.ts b/artifacts/mobile/constants/storage-keys.ts
new file mode 100644
index 0000000..e07ff30
--- /dev/null
+++ b/artifacts/mobile/constants/storage-keys.ts
@@ -0,0 +1 @@
+export const ONBOARDING_COMPLETED_KEY = "app.onboarding_completed";