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";