Co-authored-by: Claude (Opus 4.6) <claude@hermes.local> Co-committed-by: Claude (Opus 4.6) <claude@hermes.local>
265 lines
6.4 KiB
TypeScript
265 lines
6.4 KiB
TypeScript
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");
|
|
|
|
const slideStyles = StyleSheet.create({
|
|
iconCircle: {
|
|
width: 140,
|
|
height: 140,
|
|
borderRadius: 70,
|
|
backgroundColor: C.surfaceElevated,
|
|
borderWidth: 1,
|
|
borderColor: C.border,
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
},
|
|
});
|
|
|
|
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 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",
|
|
},
|
|
});
|