diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index f02d58b..929971c 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -26,3 +26,6 @@ jobs:
- name: Typecheck
run: npx tsc --noEmit
+
+ - name: Production readiness checks
+ run: bash scripts/check-prod-ready.sh
diff --git a/app.config.js b/app.config.js
index 890ce91..f1dc93e 100644
--- a/app.config.js
+++ b/app.config.js
@@ -41,6 +41,17 @@ export default {
color: '#FCF150',
},
],
+ // Mic + speech recognition for talking to Kaori on the 3D stage
+ [
+ 'expo-speech-recognition',
+ {
+ microphonePermission:
+ 'Allow $(PRODUCT_NAME) to access the microphone so you can talk to Kaori.',
+ speechRecognitionPermission:
+ 'Allow $(PRODUCT_NAME) to use speech recognition to turn your voice into text.',
+ androidSpeechServicePackages: ['com.google.android.googlequicksearchbox'],
+ },
+ ],
// Add `use_frameworks! :linkage => :static` to the iOS Podfile. Required
// because @react-native-google-signin/google-signin transitively pulls in
// AppCheckCore (Swift), whose Obj-C deps (GoogleUtilities, RecaptchaInterop)
diff --git a/app/(auth)/welcome.tsx b/app/(auth)/welcome.tsx
index 1ec6acd..11aa598 100644
--- a/app/(auth)/welcome.tsx
+++ b/app/(auth)/welcome.tsx
@@ -5,11 +5,6 @@
*/
import { Ionicons } from '@expo/vector-icons';
-import {
- GoogleSignin,
- isErrorWithCode,
- statusCodes,
-} from '@react-native-google-signin/google-signin';
import * as AppleAuthentication from 'expo-apple-authentication';
import { router } from 'expo-router';
import { useEffect, useState } from 'react';
@@ -30,11 +25,19 @@ import { getUserCount } from '@/lib/api/user';
import { useThemeContext } from '@/lib/providers/ThemeProvider';
import { useAuthStore } from '@/lib/stores/authStore';
-// Configure Google Sign-In with the web client ID (used for ID token audience)
-GoogleSignin.configure({
- iosClientId: '624774098704-r7eqvb0jc4i3or885fk3k1u3l5uqlqmd.apps.googleusercontent.com',
- webClientId: '624774098704-j2q01j9g7pj41f8poqbvkvho9f7v3mco.apps.googleusercontent.com',
-});
+// Conditionally load native Google Sign-In (unavailable in Expo Go)
+let GoogleSigninModule: any = null;
+try {
+ const mod = require('@react-native-google-signin/google-signin');
+ GoogleSigninModule = mod.GoogleSignin;
+ GoogleSigninModule.configure({
+ iosClientId: '624774098704-r7eqvb0jc4i3or885fk3k1u3l5uqlqmd.apps.googleusercontent.com',
+ webClientId: '624774098704-j2q01j9g7pj41f8poqbvkvho9f7v3mco.apps.googleusercontent.com',
+ });
+} catch {
+ // Native module not available (Expo Go) -- Google Sign-In button will be hidden
+}
+const googleSignInAvailable = GoogleSigninModule != null;
// Format number with K/M suffix
function formatCount(count: number): string {
@@ -56,10 +59,11 @@ export default function WelcomeScreen() {
// Trigger native Google sign-in
const onGoogleSignIn = async () => {
+ if (!GoogleSigninModule) return;
setIsGoogleLoading(true);
try {
- await GoogleSignin.hasPlayServices();
- const response = await GoogleSignin.signIn();
+ await GoogleSigninModule.hasPlayServices();
+ const response = await GoogleSigninModule.signIn();
if (response.type === 'success' && response.data.idToken) {
// Send ID token to backend for verification
@@ -71,16 +75,12 @@ export default function WelcomeScreen() {
Alert.alert('Error', 'Google sign-in failed. Please try again.');
}
} catch (error: any) {
- if (isErrorWithCode(error)) {
- if (error.code === statusCodes.SIGN_IN_CANCELLED) {
- // User cancelled β do nothing
- } else if (error.code === statusCodes.IN_PROGRESS) {
- // Sign-in already in progress
- } else if (error.code === statusCodes.PLAY_SERVICES_NOT_AVAILABLE) {
- Alert.alert('Error', 'Google Play Services is not available on this device.');
- } else {
- Alert.alert('Error', error.message || 'Failed to sign in with Google');
- }
+ if (error.code === 'SIGN_IN_CANCELLED') {
+ // User cancelled β do nothing
+ } else if (error.code === 'IN_PROGRESS') {
+ // Sign-in already in progress
+ } else if (error.code === 'PLAY_SERVICES_NOT_AVAILABLE') {
+ Alert.alert('Error', 'Google Play Services is not available on this device.');
} else {
Alert.alert('Error', error.message || 'Failed to sign in with Google');
}
@@ -165,30 +165,32 @@ export default function WelcomeScreen() {
Get Started
- {/* Google Sign-In Button */}
-
- {isGoogleLoading ? (
-
- ) : (
- <>
-
-
- Continue with Google
-
- >
- )}
-
+ {/* Google Sign-In Button - hidden when native module unavailable (Expo Go) */}
+ {googleSignInAvailable && (
+
+ {isGoogleLoading ? (
+
+ ) : (
+ <>
+
+
+ Continue with Google
+
+ >
+ )}
+
+ )}
{/* Apple Sign-In Button - iOS only */}
{Platform.OS === 'ios' && (
diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx
index 42ab3b2..c1ed373 100644
--- a/app/(tabs)/_layout.tsx
+++ b/app/(tabs)/_layout.tsx
@@ -7,6 +7,7 @@
import { Ionicons } from '@expo/vector-icons';
import { Tabs } from 'expo-router';
import { Platform } from 'react-native';
+import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { colors } from '@/constants/colors';
import { useThemeContext } from '@/lib/providers/ThemeProvider';
@@ -26,6 +27,8 @@ function TabBarIcon({ focused, color, size, name, focusedName }: TabBarIconProps
export default function TabsLayout() {
const { isDark, theme } = useThemeContext();
+ const insets = useSafeAreaInsets();
+ const bottomPadding = Platform.OS === 'ios' ? 28 : Math.max(insets.bottom, 8);
return (
+
+
);
}
diff --git a/app/(tabs)/homies/bot-chat/[botId].tsx b/app/(tabs)/homies/bot-chat/[botId].tsx
new file mode 100644
index 0000000..06f5b6d
--- /dev/null
+++ b/app/(tabs)/homies/bot-chat/[botId].tsx
@@ -0,0 +1,506 @@
+/**
+ * Bot Chat Screen
+ * Chat with AI companion bots (e.g. Kaori)
+ * Uses /api/bot-chat endpoints instead of regular DM
+ */
+
+import { Ionicons } from '@expo/vector-icons';
+import { router, useLocalSearchParams } from 'expo-router';
+import { useCallback, useEffect, useRef, useState } from 'react';
+import {
+ ActivityIndicator,
+ FlatList,
+ Image,
+ KeyboardAvoidingView,
+ Platform,
+ Pressable,
+ StyleSheet,
+ Text,
+ TextInput,
+ View,
+} from 'react-native';
+import { SafeAreaView } from 'react-native-safe-area-context';
+import { RichContentCard } from '@/components/chat/RichContentCards';
+import { apiClient } from '@/lib/api/client';
+import { useThemeContext } from '@/lib/providers/ThemeProvider';
+import { useAuthStore } from '@/lib/stores/authStore';
+
+const YELLOW = '#FCF150';
+const DARK = '#1a1a1a';
+
+interface Bot {
+ _id: string;
+ name: string;
+ bio?: string;
+ imageUri?: string;
+ botCharacter?: string;
+}
+
+interface BotMessage {
+ _id: string;
+ fromUserId: string;
+ toUserId: string;
+ message?: string;
+ type: 'user' | 'bot';
+ richContent?: {
+ type: string;
+ data: any;
+ };
+ createdAt: string;
+}
+
+export default function BotChatScreen() {
+ const { botId } = useLocalSearchParams<{ botId: string }>();
+ const { theme, colors } = useThemeContext();
+ const { user } = useAuthStore();
+
+ const [bot, setBot] = useState(null);
+ const [messages, setMessages] = useState([]);
+ const [inputText, setInputText] = useState('');
+ const [loading, setLoading] = useState(true);
+ const [sending, setSending] = useState(false);
+
+ const flatListRef = useRef(null);
+ const userId = user?.id || (user as any)?._id;
+
+ // Fetch bot info and chat history
+ const fetchData = useCallback(async () => {
+ if (!botId) return;
+ try {
+ const [bots, history] = await Promise.all([
+ apiClient.get('/bot-chat/bots').catch(() => []),
+ apiClient.get(`/bot-chat/history/${botId}`).catch(() => []),
+ ]);
+
+ const botInfo = (Array.isArray(bots) ? bots : []).find((b) => b._id === botId);
+ if (botInfo) setBot(botInfo);
+
+ // History comes in chronological order, reverse for inverted FlatList
+ const msgs = Array.isArray(history) ? history : [];
+ setMessages(msgs.reverse());
+ } catch (_error) {
+ } finally {
+ setLoading(false);
+ }
+ }, [botId]);
+
+ useEffect(() => {
+ fetchData();
+ }, [fetchData]);
+
+ // Send message
+ const handleSend = async () => {
+ if (!inputText.trim() || !botId || sending) return;
+
+ const messageContent = inputText.trim();
+ setInputText('');
+ setSending(true);
+
+ // Optimistically add user message
+ const tempUserMsg: BotMessage = {
+ _id: `temp-${Date.now()}`,
+ fromUserId: userId || '',
+ toUserId: botId,
+ message: messageContent,
+ type: 'user',
+ createdAt: new Date().toISOString(),
+ };
+ setMessages((prev) => [tempUserMsg, ...prev]);
+
+ try {
+ const response = await apiClient.post<{
+ userMessage: BotMessage;
+ botMessage: BotMessage;
+ }>('/bot-chat/message', { botId, message: messageContent });
+
+ if (response) {
+ // Replace temp message with real ones
+ setMessages((prev) => {
+ const withoutTemp = prev.filter((m) => m._id !== tempUserMsg._id);
+ return [response.botMessage, response.userMessage, ...withoutTemp];
+ });
+ }
+ } catch (_error) {
+ // Remove temp message on error
+ setMessages((prev) => prev.filter((m) => m._id !== tempUserMsg._id));
+ setInputText(messageContent);
+ } finally {
+ setSending(false);
+ }
+ };
+
+ const formatTime = (dateString: string) => {
+ const date = new Date(dateString);
+ return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
+ };
+
+ if (loading) {
+ return (
+
+
+
+
+
+ );
+ }
+
+ return (
+
+ {/* Header */}
+
+ router.back()}>
+
+
+
+
+
+ {bot?.imageUri ? (
+
+ ) : (
+
+ π€
+
+ )}
+
+
+
+
+
+
+ {bot?.name || 'Companion'}
+
+ AI Companion
+
+
+
+ {/* 3D stage only exists for Kaori so far β gate the affordance */}
+ {botId && (bot?.botCharacter ?? bot?.name ?? '').toLowerCase().includes('kaori') ? (
+
+ router.push({
+ pathname: '/(tabs)/homies/companion-stage/[botId]',
+ params: { botId, name: bot?.name ?? 'Kaori' },
+ })
+ }
+ >
+
+
+ ) : (
+
+ )}
+
+
+ {/* Messages */}
+
+ item._id}
+ inverted
+ contentContainerStyle={styles.messagesContent}
+ showsVerticalScrollIndicator={false}
+ ListEmptyComponent={
+
+
+ {bot?.name ? `Say hello to ${bot.name}!` : 'No messages yet. Say hello!'}
+
+
+ }
+ renderItem={({ item }) => {
+ const isMe = item.type === 'user';
+
+ return (
+ <>
+
+
+ {item.message ? (
+
+ {item.message}
+
+ ) : (
+ // Legacy rows from before the backend persisted user
+ // message text (fixed May 2026) have no message field.
+
+ message not saved
+
+ )}
+
+
+
+ {formatTime(item.createdAt)}
+
+
+
+ {/* Rich content card from bot messages */}
+ {item.richContent && (
+
+
+
+ )}
+ >
+ );
+ }}
+ />
+
+ {/* Typing indicator when sending */}
+ {sending && (
+
+
+
+
+
+
+
+
+
+ )}
+
+ {/* Input */}
+
+
+
+
+
+
+ {sending ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ },
+ loadingContainer: {
+ flex: 1,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ header: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ paddingHorizontal: 12,
+ paddingVertical: 12,
+ borderBottomWidth: 1,
+ },
+ backButton: {
+ width: 40,
+ height: 40,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ userInfo: {
+ flex: 1,
+ flexDirection: 'row',
+ alignItems: 'center',
+ marginLeft: 4,
+ },
+ avatarWrapper: {
+ position: 'relative',
+ },
+ avatar: {
+ width: 40,
+ height: 40,
+ borderRadius: 20,
+ },
+ avatarPlaceholder: {
+ width: 40,
+ height: 40,
+ borderRadius: 20,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ avatarEmoji: {
+ fontSize: 18,
+ },
+ botBadge: {
+ position: 'absolute',
+ bottom: -1,
+ right: -1,
+ width: 16,
+ height: 16,
+ borderRadius: 8,
+ backgroundColor: YELLOW,
+ borderWidth: 2,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ userText: {
+ marginLeft: 12,
+ flex: 1,
+ },
+ userName: {
+ fontSize: 16,
+ fontWeight: '600',
+ },
+ userStatus: {
+ fontSize: 12,
+ marginTop: 1,
+ },
+ menuButton: {
+ width: 40,
+ height: 40,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ keyboardView: {
+ flex: 1,
+ },
+ messagesContent: {
+ padding: 16,
+ paddingTop: 8,
+ },
+ emptyMessages: {
+ alignItems: 'center',
+ justifyContent: 'center',
+ paddingVertical: 60,
+ transform: [{ scaleY: -1 }],
+ },
+ emptyText: {
+ fontSize: 15,
+ },
+ messageRow: {
+ marginBottom: 12,
+ alignItems: 'flex-start',
+ },
+ messageRowMe: {
+ alignItems: 'flex-end',
+ },
+ messageBubble: {
+ maxWidth: '80%',
+ paddingHorizontal: 16,
+ paddingVertical: 10,
+ borderRadius: 18,
+ },
+ messageBubbleMe: {
+ borderBottomRightRadius: 4,
+ },
+ messageBubbleOther: {
+ borderBottomLeftRadius: 4,
+ },
+ messageText: {
+ fontSize: 16,
+ lineHeight: 22,
+ },
+ messageMissing: {
+ fontStyle: 'italic',
+ opacity: 0.6,
+ },
+ messageFooter: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ marginTop: 4,
+ paddingHorizontal: 4,
+ },
+ timeText: {
+ fontSize: 11,
+ },
+ // Typing indicator
+ typingContainer: {
+ paddingHorizontal: 16,
+ paddingBottom: 8,
+ },
+ typingBubble: {
+ alignSelf: 'flex-start',
+ paddingHorizontal: 16,
+ paddingVertical: 12,
+ borderRadius: 18,
+ borderBottomLeftRadius: 4,
+ },
+ typingDots: {
+ flexDirection: 'row',
+ gap: 4,
+ },
+ typingDot: {
+ width: 8,
+ height: 8,
+ borderRadius: 4,
+ backgroundColor: '#999',
+ },
+ typingDot1: {
+ opacity: 0.4,
+ },
+ typingDot2: {
+ opacity: 0.6,
+ },
+ typingDot3: {
+ opacity: 0.8,
+ },
+ // Input
+ inputContainer: {
+ flexDirection: 'row',
+ alignItems: 'flex-end',
+ paddingHorizontal: 12,
+ paddingVertical: 10,
+ borderTopWidth: 1,
+ gap: 10,
+ },
+ inputWrapper: {
+ flex: 1,
+ flexDirection: 'row',
+ alignItems: 'center',
+ borderRadius: 24,
+ paddingHorizontal: 16,
+ paddingVertical: 8,
+ minHeight: 44,
+ },
+ textInput: {
+ flex: 1,
+ fontSize: 16,
+ maxHeight: 120,
+ },
+ sendButton: {
+ width: 44,
+ height: 44,
+ borderRadius: 22,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+});
diff --git a/app/(tabs)/homies/companion-stage/[botId].tsx b/app/(tabs)/homies/companion-stage/[botId].tsx
new file mode 100644
index 0000000..ebaf215
--- /dev/null
+++ b/app/(tabs)/homies/companion-stage/[botId].tsx
@@ -0,0 +1,450 @@
+/**
+ * Companion Stage Screen
+ * Full-screen interactive 3D stage for the Kaori AI companion, with live
+ * voice: talk (or type) to Kaori β same brain as the regular chat
+ * (POST /bot-chat/message, logged in bot_chats), her reply streams back as
+ * ElevenLabs audio via the Kith sidecar while the model speaks and emotes.
+ */
+
+import { Ionicons } from '@expo/vector-icons';
+import { useIsFocused } from '@react-navigation/native';
+import { router, useLocalSearchParams } from 'expo-router';
+import { StatusBar } from 'expo-status-bar';
+import { useCallback, useEffect, useRef, useState } from 'react';
+import {
+ KeyboardAvoidingView,
+ Platform,
+ Pressable,
+ StyleSheet,
+ Text,
+ TextInput,
+ View,
+} from 'react-native';
+import { GestureHandlerRootView } from 'react-native-gesture-handler';
+import { SafeAreaView } from 'react-native-safe-area-context';
+import {
+ createTrickDemoState,
+ type DemoAction,
+ KaoriStage,
+ startAction,
+ type TrickId,
+} from '@/components/companion';
+import { brandColors } from '@/constants/colors';
+import { useKithVoice } from '@/hooks/useKithVoice';
+import { useVoiceInput } from '@/hooks/useVoiceInput';
+import { apiClient } from '@/lib/api/client';
+
+interface StageMessage {
+ id: string;
+ role: 'user' | 'kaori';
+ text: string;
+}
+
+interface BotChatResponse {
+ userMessage: { _id: string; message?: string };
+ botMessage: { _id: string; message?: string };
+}
+
+const MODE_LABELS: Record = {
+ idle: 'idle',
+ listening: 'listeningβ¦',
+ thinking: 'thinkingβ¦',
+ speaking: 'speaking',
+};
+
+/**
+ * Detect "show me a trick" intents so Kaori demonstrates with her body
+ * while she explains. First trick in the library: frontside 360.
+ */
+function detectTrickDemo(text: string): TrickId | null {
+ const wantsDemo = /\b(show|demo|demonstrate|do)\b/i.test(text);
+ const mentions360 = /\b(360|three[\s-]?sixty|frontside\s*3)\b/i.test(text);
+ return wantsDemo && mentions360 ? 'frontside-360' : null;
+}
+
+/** Split a reply the way Kith chunks speech β one sentence per turn. */
+function splitSentences(text: string): string[] {
+ return text
+ .split(/(?<=[.!?β¦])\s+/)
+ .map((sentence) => sentence.trim())
+ .filter(Boolean);
+}
+
+/**
+ * Choreography cues: map what Kaori is SAYING to what her body does.
+ * "watch this / let me show you / spin" β the full trick; phase keywords
+ * β segment demos; anything else β she keeps talking in stance.
+ */
+function actionForSentence(sentence: string): Exclude | null {
+ if (/watch|let me show|show you|check (this|it)|like this|here (we|it) go/i.test(sentence)) {
+ return 'full';
+ }
+ if (/\b(spin|rotat\w*|360|three[\s-]?sixty)\b/i.test(sentence)) return 'full';
+ if (/\b(wind|coil|crouch|bend|set[\s-]?up|load)\b/i.test(sentence)) return 'setup';
+ if (/\b(pop|jump|snap|spring)\b/i.test(sentence)) return 'pop';
+ if (/\b(land\w*|absorb|stomp)\b/i.test(sentence)) return 'land';
+ return null;
+}
+
+export default function CompanionStageScreen() {
+ const { botId, name } = useLocalSearchParams<{ botId: string; name?: string }>();
+ const isFocused = useIsFocused();
+
+ const demoState = useRef(createTrickDemoState());
+ const replySentences = useRef(null);
+
+ const { voiceState, voiceReady, getSessionId, bargeIn, reassertPlayback, setMode, beginReply } =
+ useKithVoice({
+ onAssistantSentence: (index) => {
+ const sentences = replySentences.current;
+ if (!sentences) return;
+ const action = actionForSentence(sentences[index] ?? '');
+ if (action) startAction(demoState.current, action);
+ },
+ onReplyDone: () => {
+ demoState.current.session = false;
+ replySentences.current = null;
+ },
+ });
+
+ const [messages, setMessages] = useState([]);
+ const [input, setInput] = useState('');
+ const [sending, setSending] = useState(false);
+ // Re-render the state pill periodically-ish: mode changes are driven by
+ // events; mirror them into React state where we know they change.
+ const [modeLabel, setModeLabel] = useState('idle');
+ const sendLock = useRef(false);
+
+ const syncMode = useCallback(
+ (mode: 'idle' | 'listening' | 'thinking' | 'speaking') => {
+ setMode(mode);
+ setModeLabel(mode);
+ },
+ [setMode],
+ );
+
+ // Kith events mutate voiceState outside React β mirror mode into the
+ // pill. Same-value setState bails out, so this is render-free when idle.
+ useEffect(() => {
+ const id = setInterval(() => {
+ setModeLabel((prev) => {
+ const mode = voiceState.current.mode;
+ return prev === mode ? prev : mode;
+ });
+ }, 400);
+ return () => clearInterval(id);
+ }, [voiceState]);
+
+ const sendMessage = useCallback(
+ async (raw: string) => {
+ const text = raw.trim();
+ if (!text || !botId || sendLock.current) return;
+ sendLock.current = true;
+ setSending(true);
+ setInput('');
+ setMessages((prev) => [...prev.slice(-6), { id: `u-${Date.now()}`, role: 'user', text }]);
+ syncMode('thinking');
+
+ try {
+ const sessionId = getSessionId();
+ beginReply();
+ const response = await apiClient.post(
+ '/bot-chat/message',
+ { botId, message: text },
+ sessionId ? { headers: { 'x-kith-session': sessionId } } : undefined,
+ );
+ const reply = response?.botMessage?.message;
+ if (reply) {
+ setMessages((prev) => [
+ ...prev.slice(-6),
+ { id: `k-${Date.now()}`, role: 'kaori', text: reply },
+ ]);
+ }
+ // Demo choreography: if the user asked for a demo (or her reply
+ // announces one), she steps onto the board for the whole reply and
+ // her sentences cue the moves (see onAssistantSentence).
+ const requestedTrick = detectTrickDemo(text);
+ const wantsDemo =
+ reply && (requestedTrick !== null || /watch this|let me show/i.test(reply));
+ if (wantsDemo) {
+ if (requestedTrick) demoState.current.trick = requestedTrick;
+ demoState.current.session = true;
+ demoState.current.idleT = 0;
+ replySentences.current = splitSentences(reply);
+ if (!sessionId) {
+ // Voiceless fallback: no sentence cues will arrive β run the
+ // full trick once, then step off the board.
+ startAction(demoState.current, 'full');
+ setTimeout(() => {
+ demoState.current.session = false;
+ replySentences.current = null;
+ }, 7000);
+ }
+ }
+ // Voice (if session live) arrives via Kith; tts_start flips the
+ // mode to 'speaking'. Without voice, settle back to idle β and if
+ // voice never arrives (dead session), recover after a beat.
+ if (!sessionId) {
+ syncMode('idle');
+ } else {
+ setTimeout(() => {
+ if (voiceState.current.mode === 'thinking') syncMode('idle');
+ }, 8000);
+ }
+ } catch (error) {
+ if (__DEV__) console.warn('[stage] send failed:', error);
+ syncMode('idle');
+ setMessages((prev) => [
+ ...prev.slice(-6),
+ { id: `e-${Date.now()}`, role: 'kaori', text: "hmm, message didn't send β try again?" },
+ ]);
+ } finally {
+ setSending(false);
+ sendLock.current = false;
+ }
+ },
+ [botId, getSessionId, syncMode, voiceState, beginReply],
+ );
+
+ const { listening, toggleVoiceInput } = useVoiceInput({
+ onTranscript: setInput,
+ onSubmit: (text) => {
+ sendMessage(text);
+ },
+ onListeningChange: (isListening) => {
+ if (isListening) {
+ syncMode('listening');
+ } else {
+ // Speech recognition leaves the audio session in record mode β
+ // restore playback so the reply's TTS isn't attenuated.
+ reassertPlayback();
+ if (voiceState.current.mode === 'listening') syncMode('idle');
+ }
+ },
+ });
+
+ const handleMicPress = useCallback(() => {
+ // Silence Kaori BEFORE the mic opens β otherwise her speaker output
+ // bleeds into the head of the transcript.
+ if (!listening) bargeIn();
+ toggleVoiceInput();
+ }, [listening, bargeIn, toggleVoiceInput]);
+
+ const lastMessages = messages.slice(-2);
+
+ return (
+
+ {/* The stage is always dark regardless of theme β keep status icons light */}
+
+
+ {/* Header */}
+
+ router.back()} style={styles.backButton}>
+
+
+
+ {name || 'Kaori'}
+ 3D Companion
+
+
+
+ {MODE_LABELS[modeLabel] ?? modeLabel}
+
+
+
+ {/* Stage β NEVER wrapped in KeyboardAvoidingView: resizing the
+ expo-gl surface on every keyboard toggle churns the GL context.
+ The input bar floats over it instead. */}
+
+
+
+ {/* Recent messages overlay */}
+
+ {lastMessages.map((message) => (
+
+
+ {message.text}
+
+
+ ))}
+
+
+
+ {/* Input bar β iOS: 'position' slides it up over the fixed-size
+ stage; Android: the OS adjustResize handles it natively. */}
+
+
+
+
+
+ sendMessage(input)}
+ placeholder={
+ listening ? 'Listeningβ¦' : voiceReady ? 'Talk to Kaoriβ¦' : 'Message Kaoriβ¦'
+ }
+ placeholderTextColor="#9aa3b5"
+ returnKeyType="send"
+ style={styles.textInput}
+ value={input}
+ />
+ sendMessage(input)}
+ style={[styles.sendButton, (!input.trim() || sending) && { opacity: 0.4 }]}
+ >
+
+
+
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ root: {
+ flex: 1,
+ },
+ container: {
+ flex: 1,
+ backgroundColor: '#0b0e17',
+ },
+ header: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ paddingHorizontal: 12,
+ paddingVertical: 10,
+ },
+ backButton: {
+ width: 36,
+ height: 36,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ headerText: {
+ flex: 1,
+ alignItems: 'center',
+ },
+ title: {
+ color: '#ffffff',
+ fontSize: 17,
+ fontWeight: '700',
+ },
+ subtitle: {
+ color: brandColors.primary,
+ fontSize: 12,
+ marginTop: 1,
+ },
+ statePill: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 5,
+ paddingHorizontal: 10,
+ paddingVertical: 5,
+ borderRadius: 12,
+ backgroundColor: 'rgba(24, 28, 38, 0.9)',
+ },
+ stateDot: {
+ width: 6,
+ height: 6,
+ borderRadius: 3,
+ backgroundColor: '#4a5568',
+ },
+ stateText: {
+ color: '#9aa3b5',
+ fontSize: 11,
+ },
+ stage: {
+ flex: 1,
+ },
+ messagesOverlay: {
+ position: 'absolute',
+ left: 12,
+ right: 12,
+ bottom: 12,
+ gap: 6,
+ },
+ messageBubble: {
+ maxWidth: '85%',
+ paddingHorizontal: 12,
+ paddingVertical: 8,
+ borderRadius: 14,
+ },
+ messageUser: {
+ alignSelf: 'flex-end',
+ // Translucent brand yellow β the stage shows through
+ backgroundColor: 'rgba(252, 241, 80, 0.5)',
+ },
+ messageKaori: {
+ alignSelf: 'flex-start',
+ backgroundColor: 'rgba(24, 28, 38, 0.45)',
+ },
+ messageText: {
+ color: '#ffffff',
+ fontSize: 14,
+ lineHeight: 19,
+ },
+ inputRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 8,
+ paddingHorizontal: 12,
+ paddingVertical: 10,
+ backgroundColor: '#10141f',
+ },
+ micButton: {
+ width: 40,
+ height: 40,
+ borderRadius: 20,
+ alignItems: 'center',
+ justifyContent: 'center',
+ backgroundColor: 'rgba(255, 255, 255, 0.08)',
+ },
+ micButtonLive: {
+ backgroundColor: brandColors.primary,
+ },
+ textInput: {
+ flex: 1,
+ height: 40,
+ borderRadius: 20,
+ paddingHorizontal: 14,
+ color: '#ffffff',
+ fontSize: 15,
+ backgroundColor: 'rgba(255, 255, 255, 0.08)',
+ },
+ sendButton: {
+ width: 40,
+ height: 40,
+ borderRadius: 20,
+ alignItems: 'center',
+ justifyContent: 'center',
+ backgroundColor: brandColors.primary,
+ },
+});
diff --git a/app/(tabs)/homies/index.tsx b/app/(tabs)/homies/index.tsx
index ab4690d..61a09bc 100644
--- a/app/(tabs)/homies/index.tsx
+++ b/app/(tabs)/homies/index.tsx
@@ -25,9 +25,9 @@ import {
View,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
+import { apiClient } from '@/lib/api/client';
import {
acceptHomieRequest,
- getDiscoverableUsers,
getMyHomies,
getPendingRequests,
type Homie,
@@ -37,7 +37,6 @@ import {
sendHomieRequest,
} from '@/lib/api/homies';
import { getOrCreateConversation } from '@/lib/api/messages';
-import { apiClient } from '@/lib/api/client';
import { useThemeContext } from '@/lib/providers/ThemeProvider';
import { useAuthStore } from '@/lib/stores/authStore';
diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx
index 8ba2468..965e0cf 100644
--- a/app/(tabs)/index.tsx
+++ b/app/(tabs)/index.tsx
@@ -1,11 +1,13 @@
/**
* Home Screen (Dashboard)
- * Personal dashboard matching HomeScreen.png design
+ * Action-oriented dashboard for quick access to core features
*
* Layout:
- * - Header: Avatar + "Yo, {name}!" (name in gold) + settings icon
+ * - Header: Avatar + "Yo, {name}!" + messages icon (badge) + settings icon
+ * - Primary Actions: 3 large cards (Add Trick, Trickipedia, Find a Spot)
+ * - Companion Widget: Kaori AI companion quick-launch
* - Current Goals: Horizontal scroll of GoalCards
- * - Split row: Progress Stats | Quick Actions (2x2)
+ * - Feed CTA: Banner to browse the feed
* - Homie Activity: Horizontal scroll
*/
@@ -22,12 +24,14 @@ import {
View,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
-import { ActivityCard, GoalCard, QuickActions, StatsCard } from '@/components/home';
-import { Avatar, Card, IconButton, SectionHeader } from '@/components/ui';
+import { ActionCard, ActivityCard, CompanionWidget, FeedCTA, GoalCard } from '@/components/home';
+import { Avatar, Card, CountBadge, IconButton, SectionHeader } from '@/components/ui';
import { colors as brandColors } from '@/constants/colors';
+import { apiClient } from '@/lib/api/client';
import { getMyHomies, type Homie } from '@/lib/api/homies';
+import { getUnreadCount } from '@/lib/api/messages';
import { getUserTrickLists } from '@/lib/api/trickbook';
-import { type ActivityItem, getHomieActivity, getUserStats, type UserStats } from '@/lib/api/user';
+import { type ActivityItem, getHomieActivity } from '@/lib/api/user';
import { useThemeContext } from '@/lib/providers/ThemeProvider';
import { useAuthStore } from '@/lib/stores/authStore';
import type { TrickList, TrickListItem } from '@/types/trickbook';
@@ -58,32 +62,25 @@ function getStatusProgress(status: 'learning' | 'landed' | 'notStarted' | 'maste
}
}
-// Get relative time string
-function _getTimeAgo(dateString: string): string {
- const date = new Date(dateString);
- const now = new Date();
- const diffMs = now.getTime() - date.getTime();
- const diffMins = Math.floor(diffMs / 60000);
- const diffHours = Math.floor(diffMs / 3600000);
- const diffDays = Math.floor(diffMs / 86400000);
-
- if (diffMins < 1) return 'just now';
- if (diffMins < 60) return `${diffMins}m ago`;
- if (diffHours < 24) return `${diffHours}h ago`;
- if (diffDays < 7) return `${diffDays}d ago`;
- return date.toLocaleDateString();
+interface Bot {
+ _id: string;
+ name: string;
+ bio?: string;
+ imageUri?: string;
+ botCharacter?: string;
}
export default function HomeScreen() {
const { theme, colors, isDark } = useThemeContext();
const { user, token } = useAuthStore();
- const [stats, setStats] = useState(null);
const [trickLists, setTrickLists] = useState([]);
const [homies, setHomies] = useState([]);
const [homieActivity, setHomieActivity] = useState<
(ActivityItem & { userName?: string; userImage?: string })[]
>([]);
+ const [unreadCount, setUnreadCount] = useState(0);
+ const [companion, setCompanion] = useState(null);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
@@ -92,22 +89,25 @@ export default function HomeScreen() {
if (!user?.id || !token) return;
try {
- const [statsData, listsData, homiesData] = await Promise.all([
- getUserStats(user.id).catch(() => null),
+ const [listsData, homiesData, unreadData, botsData] = await Promise.all([
getUserTrickLists(user.id, token).catch(() => []),
getMyHomies().catch(() => []),
+ getUnreadCount().catch(() => 0),
+ apiClient.get('/bot-chat/bots').catch(() => []),
]);
- if (statsData) setStats(statsData);
setTrickLists(listsData);
setHomies(homiesData);
+ setUnreadCount(unreadData);
+
+ const bots = Array.isArray(botsData) ? botsData : [];
+ setCompanion(bots[0] || null);
// Fetch homie activity if we have homies
if (homiesData.length > 0) {
const homieIds = homiesData.map((h) => h._id);
const activities = await getHomieActivity(homieIds, 10).catch(() => []);
- // Enrich activities with user info
const enrichedActivities = activities.map((activity) => {
const homie = homiesData.find(
(h) => h._id === activity.data?.userId || h._id === (activity as any).userId,
@@ -137,8 +137,7 @@ export default function HomeScreen() {
setRefreshing(false);
}, [fetchData]);
- // Extract current goals from trick lists - sorted by most recent activity first
- // Prioritize updatedAt (when trick was edited/status changed) over createdAt
+ // Extract current goals from trick lists
const currentGoals = trickLists
.flatMap((list: TrickList) =>
(list.tricks || []).map((trick: TrickListItem) => ({
@@ -151,59 +150,15 @@ export default function HomeScreen() {
})),
)
.sort((a, b) => {
- // Tricks with timestamps come first, sorted newest to oldest
- // Tricks without timestamps go to the end
const timeA = a.timestamp ? new Date(a.timestamp).getTime() : 0;
const timeB = b.timestamp ? new Date(b.timestamp).getTime() : 0;
return timeB - timeA;
})
.slice(0, 5);
- // Calculate stats
- const totalLanded =
- stats?.tricklistCount ||
- trickLists.reduce(
- (acc: number, list: TrickList) =>
- acc +
- (list.tricks || []).filter(
- (t: TrickListItem) =>
- t.status === 'Landed' || t.status === 'Mastered' || t.checked === 'Complete',
- ).length,
- 0,
- );
-
- // Quick actions configuration
- const quickActions = [
- {
- id: 'add-trick',
- label: 'Add Trick',
- icon: 'sparkles' as const,
- onPress: () => router.push('/(tabs)/trickbook'),
- },
- {
- id: 'feed',
- label: 'The Feed',
- icon: 'play' as const,
- onPress: () => router.push('/(tabs)/media?tab=feed'),
- },
- {
- id: 'trickipedia',
- label: 'Open Trickipedia',
- icon: 'book' as const,
- onPress: () => router.push('/(tabs)/trickbook'),
- },
- {
- id: 'find-spot',
- label: 'Find Spot',
- icon: 'location' as const,
- onPress: () => router.push('/(tabs)/spots'),
- },
- ];
-
// Get user display name and avatar
const displayName = user?.name || 'Rider';
const avatarEmoji = user?.riderProfile?.avatarIcon?.emoji || 'πΉ';
- const _avatarBg = user?.riderProfile?.avatarIcon?.bg;
const isPremium = user?.subscription?.plan === 'premium';
if (loading) {
@@ -240,7 +195,7 @@ export default function HomeScreen() {
onPress={() =>
router.push({
pathname: '/(tabs)/profile/[userId]',
- params: { userId: user?.id || user?._id, from: 'home' },
+ params: { userId: user?.id || user?._id || '', from: 'home' },
})
}
>
@@ -260,10 +215,65 @@ export default function HomeScreen() {
- router.push('/profile/settings')}
+
+
+ router.push('/(tabs)/homies/conversations')}
+ />
+ {unreadCount > 0 && (
+
+
+
+ )}
+
+ router.push('/profile/settings')}
+ />
+
+
+
+ {/* Primary Action Cards */}
+
+ router.push('/(tabs)/trickbook')}
+ />
+ router.push('/(tabs)/trickbook')}
+ />
+ router.push('/(tabs)/spots')}
+ />
+
+
+ {/* Companion Widget */}
+
+ companion && router.push(`/(tabs)/homies/bot-chat/${companion._id}`)}
+ onView3D={
+ // 3D stage only exists for Kaori so far
+ companion &&
+ (companion.botCharacter ?? companion.name).toLowerCase().includes('kaori')
+ ? () =>
+ router.push({
+ pathname: '/(tabs)/homies/companion-stage/[botId]',
+ params: { botId: companion._id, name: companion.name },
+ })
+ : undefined
+ }
/>
@@ -318,19 +328,9 @@ export default function HomeScreen() {
)}
- {/* Stats + Quick Actions Row */}
-
- {/* Progress Stats */}
-
-
- {/* Quick Actions */}
-
+ {/* Feed CTA */}
+
+ router.push('/(tabs)/media?tab=feed')} />
{/* Homie Activity - Horizontal Scroll */}
@@ -352,7 +352,6 @@ export default function HomeScreen() {
contentContainerStyle={styles.activityScroll}
>
{homieActivity.map((activity, index) => {
- // Determine activity type and subject from the activity data
const activityType =
activity.type === 'post' ? 'added' : activity.type === 'spot' ? 'spot' : 'added';
const subject =
@@ -441,11 +440,32 @@ const styles = StyleSheet.create({
flexDirection: 'row',
alignItems: 'center',
gap: 12,
+ flex: 1,
+ },
+ headerRight: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 8,
+ },
+ iconWithBadge: {
+ position: 'relative',
+ },
+ badgePosition: {
+ position: 'absolute',
+ top: -2,
+ right: -2,
+ zIndex: 1,
},
greeting: {
fontSize: 24,
fontWeight: '700',
},
+ actionsRow: {
+ flexDirection: 'row',
+ paddingHorizontal: 20,
+ gap: 12,
+ marginBottom: 16,
+ },
section: {
marginBottom: 20,
},
@@ -454,16 +474,12 @@ const styles = StyleSheet.create({
},
sectionPadded: {
paddingHorizontal: 20,
+ marginBottom: 16,
},
goalsScroll: {
paddingHorizontal: 20,
gap: 12,
},
- splitRow: {
- flexDirection: 'row',
- paddingHorizontal: 20,
- gap: 12,
- },
activityScroll: {
paddingHorizontal: 20,
gap: 12,
diff --git a/app/(tabs)/media/collection/[collectionId].tsx b/app/(tabs)/media/collection/[collectionId].tsx
index 6e116ae..e012b51 100644
--- a/app/(tabs)/media/collection/[collectionId].tsx
+++ b/app/(tabs)/media/collection/[collectionId].tsx
@@ -17,17 +17,27 @@ import {
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import {
+ type CouchCollection,
+ type CouchVideo,
formatDuration,
getCollection,
- getTypeLabel,
- type MediaCollection,
- type MediaVideo,
} from '@/lib/api/couch';
import { useThemeContext } from '@/lib/providers/ThemeProvider';
const YELLOW = '#FCF150';
const _DARK = '#1a1a1a';
+type MediaCollection = CouchCollection & { coverImage?: string };
+// Some backend responses include extra fields not yet in the CouchVideo contract
+type MediaVideo = CouchVideo & { avgRating?: number };
+
+/** Source label derived from the fields the backend actually persists. */
+function getTypeLabel(video: MediaVideo): string {
+ if (video.bunnyVideoId || video.hlsUrl) return 'Stream';
+ if (video.driveFileId) return 'Drive';
+ return 'Video';
+}
+
export default function CollectionDetailScreen() {
const { collectionId } = useLocalSearchParams<{ collectionId: string }>();
const { theme, colors } = useThemeContext();
@@ -192,7 +202,7 @@ function VideoListItem({ video, theme, colors }: VideoListItemProps) {
- {getTypeLabel(video.type)}
+ {getTypeLabel(video)}
{video.avgRating !== undefined && video.avgRating > 0 && (
diff --git a/app/(tabs)/media/index.tsx b/app/(tabs)/media/index.tsx
index ad940e9..4a63fbb 100644
--- a/app/(tabs)/media/index.tsx
+++ b/app/(tabs)/media/index.tsx
@@ -361,15 +361,15 @@ function FeedView({ theme, colors, onSwitchTab }: FeedViewProps) {
async (pageNum: number = 1, refresh: boolean = false) => {
try {
const response = user
- ? await getFeed({ page: pageNum, limit: 10 })
- : await getTrending({ page: pageNum, limit: 10 });
+ ? await getFeed({ page: pageNum, limit: 20 })
+ : await getTrending({ page: pageNum, limit: 20 });
if (refresh || pageNum === 1) {
setPosts(response.posts);
} else {
setPosts((prev) => [...prev, ...response.posts]);
}
- setHasMore(response.pagination.hasMore ?? response.posts.length === 10);
+ setHasMore(response.pagination.hasMore ?? response.posts.length === 20);
setPage(pageNum);
} catch (_error) {
} finally {
@@ -649,30 +649,53 @@ interface FeedVideoItemProps {
// Separate video player component that only renders when we have a URL
function FeedVideoPlayer({
videoUrl,
+ fallbackUrls,
isActive,
isMuted,
onMutedChange,
}: {
videoUrl: string;
+ fallbackUrls?: string[];
isActive: boolean;
isMuted: boolean;
onMutedChange: (muted: boolean) => void;
}) {
- const player = useVideoPlayer(videoUrl, (p) => {
+ const [currentUrl, setCurrentUrl] = useState(videoUrl);
+ const triedUrls = useRef(new Set());
+
+ // Reset when videoUrl prop changes (new post)
+ useEffect(() => {
+ setCurrentUrl(videoUrl);
+ triedUrls.current.clear();
+ }, [videoUrl]);
+
+ const player = useVideoPlayer(currentUrl, (p) => {
p.loop = true;
p.muted = isMuted;
});
- // Listen for player events
+ // Listen for player events β try fallback URLs on error
useEffect(() => {
if (!player) return;
- const statusSub = player.addListener('statusChange', (_status) => {});
+ const statusSub = player.addListener('statusChange', ({ status }) => {
+ if (status === 'error') {
+ console.error('Video playback error for URL:', currentUrl);
+ triedUrls.current.add(currentUrl);
+
+ // Try next fallback URL
+ const nextUrl = fallbackUrls?.find((u) => !triedUrls.current.has(u));
+ if (nextUrl) {
+ console.log('Trying fallback URL:', nextUrl);
+ setCurrentUrl(nextUrl);
+ }
+ }
+ });
return () => {
statusSub.remove();
};
- }, [player]);
+ }, [player, currentUrl, fallbackUrls]);
// Handle play/pause based on visibility
useEffect(() => {
@@ -729,39 +752,43 @@ const FeedVideoItem = memo(function FeedVideoItem({
const hasLove = post.userReactions?.includes('love');
const hasRespect = post.userReactions?.includes('respect');
- // Determine video URL - prefer signed URLs from backend
+ // Determine video URL - prefer HLS so playback adapts to whatever
+ // resolutions Bunny actually transcoded (the hardcoded play_720p.mp4
+ // fallback returns 404 for source videos uploaded below 720p).
const videoUrl = useMemo(() => {
if (post.mediaType !== 'video') return null;
- // Prefer signed MP4 URL from backend (for mobile compatibility)
- if (post.signedMp4Url) {
- return post.signedMp4Url;
- }
-
- // Fall back to signed HLS URL
+ // Prefer signed HLS URL β adaptive, works for any source resolution
if (post.signedHlsUrl) {
return post.signedHlsUrl;
}
- // Last resort: try unsigned URL (won't work if token auth is enabled)
+ // Fall back to unsigned HLS
if (post.hlsUrl) {
return post.hlsUrl;
}
- return null;
- }, [post.mediaType, post.signedMp4Url, post.signedHlsUrl, post.hlsUrl]);
-
- // Debug logging
- useEffect(() => {
- if (isActive) {
+ // Last resort: signed 720p MP4 (only valid if source >= 720p)
+ if (post.signedMp4Url) {
+ return post.signedMp4Url;
}
- }, [isActive]);
+
+ return null;
+ }, [post.mediaType, post.signedHlsUrl, post.hlsUrl, post.signedMp4Url]);
// Toggle mute
const handleMuteToggle = () => {
setIsMuted(!isMuted);
};
+ // Build fallback URL list (all available URLs except the primary one)
+ const fallbackUrls = useMemo(() => {
+ const urls = [post.signedHlsUrl, post.hlsUrl, post.signedMp4Url].filter(
+ (u): u is string => !!u && u !== videoUrl,
+ );
+ return urls;
+ }, [post.signedHlsUrl, post.hlsUrl, post.signedMp4Url, videoUrl]);
+
const isVideo = post.mediaType === 'video' && videoUrl;
return (
@@ -770,6 +797,7 @@ const FeedVideoItem = memo(function FeedVideoItem({
{isVideo && videoUrl ? (
{
router.push(`/(tabs)/homies/chat/${conversationId}`);
diff --git a/app/(tabs)/spots/index.tsx b/app/(tabs)/spots/index.tsx
index bb53ae7..31d1c20 100644
--- a/app/(tabs)/spots/index.tsx
+++ b/app/(tabs)/spots/index.tsx
@@ -30,7 +30,6 @@ import { SafeAreaView } from 'react-native-safe-area-context';
import { SpotListCard as SpotListCardComponent } from '@/components/spots';
import { useKeyboardVisible } from '@/hooks/useKeyboardVisible';
import { useMapClusters } from '@/hooks/useMapClusters';
-import { projectToScreen } from '@/lib/mapProjection';
import { createSpotList, getSpotLists } from '@/lib/api/spotlists';
import {
getMapPins,
@@ -42,6 +41,7 @@ import {
type Spot,
type SpotCategory,
} from '@/lib/api/spots';
+import { projectToScreen } from '@/lib/mapProjection';
import { useThemeContext } from '@/lib/providers/ThemeProvider';
import { useAuthStore } from '@/lib/stores/authStore';
import type { CreateSpotListInput, SpotList } from '@/types/spots';
diff --git a/app/(tabs)/trickbook/list/[listId].tsx b/app/(tabs)/trickbook/list/[listId].tsx
index 70cd37c..7249b1e 100644
--- a/app/(tabs)/trickbook/list/[listId].tsx
+++ b/app/(tabs)/trickbook/list/[listId].tsx
@@ -38,6 +38,7 @@ import {
editTrick,
getTrickList,
removeTrickFromList,
+ toggleTrickListVisibility,
updateTrickList,
updateTrickStatus,
} from '@/lib/api/trickbook';
@@ -252,6 +253,19 @@ export default function TrickListDetailScreen() {
setSelectedTrick(null);
};
+ // Toggle public/private visibility
+ const handleToggleVisibility = async () => {
+ if (!list || !token || !listId) return;
+ const newValue = !list.isPublic;
+ const result = await toggleTrickListVisibility(listId, newValue, token);
+ if (result) {
+ setList((prev) => (prev ? { ...prev, isPublic: newValue } : prev));
+ Alert.alert(newValue ? 'List is now public' : 'List is now private');
+ } else {
+ Alert.alert('Error', 'Could not update visibility');
+ }
+ };
+
// Open share modal
const handleOpenShare = () => {
setListEditModalVisible(false);
@@ -879,6 +893,19 @@ export default function TrickListDetailScreen() {
>
{list.name}
+
+
+
+ {list.isPublic ? 'Make Private' : 'Make Public'}
+
+
(N) TrickList (1) ββββββ> (N) Trick
+```
+
+- A User can have many TrickLists
+- A TrickList can have many Tricks
+- A Trick belongs to exactly one TrickList
+
+## Example Documents
+
+### Manually Added Trick
+```json
+{
+ "_id": "668c6877d3b37f7d802ef4cf",
+ "list_id": "668c6800d3b37f7d802ef4ce",
+ "name": "bs shuv",
+ "link": "https://www.youtube.com/watch?v=example",
+ "notes": "Keep shoulders level, pop with back foot",
+ "checked": "Complete",
+ "createdAt": "2024-07-08T15:30:00.000Z"
+}
+```
+
+### Trick Added from Trickipedia
+```json
+{
+ "_id": "668c6877d3b37f7d802ef4d0",
+ "list_id": "668c6800d3b37f7d802ef4ce",
+ "name": "Kickflip",
+ "link": "https://www.youtube.com/watch?v=kickflip-tutorial",
+ "notes": "From Trickipedia: Flatground - Intermediate",
+ "checked": "To Do",
+ "trickipediaId": "66a1b2c3d4e5f67890123456",
+ "createdAt": "2024-07-10T12:00:00.000Z"
+}
+```
+
+When `trickipediaId` is present, the mobile app and web app show a "View Tutorial in Trickipedia" button that navigates to the full trick tutorial with steps, videos, and tips.
+
+## Related Models
+
+- [TrickList](./TRICKLIST.md) - Parent container for tricks
+- [User](./USER.md) - Owner of trick lists
diff --git a/docs/models/TRICKLIST.md b/docs/models/TRICKLIST.md
new file mode 100644
index 0000000..7644a64
--- /dev/null
+++ b/docs/models/TRICKLIST.md
@@ -0,0 +1,211 @@
+# TrickList Data Model
+
+## Overview
+
+A **TrickList** is a collection of tricks that a user wants to learn or track. Users can create multiple lists to organize their skating goals (e.g., "Park Tricks", "Street Session", "Competition Prep").
+
+## Database Schema
+
+**Collection:** `tricklists`
+
+```typescript
+interface TrickList {
+ _id: ObjectId; // MongoDB auto-generated ID
+ name: string; // List name (e.g., "skate winston spot!")
+ user: DBRef; // Reference to user document
+ tricks: TrickRef[]; // Array of trick references
+ completed: number; // Count of completed tricks (legacy, may be stale)
+ isPublic: boolean; // Whether list is visible to other users
+ createdAt: Date; // When the list was created
+}
+
+interface TrickRef {
+ _id: ObjectId; // Reference to trick in 'tricks' collection
+}
+
+interface DBRef {
+ $ref: string; // Collection name ("users")
+ $id: string; // User's ObjectId
+}
+```
+
+## Field Descriptions
+
+| Field | Type | Required | Description |
+|-------|------|----------|-------------|
+| `_id` | ObjectId | Auto | Unique identifier for the list |
+| `name` | string | Yes | Display name of the list |
+| `user` | DBRef | Yes | Reference to the owner user |
+| `tricks` | TrickRef[] | Yes | Array of trick references |
+| `completed` | number | No | Cached count of completed tricks |
+| `isPublic` | boolean | No | If true, appears in "Homie TrickLists" |
+| `createdAt` | Date | Yes | Timestamp when list was created |
+
+## API Endpoints
+
+### Get User's TrickLists
+```
+GET /api/listings?userId={userId}
+Headers: x-auth-token: {token}
+Response: TrickList[] (with enriched trick data)
+```
+
+The response includes full trick details:
+```typescript
+{
+ _id: string,
+ name: string,
+ user: { $ref: "users", $id: string },
+ tricks: [{
+ _id: string,
+ name: string, // From tricks collection
+ checked: string, // From tricks collection
+ // ...other trick fields
+ }],
+ isPublic: boolean,
+ createdAt: string
+}
+```
+
+### Get Public TrickLists
+```
+GET /api/listings/public
+Headers: x-auth-token: {token} (optional)
+Response: TrickList[] (with user info)
+```
+
+### Create TrickList
+```
+POST /api/listings
+Headers: x-auth-token: {token}
+Body: {
+ title: string, // Note: 'title' maps to 'name' in DB
+ userId: string,
+ isPublic?: boolean
+}
+Response: TrickList
+```
+
+### Delete TrickList
+```
+DELETE /api/listings/{listId}
+Headers: x-auth-token: {token}
+```
+Note: This also deletes all tricks in the list.
+
+### Update TrickList Name
+```
+PUT /api/listings/edit
+Headers: x-auth-token: {token}
+Body: {
+ trickListId: string,
+ name: string
+}
+```
+
+### Toggle Public/Private
+```
+PUT /api/listings/{listId}/visibility
+Headers: x-auth-token: {token}
+Body: {
+ isPublic: boolean
+}
+```
+
+### Get TrickList Count
+```
+GET /api/listings/countTrickLists?userId={userId}
+Headers: x-auth-token: {token}
+Response: { totalTrickLists: number }
+```
+
+## Frontend Type Definition
+
+```typescript
+// src/types/trickbook.ts
+
+export interface TrickList {
+ _id: string;
+ name: string;
+ tricks: TrickListItem[];
+ isPublic?: boolean;
+ user?: {
+ $id: string;
+ name?: string;
+ imageUri?: string;
+ };
+ createdAt?: string;
+ updatedAt?: string;
+}
+```
+
+## Progress Calculation
+
+The frontend calculates progress based on trick statuses:
+
+```typescript
+function calculateProgress(tricks: TrickListItem[]): ListProgress {
+ const total = tricks.length;
+ let completed = 0;
+
+ tricks.forEach(trick => {
+ if (trick.checked === 'Complete' || trick.checked === 'Completed') {
+ completed++;
+ }
+ });
+
+ return {
+ total,
+ completed,
+ percentage: Math.round((completed / total) * 100)
+ };
+}
+```
+
+## Relationships
+
+```
+User (1) ββββββ> (N) TrickList (1) ββββββ> (N) Trick
+ β
+ βββ isPublic ββ> Visible in "Homie TrickLists"
+```
+
+## Example Document
+
+```json
+{
+ "_id": "668c6800d3b37f7d802ef4ce",
+ "name": "skate winston spot!",
+ "user": {
+ "$ref": "users",
+ "$id": "63c5fd35a86c84e34777f14a"
+ },
+ "tricks": [
+ { "_id": "668c6877d3b37f7d802ef4cf" },
+ { "_id": "668c6890d3b37f7d802ef4d0" },
+ { "_id": "668c68a5d3b37f7d802ef4d1" },
+ { "_id": "668c68b2d3b37f7d802ef4d2" }
+ ],
+ "completed": 2,
+ "isPublic": false,
+ "createdAt": "2024-07-08T15:00:00.000Z"
+}
+```
+
+## UI Display
+
+### My TrickLists View
+Each list card shows:
+- List name (bold)
+- Progress: "X/Y Landed"
+- Yellow progress bar
+
+### TrickList Detail View
+- Header: "< My Trick Lists {list name}"
+- List of tricks with swipe actions
+- Yellow "+ ADD TRICK" button at bottom
+
+## Related Models
+
+- [Trick](./TRICK.md) - Individual tricks in a list
+- [User](./USER.md) - Owner of trick lists
diff --git a/eas-build-pre-install.sh b/eas-build-pre-install.sh
new file mode 100755
index 0000000..154aa07
--- /dev/null
+++ b/eas-build-pre-install.sh
@@ -0,0 +1,11 @@
+#!/bin/bash
+# EAS Build lifecycle hook β runs before npm install on EAS Build servers
+# Only run production checks for store distribution profiles
+# https://docs.expo.dev/build-reference/npm-hooks/
+
+if [ "$EAS_BUILD_PROFILE" = "playstore" ] || [ "$EAS_BUILD_PROFILE" = "testflight" ] || [ "$EAS_BUILD_PROFILE" = "production" ]; then
+ echo "Store build detected (profile: $EAS_BUILD_PROFILE) β running production readiness checks..."
+ bash scripts/check-prod-ready.sh
+else
+ echo "Non-store build (profile: $EAS_BUILD_PROFILE) β skipping production checks"
+fi
diff --git a/metro.config.js b/metro.config.js
index 93f67c3..7119986 100644
--- a/metro.config.js
+++ b/metro.config.js
@@ -5,4 +5,23 @@ const { withNativeWind } = require('nativewind/metro');
/** @type {import('expo/metro-config').MetroConfig} */
const config = getDefaultConfig(__dirname);
+// 3D companion models (binary glTF with VRM extensions) bundle as assets
+config.resolver.assetExts.push('vrm');
+
+// Force a single three.js instance. Package-exports resolution can pull in
+// both the ESM and CJS builds (bare 'three' vs three/examples imports),
+// which triggers "Multiple instances of Three.js" and breaks instanceof
+// checks inside @pixiv/three-vrm.
+const threeEntry = require.resolve('three');
+const defaultResolveRequest = config.resolver.resolveRequest;
+config.resolver.resolveRequest = (context, moduleName, platform) => {
+ if (moduleName === 'three') {
+ return { type: 'sourceFile', filePath: threeEntry };
+ }
+ if (defaultResolveRequest) {
+ return defaultResolveRequest(context, moduleName, platform);
+ }
+ return context.resolveRequest(context, moduleName, platform);
+};
+
module.exports = withNativeWind(config, { input: './global.css' });
diff --git a/package-lock.json b/package-lock.json
index 6ecb2b8..7b45481 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -11,17 +11,20 @@
"dependencies": {
"@expo/config-plugins": "~54.0.4",
"@hookform/resolvers": "^5.2.2",
+ "@pixiv/three-vrm": "3.5.2",
"@react-native-async-storage/async-storage": "2.2.0",
"@react-native-google-signin/google-signin": "^16.1.2",
"@react-navigation/bottom-tabs": "^7.10.1",
"@react-navigation/native": "^7.1.28",
"@react-navigation/native-stack": "^7.11.0",
+ "@react-three/fiber": "9.6.1",
"@tanstack/react-query": "^5.90.20",
"apisauce": "^1.1.1",
"axios": "^1.13.3",
"clsx": "^2.1.1",
"expo": "^54.0.0",
"expo-apple-authentication": "~8.0.8",
+ "expo-asset": "~12.0.13",
"expo-auth-session": "~7.0.10",
"expo-av": "~16.0.8",
"expo-build-properties": "~1.0.10",
@@ -30,6 +33,7 @@
"expo-dev-client": "~6.0.20",
"expo-device": "~8.0.10",
"expo-file-system": "~19.0.21",
+ "expo-gl": "~16.0.10",
"expo-image": "~3.0.11",
"expo-image-manipulator": "~14.0.8",
"expo-image-picker": "~17.0.10",
@@ -39,6 +43,7 @@
"expo-router": "~6.0.22",
"expo-screen-orientation": "~9.0.8",
"expo-secure-store": "~15.0.8",
+ "expo-speech-recognition": "3.1.3",
"expo-splash-screen": "~31.0.13",
"expo-status-bar": "~3.0.9",
"expo-updates": "~29.0.16",
@@ -54,8 +59,8 @@
"react-dom": "19.1.0",
"react-hook-form": "^7.71.1",
"react-native": "0.81.5",
+ "react-native-audio-api": "0.13.1",
"react-native-gesture-handler": "~2.28.0",
- "react-native-map-clustering": "^4.0.0",
"react-native-maps": "1.20.1",
"react-native-reanimated": "~4.1.1",
"react-native-safe-area-context": "~5.6.0",
@@ -63,11 +68,13 @@
"react-native-screens": "~4.16.0",
"react-native-web": "^0.21.0",
"react-native-webview": "^13.16.0",
- "react-native-worklets": "^0.7.2",
+ "react-native-worklets": "0.5.1",
"react-native-worklets-core": "^1.6.2",
"socket.io-client": "^4.8.3",
+ "supercluster": "^8.0.1",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^3.3.2",
+ "three": "0.170.0",
"typescript": "~5.9.2",
"yup": "^0.32.11",
"zod": "^3.25.76",
@@ -77,6 +84,7 @@
"@babel/core": "^7.12.9",
"@biomejs/biome": "^2.3.14",
"@types/react": "~19.1.10",
+ "@types/three": "^0.170.0",
"husky": "^9.1.7",
"lint-staged": "^16.2.7"
},
@@ -2469,26 +2477,6 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
- "node_modules/@mapbox/geo-viewport": {
- "version": "0.4.1",
- "resolved": "https://registry.npmjs.org/@mapbox/geo-viewport/-/geo-viewport-0.4.1.tgz",
- "integrity": "sha512-5g6eM3EOSl7+0p0VY+vHWEYjUlNzof936VKHTi/NuJVABjbYe8D2NAVJ0qt5C9Np4glUlhKFepgAgQ0OEybrjQ==",
- "license": "BSD-2-Clause",
- "dependencies": {
- "@mapbox/sphericalmercator": "~1.1.0"
- }
- },
- "node_modules/@mapbox/sphericalmercator": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/@mapbox/sphericalmercator/-/sphericalmercator-1.1.0.tgz",
- "integrity": "sha512-pEsfZyG4OMThlfFQbCte4gegvHUjxXCjz0KZ4Xk8NdOYTQBLflj6U8PL05RPAiuRAMAQNUUKJuL6qYZ5Y4kAWA==",
- "bin": {
- "bbox": "bin/bbox.js",
- "to4326": "bin/to4326.js",
- "to900913": "bin/to900913.js",
- "xyz": "bin/xyz.js"
- }
- },
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -2524,6 +2512,142 @@
"node": ">= 8"
}
},
+ "node_modules/@pixiv/three-vrm": {
+ "version": "3.5.2",
+ "resolved": "https://registry.npmjs.org/@pixiv/three-vrm/-/three-vrm-3.5.2.tgz",
+ "integrity": "sha512-YC3T8WZvb18n1uzYS60I9WCc2kOJxWZ/Q0RfklFAg0nD3Dz/kvvEoCYfZj5idnJqOtbLUXZGXZF1KCXKUB2qHg==",
+ "license": "MIT",
+ "dependencies": {
+ "@pixiv/three-vrm-core": "3.5.2",
+ "@pixiv/three-vrm-materials-hdr-emissive-multiplier": "3.5.2",
+ "@pixiv/three-vrm-materials-mtoon": "3.5.2",
+ "@pixiv/three-vrm-materials-v0compat": "3.5.2",
+ "@pixiv/three-vrm-node-constraint": "3.5.2",
+ "@pixiv/three-vrm-springbone": "3.5.2"
+ },
+ "peerDependencies": {
+ "three": ">=0.137"
+ }
+ },
+ "node_modules/@pixiv/three-vrm-core": {
+ "version": "3.5.2",
+ "resolved": "https://registry.npmjs.org/@pixiv/three-vrm-core/-/three-vrm-core-3.5.2.tgz",
+ "integrity": "sha512-QFpkCvsD5VMApNyIqFEw1lxSKFCM5X8+715FsfsAOx5mLh2LP0E0pDmqpDjfau/kAkDpWquGwzzHmXdAYjc8mQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@pixiv/types-vrm-0.0": "3.5.2",
+ "@pixiv/types-vrmc-vrm-1.0": "3.5.2"
+ },
+ "peerDependencies": {
+ "three": ">=0.137"
+ }
+ },
+ "node_modules/@pixiv/three-vrm-materials-hdr-emissive-multiplier": {
+ "version": "3.5.2",
+ "resolved": "https://registry.npmjs.org/@pixiv/three-vrm-materials-hdr-emissive-multiplier/-/three-vrm-materials-hdr-emissive-multiplier-3.5.2.tgz",
+ "integrity": "sha512-XJk6l/kexWeYpZmQu0h6VBXPsx4IV2uCN/9EPSJV8usspmP8CcMOsWx/5ESW4DKvGFufbiFZ5J6SGECJOTXTYg==",
+ "license": "MIT",
+ "dependencies": {
+ "@pixiv/types-vrmc-materials-hdr-emissive-multiplier-1.0": "3.5.2"
+ },
+ "peerDependencies": {
+ "three": ">=0.137"
+ }
+ },
+ "node_modules/@pixiv/three-vrm-materials-mtoon": {
+ "version": "3.5.2",
+ "resolved": "https://registry.npmjs.org/@pixiv/three-vrm-materials-mtoon/-/three-vrm-materials-mtoon-3.5.2.tgz",
+ "integrity": "sha512-P0oobZJ+JrMae7jdGgHqQqcoHpp3WYXq5blyCMybfsgxh9KtDzJ2NpBXf57LQlkN0cD6iT0R299tAqfH/1Uqcg==",
+ "license": "MIT",
+ "dependencies": {
+ "@pixiv/types-vrm-0.0": "3.5.2",
+ "@pixiv/types-vrmc-materials-mtoon-1.0": "3.5.2"
+ },
+ "peerDependencies": {
+ "three": ">=0.137"
+ }
+ },
+ "node_modules/@pixiv/three-vrm-materials-v0compat": {
+ "version": "3.5.2",
+ "resolved": "https://registry.npmjs.org/@pixiv/three-vrm-materials-v0compat/-/three-vrm-materials-v0compat-3.5.2.tgz",
+ "integrity": "sha512-KuAq0ewDGqDKP8lmLAFrhpwRhYnUhnck6HqjO90XJM1b6HgoqyZK2CNOfTsgJQMJgp7sXF6J0EhjCkBiXTNvGA==",
+ "license": "MIT",
+ "dependencies": {
+ "@pixiv/types-vrm-0.0": "3.5.2",
+ "@pixiv/types-vrmc-materials-mtoon-1.0": "3.5.2"
+ },
+ "peerDependencies": {
+ "three": ">=0.137"
+ }
+ },
+ "node_modules/@pixiv/three-vrm-node-constraint": {
+ "version": "3.5.2",
+ "resolved": "https://registry.npmjs.org/@pixiv/three-vrm-node-constraint/-/three-vrm-node-constraint-3.5.2.tgz",
+ "integrity": "sha512-jUBL6XkgTKhf/YgkDILHYAjOlN4n1Oz2dSSSvIfbJ0RVDah9jACm2775ZTult9ADgWmyvBsJQUr5u8JWOMLOKg==",
+ "license": "MIT",
+ "dependencies": {
+ "@pixiv/types-vrmc-node-constraint-1.0": "3.5.2"
+ },
+ "peerDependencies": {
+ "three": ">=0.137"
+ }
+ },
+ "node_modules/@pixiv/three-vrm-springbone": {
+ "version": "3.5.2",
+ "resolved": "https://registry.npmjs.org/@pixiv/three-vrm-springbone/-/three-vrm-springbone-3.5.2.tgz",
+ "integrity": "sha512-+V3ok2W93f7U4wTcclgZxwz3hKe78DjStl1lCBsORKTo21tLMHyeHoNDQCJ8i1bvjLrr4QLfGYH/oBbVNPxvWQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@pixiv/types-vrm-0.0": "3.5.2",
+ "@pixiv/types-vrmc-springbone-1.0": "3.5.2",
+ "@pixiv/types-vrmc-springbone-extended-collider-1.0": "3.5.2"
+ },
+ "peerDependencies": {
+ "three": ">=0.137"
+ }
+ },
+ "node_modules/@pixiv/types-vrm-0.0": {
+ "version": "3.5.2",
+ "resolved": "https://registry.npmjs.org/@pixiv/types-vrm-0.0/-/types-vrm-0.0-3.5.2.tgz",
+ "integrity": "sha512-H+aiDQqezc1plVJFyvxK4/gfFtpKlK9WghumHGm+dnsRoROLmUZw6sQoHmpd9UoXI2hu1UHiIyyJLHP/MMaWhQ==",
+ "license": "MIT"
+ },
+ "node_modules/@pixiv/types-vrmc-materials-hdr-emissive-multiplier-1.0": {
+ "version": "3.5.2",
+ "resolved": "https://registry.npmjs.org/@pixiv/types-vrmc-materials-hdr-emissive-multiplier-1.0/-/types-vrmc-materials-hdr-emissive-multiplier-1.0-3.5.2.tgz",
+ "integrity": "sha512-dv8oJ8uFRsGqLUtV75Dovmg3lbTbu5oi5s+P/5+oPy7LCs/JsJtSDXCycDwQBYtrie797VMHp4b7G8TvGCP+gQ==",
+ "license": "MIT"
+ },
+ "node_modules/@pixiv/types-vrmc-materials-mtoon-1.0": {
+ "version": "3.5.2",
+ "resolved": "https://registry.npmjs.org/@pixiv/types-vrmc-materials-mtoon-1.0/-/types-vrmc-materials-mtoon-1.0-3.5.2.tgz",
+ "integrity": "sha512-dXC9vphdKDKQNpPpj61l+nveQK/soYFpfFqr74hKo/7BaYm5B6nZ6uImQYPCD7U2WICUF91lBoRYKD9ONTPHmQ==",
+ "license": "MIT"
+ },
+ "node_modules/@pixiv/types-vrmc-node-constraint-1.0": {
+ "version": "3.5.2",
+ "resolved": "https://registry.npmjs.org/@pixiv/types-vrmc-node-constraint-1.0/-/types-vrmc-node-constraint-1.0-3.5.2.tgz",
+ "integrity": "sha512-4swBTE/FgQlpfOPnnFQFD7GF/XiNB883prIr2KOhAqru0VjOyiYDQPIZTwynKtBFCIjUxp9DW4YQzR6ETqhQmw==",
+ "license": "MIT"
+ },
+ "node_modules/@pixiv/types-vrmc-springbone-1.0": {
+ "version": "3.5.2",
+ "resolved": "https://registry.npmjs.org/@pixiv/types-vrmc-springbone-1.0/-/types-vrmc-springbone-1.0-3.5.2.tgz",
+ "integrity": "sha512-Au+nszNPbOuaCdaymOQ5jXFknWb5Md7xjCzoP9aPAHeh1uRKHccR1MErtzuRit/9L6mNhW1OneXet9+Ple5GmQ==",
+ "license": "MIT"
+ },
+ "node_modules/@pixiv/types-vrmc-springbone-extended-collider-1.0": {
+ "version": "3.5.2",
+ "resolved": "https://registry.npmjs.org/@pixiv/types-vrmc-springbone-extended-collider-1.0/-/types-vrmc-springbone-extended-collider-1.0-3.5.2.tgz",
+ "integrity": "sha512-W1CeeAwm6ybiWHrQcqOFOvcO/9LKzLA2fA0wBT30BCwvQFLIwo77mU91N88+TcEeXdH+RxVil/oEdVH7aFZ6nw==",
+ "license": "MIT"
+ },
+ "node_modules/@pixiv/types-vrmc-vrm-1.0": {
+ "version": "3.5.2",
+ "resolved": "https://registry.npmjs.org/@pixiv/types-vrmc-vrm-1.0/-/types-vrmc-vrm-1.0-3.5.2.tgz",
+ "integrity": "sha512-kgvRCJc9J0ruaVIxC5R9x4ZYNpM1bxs7vSMcl4SYPi2+baolHvXQiH0kFaN61+rFleld+sOOjDHtE98egY2dxQ==",
+ "license": "MIT"
+ },
"node_modules/@radix-ui/primitive": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
@@ -3398,6 +3522,84 @@
"nanoid": "^3.3.11"
}
},
+ "node_modules/@react-three/fiber": {
+ "version": "9.6.1",
+ "resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.6.1.tgz",
+ "integrity": "sha512-zF0rsKcVYpcJwbFEnv2HkHX9cvOEgsfQo/X8lwmR2dn13S4qEQJXir9fxf5js2LQFoXqxOY7MDkOkYx2uZ4gSg==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.17.8",
+ "@types/webxr": "*",
+ "base64-js": "^1.5.1",
+ "buffer": "^6.0.3",
+ "its-fine": "^2.0.0",
+ "react-use-measure": "^2.1.7",
+ "scheduler": "^0.27.0",
+ "suspend-react": "^0.1.3",
+ "use-sync-external-store": "^1.4.0",
+ "zustand": "^5.0.3"
+ },
+ "peerDependencies": {
+ "expo": ">=43.0",
+ "expo-asset": ">=8.4",
+ "expo-file-system": ">=11.0",
+ "expo-gl": ">=11.0",
+ "react": ">=19 <19.3",
+ "react-dom": ">=19 <19.3",
+ "react-native": ">=0.78",
+ "three": ">=0.156"
+ },
+ "peerDependenciesMeta": {
+ "expo": {
+ "optional": true
+ },
+ "expo-asset": {
+ "optional": true
+ },
+ "expo-file-system": {
+ "optional": true
+ },
+ "expo-gl": {
+ "optional": true
+ },
+ "react-dom": {
+ "optional": true
+ },
+ "react-native": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@react-three/fiber/node_modules/buffer": {
+ "version": "6.0.3",
+ "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
+ "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "base64-js": "^1.3.1",
+ "ieee754": "^1.2.1"
+ }
+ },
+ "node_modules/@react-three/fiber/node_modules/scheduler": {
+ "version": "0.27.0",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
+ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
+ "license": "MIT"
+ },
"node_modules/@sinclair/typebox": {
"version": "0.27.10",
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz",
@@ -3460,6 +3662,13 @@
"react": "^18 || ^19"
}
},
+ "node_modules/@tweenjs/tween.js": {
+ "version": "23.1.3",
+ "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
+ "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -3583,12 +3792,49 @@
"csstype": "^3.0.2"
}
},
+ "node_modules/@types/react-reconciler": {
+ "version": "0.28.9",
+ "resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.28.9.tgz",
+ "integrity": "sha512-HHM3nxyUZ3zAylX8ZEyrDNd2XZOnQ0D5XfunJF5FLQnZbHHYq4UWvW1QfelQNXv1ICNkwYhfxjwfnqivYB6bFg==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*"
+ }
+ },
"node_modules/@types/stack-utils": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz",
"integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==",
"license": "MIT"
},
+ "node_modules/@types/stats.js": {
+ "version": "0.17.4",
+ "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz",
+ "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/three": {
+ "version": "0.170.0",
+ "resolved": "https://registry.npmjs.org/@types/three/-/three-0.170.0.tgz",
+ "integrity": "sha512-CUm2uckq+zkCY7ZbFpviRttY+6f9fvwm6YqSqPfA5K22s9w7R4VnA3rzJse8kHVvuzLcTx+CjNCs2NYe0QFAyg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@tweenjs/tween.js": "~23.1.3",
+ "@types/stats.js": "*",
+ "@types/webxr": "*",
+ "@webgpu/types": "*",
+ "fflate": "~0.8.2",
+ "meshoptimizer": "~0.18.1"
+ }
+ },
+ "node_modules/@types/webxr": {
+ "version": "0.5.24",
+ "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz",
+ "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==",
+ "license": "MIT"
+ },
"node_modules/@types/yargs": {
"version": "17.0.35",
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz",
@@ -3633,6 +3879,13 @@
"@urql/core": "^5.0.0"
}
},
+ "node_modules/@webgpu/types": {
+ "version": "0.1.71",
+ "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.71.tgz",
+ "integrity": "sha512-mMy8/ODcKhab808co15eW+yN+HgXoQxRQHTiBV9Mrvl1r0ufnid7YOcI+gi4eUWSWl9ezD6TW2KXccrL8HCh2A==",
+ "dev": true,
+ "license": "BSD-3-Clause"
+ },
"node_modules/@xmldom/xmldom": {
"version": "0.8.11",
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz",
@@ -5372,13 +5625,13 @@
}
},
"node_modules/expo-asset": {
- "version": "12.0.12",
- "resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-12.0.12.tgz",
- "integrity": "sha512-CsXFCQbx2fElSMn0lyTdRIyKlSXOal6ilLJd+yeZ6xaC7I9AICQgscY5nj0QcwgA+KYYCCEQEBndMsmj7drOWQ==",
+ "version": "12.0.13",
+ "resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-12.0.13.tgz",
+ "integrity": "sha512-x/p7WvQUnkn6K43b9eL6SPeq5Vnf1E8BDe9bDrWrvMqzyUvJnUFvl+ctg3034s/+UHe7Ne2pAmc0+yzbl8CrDQ==",
"license": "MIT",
"dependencies": {
"@expo/image-utils": "^0.8.8",
- "expo-constants": "~18.0.12"
+ "expo-constants": "~18.0.13"
},
"peerDependencies": {
"expo": "*",
@@ -5591,6 +5844,34 @@
"react-native": "*"
}
},
+ "node_modules/expo-gl": {
+ "version": "16.0.10",
+ "resolved": "https://registry.npmjs.org/expo-gl/-/expo-gl-16.0.10.tgz",
+ "integrity": "sha512-/pPlSJvfmrGuW+UXBRVADr52nhiHFwRGXB8shhQb+b6KKreCuTmQZUASznAXS6YaHNjkOghmkaUW0hRnyiAwBQ==",
+ "license": "MIT",
+ "dependencies": {
+ "invariant": "^2.2.4"
+ },
+ "peerDependencies": {
+ "expo": "*",
+ "react": "*",
+ "react-dom": "*",
+ "react-native": "*",
+ "react-native-reanimated": "*",
+ "react-native-web": "*"
+ },
+ "peerDependenciesMeta": {
+ "react-dom": {
+ "optional": true
+ },
+ "react-native-reanimated": {
+ "optional": true
+ },
+ "react-native-web": {
+ "optional": true
+ }
+ }
+ },
"node_modules/expo-image": {
"version": "3.0.11",
"resolved": "https://registry.npmjs.org/expo-image/-/expo-image-3.0.11.tgz",
@@ -5853,6 +6134,17 @@
"node": ">=20.16.0"
}
},
+ "node_modules/expo-speech-recognition": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/expo-speech-recognition/-/expo-speech-recognition-3.1.3.tgz",
+ "integrity": "sha512-vluXqggfY2AJcazYITvnV6YSE2rVe5SId5TpdjMC/m6JPUgHCOODrcHJtAQF6OUYl4f2T7oZSceGw6fJCC86Xw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "expo": "*",
+ "react": "*",
+ "react-native": "*"
+ }
+ },
"node_modules/expo-splash-screen": {
"version": "31.0.13",
"resolved": "https://registry.npmjs.org/expo-splash-screen/-/expo-splash-screen-31.0.13.tgz",
@@ -6207,6 +6499,13 @@
"asap": "~2.0.3"
}
},
+ "node_modules/fflate": {
+ "version": "0.8.3",
+ "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
+ "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/fill-range": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
@@ -7132,6 +7431,18 @@
"node": ">=8"
}
},
+ "node_modules/its-fine": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/its-fine/-/its-fine-2.0.0.tgz",
+ "integrity": "sha512-KLViCmWx94zOvpLwSlsx6yOCeMhZYaxrJV87Po5k/FoZzcPSahvK5qJ7fYhS61sZi5ikmh2S3Hz55A2l3U69ng==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/react-reconciler": "^0.28.9"
+ },
+ "peerDependencies": {
+ "react": "^19.0.0"
+ }
+ },
"node_modules/jest-environment-node": {
"version": "29.7.0",
"resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-29.7.0.tgz",
@@ -8331,6 +8642,13 @@
"node": ">= 8"
}
},
+ "node_modules/meshoptimizer": {
+ "version": "0.18.1",
+ "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-0.18.1.tgz",
+ "integrity": "sha512-ZhoIoL7TNV4s5B6+rx5mC//fw8/POGyNxS/DZyCJeiZ12ScLfVwRE/GfsxwiTkMYYD5DmK2/JXnEVXqL4rF+Sw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/metro": {
"version": "0.83.3",
"resolved": "https://registry.npmjs.org/metro/-/metro-0.83.3.tgz",
@@ -9916,6 +10234,40 @@
}
}
},
+ "node_modules/react-native-audio-api": {
+ "version": "0.13.1",
+ "resolved": "https://registry.npmjs.org/react-native-audio-api/-/react-native-audio-api-0.13.1.tgz",
+ "integrity": "sha512-FuUcj/flfWImRFQkxpJVJe0HueQxwliCjeI+kVcWQPOj46mkpsbEhvNF0IPRNwWiDl1GaiYka8oLShZ+Y7Y9OA==",
+ "license": "MIT",
+ "dependencies": {
+ "semver": "^7.7.3"
+ },
+ "bin": {
+ "setup-rn-audio-api-web": "scripts/setup-rn-audio-api-web.js"
+ },
+ "peerDependencies": {
+ "react": "*",
+ "react-native": "*",
+ "react-native-worklets": ">= 0.6.0"
+ },
+ "peerDependenciesMeta": {
+ "react-native-worklets": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/react-native-audio-api/node_modules/semver": {
+ "version": "7.8.5",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
+ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/react-native-css-interop": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/react-native-css-interop/-/react-native-css-interop-0.2.1.tgz",
@@ -10224,20 +10576,6 @@
"react-native": "*"
}
},
- "node_modules/react-native-map-clustering": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/react-native-map-clustering/-/react-native-map-clustering-4.0.0.tgz",
- "integrity": "sha512-+YNh4frhZIHQReURxYGHNy9MJ50GYWpW6psoBEjvTG6vb33eYu00GmO8Pu/9VwMB1YL5lOxZ9+sJClJ8Mz1Bxw==",
- "license": "MIT",
- "dependencies": {
- "@mapbox/geo-viewport": "^0.4.1",
- "supercluster": "^8.0.0"
- },
- "peerDependencies": {
- "react-native": "*",
- "react-native-maps": "*"
- }
- },
"node_modules/react-native-maps": {
"version": "1.20.1",
"resolved": "https://registry.npmjs.org/react-native-maps/-/react-native-maps-1.20.1.tgz",
@@ -10381,25 +10719,25 @@
}
},
"node_modules/react-native-worklets": {
- "version": "0.7.2",
- "resolved": "https://registry.npmjs.org/react-native-worklets/-/react-native-worklets-0.7.2.tgz",
- "integrity": "sha512-DuLu1kMV/Uyl9pQHp3hehAlThoLw7Yk2FwRTpzASOmI+cd4845FWn3m2bk9MnjUw8FBRIyhwLqYm2AJaXDXsog==",
- "license": "MIT",
- "dependencies": {
- "@babel/plugin-transform-arrow-functions": "7.27.1",
- "@babel/plugin-transform-class-properties": "7.27.1",
- "@babel/plugin-transform-classes": "7.28.4",
- "@babel/plugin-transform-nullish-coalescing-operator": "7.27.1",
- "@babel/plugin-transform-optional-chaining": "7.27.1",
- "@babel/plugin-transform-shorthand-properties": "7.27.1",
- "@babel/plugin-transform-template-literals": "7.27.1",
- "@babel/plugin-transform-unicode-regex": "7.27.1",
- "@babel/preset-typescript": "7.27.1",
- "convert-source-map": "2.0.0",
- "semver": "7.7.3"
+ "version": "0.5.1",
+ "resolved": "https://registry.npmjs.org/react-native-worklets/-/react-native-worklets-0.5.1.tgz",
+ "integrity": "sha512-lJG6Uk9YuojjEX/tQrCbcbmpdLCSFxDK1rJlkDhgqkVi1KZzG7cdcBFQRqyNOOzR9Y0CXNuldmtWTGOyM0k0+w==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/plugin-transform-arrow-functions": "^7.0.0-0",
+ "@babel/plugin-transform-class-properties": "^7.0.0-0",
+ "@babel/plugin-transform-classes": "^7.0.0-0",
+ "@babel/plugin-transform-nullish-coalescing-operator": "^7.0.0-0",
+ "@babel/plugin-transform-optional-chaining": "^7.0.0-0",
+ "@babel/plugin-transform-shorthand-properties": "^7.0.0-0",
+ "@babel/plugin-transform-template-literals": "^7.0.0-0",
+ "@babel/plugin-transform-unicode-regex": "^7.0.0-0",
+ "@babel/preset-typescript": "^7.16.7",
+ "convert-source-map": "^2.0.0",
+ "semver": "7.7.2"
},
"peerDependencies": {
- "@babel/core": "*",
+ "@babel/core": "^7.0.0-0",
"react": "*",
"react-native": "*"
}
@@ -10417,96 +10755,10 @@
"react-native": "*"
}
},
- "node_modules/react-native-worklets/node_modules/@babel/plugin-transform-class-properties": {
- "version": "7.27.1",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.27.1.tgz",
- "integrity": "sha512-D0VcalChDMtuRvJIu3U/fwWjf8ZMykz5iZsg77Nuj821vCKI3zCyRLwRdWbsuJ/uRwZhZ002QtCqIkwC/ZkvbA==",
- "license": "MIT",
- "dependencies": {
- "@babel/helper-create-class-features-plugin": "^7.27.1",
- "@babel/helper-plugin-utils": "^7.27.1"
- },
- "engines": {
- "node": ">=6.9.0"
- },
- "peerDependencies": {
- "@babel/core": "^7.0.0-0"
- }
- },
- "node_modules/react-native-worklets/node_modules/@babel/plugin-transform-classes": {
- "version": "7.28.4",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.28.4.tgz",
- "integrity": "sha512-cFOlhIYPBv/iBoc+KS3M6et2XPtbT2HiCRfBXWtfpc9OAyostldxIf9YAYB6ypURBBbx+Qv6nyrLzASfJe+hBA==",
- "license": "MIT",
- "dependencies": {
- "@babel/helper-annotate-as-pure": "^7.27.3",
- "@babel/helper-compilation-targets": "^7.27.2",
- "@babel/helper-globals": "^7.28.0",
- "@babel/helper-plugin-utils": "^7.27.1",
- "@babel/helper-replace-supers": "^7.27.1",
- "@babel/traverse": "^7.28.4"
- },
- "engines": {
- "node": ">=6.9.0"
- },
- "peerDependencies": {
- "@babel/core": "^7.0.0-0"
- }
- },
- "node_modules/react-native-worklets/node_modules/@babel/plugin-transform-nullish-coalescing-operator": {
- "version": "7.27.1",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.27.1.tgz",
- "integrity": "sha512-aGZh6xMo6q9vq1JGcw58lZ1Z0+i0xB2x0XaauNIUXd6O1xXc3RwoWEBlsTQrY4KQ9Jf0s5rgD6SiNkaUdJegTA==",
- "license": "MIT",
- "dependencies": {
- "@babel/helper-plugin-utils": "^7.27.1"
- },
- "engines": {
- "node": ">=6.9.0"
- },
- "peerDependencies": {
- "@babel/core": "^7.0.0-0"
- }
- },
- "node_modules/react-native-worklets/node_modules/@babel/plugin-transform-optional-chaining": {
- "version": "7.27.1",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.27.1.tgz",
- "integrity": "sha512-BQmKPPIuc8EkZgNKsv0X4bPmOoayeu4F1YCwx2/CfmDSXDbp7GnzlUH+/ul5VGfRg1AoFPsrIThlEBj2xb4CAg==",
- "license": "MIT",
- "dependencies": {
- "@babel/helper-plugin-utils": "^7.27.1",
- "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1"
- },
- "engines": {
- "node": ">=6.9.0"
- },
- "peerDependencies": {
- "@babel/core": "^7.0.0-0"
- }
- },
- "node_modules/react-native-worklets/node_modules/@babel/preset-typescript": {
- "version": "7.27.1",
- "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.27.1.tgz",
- "integrity": "sha512-l7WfQfX0WK4M0v2RudjuQK4u99BS6yLHYEmdtVPP7lKV013zr9DygFuWNlnbvQ9LR+LS0Egz/XAvGx5U9MX0fQ==",
- "license": "MIT",
- "dependencies": {
- "@babel/helper-plugin-utils": "^7.27.1",
- "@babel/helper-validator-option": "^7.27.1",
- "@babel/plugin-syntax-jsx": "^7.27.1",
- "@babel/plugin-transform-modules-commonjs": "^7.27.1",
- "@babel/plugin-transform-typescript": "^7.27.1"
- },
- "engines": {
- "node": ">=6.9.0"
- },
- "peerDependencies": {
- "@babel/core": "^7.0.0-0"
- }
- },
"node_modules/react-native-worklets/node_modules/semver": {
- "version": "7.7.3",
- "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
- "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
+ "version": "7.7.2",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
+ "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
@@ -10689,6 +10941,21 @@
}
}
},
+ "node_modules/react-use-measure": {
+ "version": "2.1.7",
+ "resolved": "https://registry.npmjs.org/react-use-measure/-/react-use-measure-2.1.7.tgz",
+ "integrity": "sha512-KrvcAo13I/60HpwGO5jpW7E9DfusKyLPLvuHlUyP5zqnmAPhNc6qTRjUQrdTADl0lpPpDVU2/Gg51UlOGHXbdg==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": ">=16.13",
+ "react-dom": ">=16.13"
+ },
+ "peerDependenciesMeta": {
+ "react-dom": {
+ "optional": true
+ }
+ }
+ },
"node_modules/read-cache": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
@@ -11605,6 +11872,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/suspend-react": {
+ "version": "0.1.3",
+ "resolved": "https://registry.npmjs.org/suspend-react/-/suspend-react-0.1.3.tgz",
+ "integrity": "sha512-aqldKgX9aZqpoDp3e8/BZ8Dm7x1pJl+qI3ZKxDN0i/IQTWUwBx/ManmlVJ3wowqbno6c2bmiIfs+Um6LbsjJyQ==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": ">=17.0"
+ }
+ },
"node_modules/tailwind-merge": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.4.0.tgz",
@@ -11804,6 +12080,12 @@
"node": ">=0.8"
}
},
+ "node_modules/three": {
+ "version": "0.170.0",
+ "resolved": "https://registry.npmjs.org/three/-/three-0.170.0.tgz",
+ "integrity": "sha512-FQK+LEpYc0fBD+J8g6oSEyyNzjp+Q7Ks1C568WWaoMRLW+TkNNWmenWeGgJjV105Gd+p/2ql1ZcjYvNiPZBhuQ==",
+ "license": "MIT"
+ },
"node_modules/throat": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/throat/-/throat-5.0.0.tgz",
diff --git a/package.json b/package.json
index b53bcc6..b530d05 100644
--- a/package.json
+++ b/package.json
@@ -23,17 +23,20 @@
"dependencies": {
"@expo/config-plugins": "~54.0.4",
"@hookform/resolvers": "^5.2.2",
+ "@pixiv/three-vrm": "3.5.2",
"@react-native-async-storage/async-storage": "2.2.0",
"@react-native-google-signin/google-signin": "^16.1.2",
"@react-navigation/bottom-tabs": "^7.10.1",
"@react-navigation/native": "^7.1.28",
"@react-navigation/native-stack": "^7.11.0",
+ "@react-three/fiber": "9.6.1",
"@tanstack/react-query": "^5.90.20",
"apisauce": "^1.1.1",
"axios": "^1.13.3",
"clsx": "^2.1.1",
"expo": "^54.0.0",
"expo-apple-authentication": "~8.0.8",
+ "expo-asset": "~12.0.13",
"expo-auth-session": "~7.0.10",
"expo-av": "~16.0.8",
"expo-build-properties": "~1.0.10",
@@ -42,6 +45,7 @@
"expo-dev-client": "~6.0.20",
"expo-device": "~8.0.10",
"expo-file-system": "~19.0.21",
+ "expo-gl": "~16.0.10",
"expo-image": "~3.0.11",
"expo-image-manipulator": "~14.0.8",
"expo-image-picker": "~17.0.10",
@@ -51,6 +55,7 @@
"expo-router": "~6.0.22",
"expo-screen-orientation": "~9.0.8",
"expo-secure-store": "~15.0.8",
+ "expo-speech-recognition": "3.1.3",
"expo-splash-screen": "~31.0.13",
"expo-status-bar": "~3.0.9",
"expo-updates": "~29.0.16",
@@ -66,6 +71,7 @@
"react-dom": "19.1.0",
"react-hook-form": "^7.71.1",
"react-native": "0.81.5",
+ "react-native-audio-api": "0.13.1",
"react-native-gesture-handler": "~2.28.0",
"react-native-maps": "1.20.1",
"react-native-reanimated": "~4.1.1",
@@ -74,12 +80,13 @@
"react-native-screens": "~4.16.0",
"react-native-web": "^0.21.0",
"react-native-webview": "^13.16.0",
- "react-native-worklets": "^0.7.2",
+ "react-native-worklets": "0.5.1",
"react-native-worklets-core": "^1.6.2",
"socket.io-client": "^4.8.3",
"supercluster": "^8.0.1",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^3.3.2",
+ "three": "0.170.0",
"typescript": "~5.9.2",
"yup": "^0.32.11",
"zod": "^3.25.76",
@@ -89,6 +96,7 @@
"@babel/core": "^7.12.9",
"@biomejs/biome": "^2.3.14",
"@types/react": "~19.1.10",
+ "@types/three": "^0.170.0",
"husky": "^9.1.7",
"lint-staged": "^16.2.7"
},
diff --git a/patches/react-native-audio-api+0.13.1.patch b/patches/react-native-audio-api+0.13.1.patch
new file mode 100644
index 0000000..0e5b508
--- /dev/null
+++ b/patches/react-native-audio-api+0.13.1.patch
@@ -0,0 +1,16 @@
+diff --git a/node_modules/react-native-audio-api/ios/audioapi/ios/system/AudioSessionManager.mm b/node_modules/react-native-audio-api/ios/audioapi/ios/system/AudioSessionManager.mm
+index d115837..691a8e7 100644
+--- a/node_modules/react-native-audio-api/ios/audioapi/ios/system/AudioSessionManager.mm
++++ b/node_modules/react-native-audio-api/ios/audioapi/ios/system/AudioSessionManager.mm
+@@ -513,7 +513,10 @@ - (AVAudioSessionCategoryOptions)optionsFromArray:(NSArray *)optionsArray
+ }
+
+ if ([option isEqualToString:@"allowBluetoothHFP"]) {
+- options |= AVAudioSessionCategoryOptionAllowBluetoothHFP;
++ // AVAudioSessionCategoryOptionAllowBluetoothHFP is the iOS 26 SDK
++ // rename of AllowBluetooth (same bit value); the old name compiles
++ // on every SDK including the Xcode 16.x EAS build images.
++ options |= AVAudioSessionCategoryOptionAllowBluetooth;
+ continue;
+ }
+
diff --git a/plugins/withAllowNonModularIncludes.js b/plugins/withAllowNonModularIncludes.js
index b134ead..52000f2 100644
--- a/plugins/withAllowNonModularIncludes.js
+++ b/plugins/withAllowNonModularIncludes.js
@@ -84,10 +84,14 @@ module.exports = function withTrickBookPodfilePatch(config) {
if (!/^\s*use_modular_headers!/m.test(src)) {
const targetRegex = /^([ \t]*target\s+['"][^'"]+['"]\s+do[ \t]*$)/m;
if (targetRegex.test(src)) {
- src = src.replace(targetRegex, "$1\n use_modular_headers!");
- console.log('[withAllowNonModularIncludes] injected use_modular_headers! after target line');
+ src = src.replace(targetRegex, '$1\n use_modular_headers!');
+ console.log(
+ '[withAllowNonModularIncludes] injected use_modular_headers! after target line',
+ );
} else {
- console.log('[withAllowNonModularIncludes] WARN: target line not found, use_modular_headers! NOT added');
+ console.log(
+ '[withAllowNonModularIncludes] WARN: target line not found, use_modular_headers! NOT added',
+ );
}
} else {
console.log('[withAllowNonModularIncludes] use_modular_headers! already present');
@@ -104,10 +108,14 @@ module.exports = function withTrickBookPodfilePatch(config) {
`${body}${POST_INSTALL_INJECTION}\n${indent}end${trailing}`,
);
if (src === beforePostInstall) {
- console.log('[withAllowNonModularIncludes] WARN: post_install regex did not match, falling back to append');
+ console.log(
+ '[withAllowNonModularIncludes] WARN: post_install regex did not match, falling back to append',
+ );
src += `\npost_install do |installer|${POST_INSTALL_INJECTION}\nend\n`;
} else {
- console.log('[withAllowNonModularIncludes] injected build settings into existing post_install');
+ console.log(
+ '[withAllowNonModularIncludes] injected build settings into existing post_install',
+ );
}
} else {
console.log('[withAllowNonModularIncludes] no post_install found, appending fresh block');
@@ -121,10 +129,7 @@ module.exports = function withTrickBookPodfilePatch(config) {
if (!src.includes(PRE_INSTALL_MARKER)) {
// Insert BEFORE the post_install block so order is conventional.
if (/post_install\s+do\s+\|installer\|/.test(src)) {
- src = src.replace(
- /(post_install\s+do\s+\|installer\|)/,
- `${PRE_INSTALL_BLOCK}\n$1`,
- );
+ src = src.replace(/(post_install\s+do\s+\|installer\|)/, `${PRE_INSTALL_BLOCK}\n$1`);
console.log('[withAllowNonModularIncludes] injected pre_install before post_install');
} else {
src += `\n${PRE_INSTALL_BLOCK}\n`;
diff --git a/scripts/check-prod-ready.sh b/scripts/check-prod-ready.sh
new file mode 100755
index 0000000..2095600
--- /dev/null
+++ b/scripts/check-prod-ready.sh
@@ -0,0 +1,90 @@
+#!/bin/bash
+# Production readiness checks
+# Runs in two contexts:
+# 1. CI on PRs to v2-rebuild (GitHub Actions)
+# 2. Pre-build hook before EAS builds (eas.json prebuild script)
+# Catches dev-only changes that shouldn't ship to production builds
+
+ERRORS=0
+
+echo "=== Production Readiness Checks ==="
+echo ""
+
+# 1. Verify production API URL hasn't been changed
+if grep -q "https://api\.thetrickbook\.com/api" src/constants/api.ts 2>/dev/null; then
+ echo "PASS: Production API URL is correct"
+else
+ echo "FAIL: Production API URL (https://api.thetrickbook.com/api) not found in src/constants/api.ts"
+ ERRORS=$((ERRORS + 1))
+fi
+
+# 2. Verify production socket URL
+if grep -q "https://api\.thetrickbook\.com" src/constants/api.ts 2>/dev/null; then
+ echo "PASS: Production socket URL is correct"
+else
+ echo "FAIL: Production socket URL not found in src/constants/api.ts"
+ ERRORS=$((ERRORS + 1))
+fi
+
+# 3. Verify EAS store profiles have EXPO_NO_DOTENV
+if grep -q "EXPO_NO_DOTENV" eas.json 2>/dev/null; then
+ echo "PASS: EAS build profiles have EXPO_NO_DOTENV"
+else
+ echo "FAIL: eas.json missing EXPO_NO_DOTENV=1 in store build profiles"
+ ERRORS=$((ERRORS + 1))
+fi
+
+# 4. Verify no hardcoded secrets in source files
+SECRETS_FOUND=$(grep -rn "sk_live\|AKIA[A-Z0-9]\{16\}" \
+ --include="*.ts" --include="*.tsx" --include="*.js" \
+ --exclude-dir=node_modules --exclude-dir=.expo \
+ . 2>/dev/null || true)
+if [ -n "$SECRETS_FOUND" ]; then
+ echo "FAIL: Potential secrets found in source files:"
+ echo "$SECRETS_FOUND"
+ ERRORS=$((ERRORS + 1))
+else
+ echo "PASS: No hardcoded secrets detected"
+fi
+
+# 5. Verify Google Sign-In uses conditional require (not static import)
+if grep -q "from '@react-native-google-signin" app/\(auth\)/welcome.tsx 2>/dev/null; then
+ echo "FAIL: welcome.tsx has static import of @react-native-google-signin (should use conditional require)"
+ ERRORS=$((ERRORS + 1))
+else
+ echo "PASS: Google Sign-In uses conditional loading"
+fi
+
+# 6. Verify @react-native-google-signin is in dependencies (not accidentally removed)
+if grep -q "@react-native-google-signin/google-signin" package.json 2>/dev/null; then
+ echo "PASS: Google Sign-In native package is in dependencies"
+else
+ echo "FAIL: @react-native-google-signin/google-signin missing from package.json"
+ ERRORS=$((ERRORS + 1))
+fi
+
+# 7. Verify app.config.js has the Google Sign-In plugin
+if grep -q "@react-native-google-signin/google-signin" app.config.js 2>/dev/null; then
+ echo "PASS: Google Sign-In config plugin is registered"
+else
+ echo "FAIL: @react-native-google-signin/google-signin plugin missing from app.config.js"
+ ERRORS=$((ERRORS + 1))
+fi
+
+# 8. Verify service account key path exists for Play Store submissions
+SERVICE_KEY_PATH=$(grep -o '"serviceAccountKeyPath"[[:space:]]*:[[:space:]]*"[^"]*"' eas.json 2>/dev/null | head -1 | sed 's/.*"\([^"]*\)"/\1/' || true)
+if [ -n "$SERVICE_KEY_PATH" ] && [ -f "$SERVICE_KEY_PATH" ]; then
+ echo "PASS: Google Play service account key exists at $SERVICE_KEY_PATH"
+elif [ -n "$SERVICE_KEY_PATH" ]; then
+ echo "WARN: Service account key path configured ($SERVICE_KEY_PATH) but file not found (OK in CI)"
+else
+ echo "WARN: No serviceAccountKeyPath in eas.json (needed for eas submit)"
+fi
+
+echo ""
+if [ $ERRORS -gt 0 ]; then
+ echo "FAILED: $ERRORS check(s) failed β fix before building for production"
+ exit 1
+else
+ echo "All production readiness checks passed"
+fi
diff --git a/src/components/chat/RichContentCards.tsx b/src/components/chat/RichContentCards.tsx
new file mode 100644
index 0000000..9807ac5
--- /dev/null
+++ b/src/components/chat/RichContentCards.tsx
@@ -0,0 +1,501 @@
+/**
+ * Rich Content Cards for Bot Chat
+ * Renders interactive cards (spots, tricks, tricklists) in chat messages
+ */
+
+import { Ionicons } from '@expo/vector-icons';
+import { router } from 'expo-router';
+import { Image, Pressable, StyleSheet, Text, View } from 'react-native';
+import type { RichContent } from '@/lib/api/messages';
+
+const YELLOW = '#FCF150';
+const DARK = '#1a1a1a';
+const MAX_CARD_WIDTH = 280;
+
+// --- Individual Card Components ---
+
+function SpotCard({
+ data,
+ onPress,
+}: {
+ data: {
+ _id: string;
+ name: string;
+ imageUrl?: string;
+ rating?: number;
+ reviewCount?: number;
+ category?: string;
+ address?: string;
+ };
+ onPress: () => void;
+}) {
+ return (
+
+ {data.imageUrl ? (
+
+ ) : (
+
+
+
+ )}
+
+
+ {data.name}
+
+ {data.address && (
+
+ {data.address}
+
+ )}
+
+ {data.rating != null && (
+
+ β {data.rating.toFixed(1)}
+ {data.reviewCount != null && (
+ ({data.reviewCount})
+ )}
+
+ )}
+ {data.category && (
+
+ {data.category}
+
+ )}
+
+
+
+ );
+}
+
+function SpotsListCard({
+ data,
+ onPress,
+}: {
+ data: {
+ spots: Array<{
+ _id: string;
+ name: string;
+ imageUrl?: string;
+ rating?: number;
+ category?: string;
+ }>;
+ title?: string;
+ };
+ onPress: (spotId: string) => void;
+}) {
+ return (
+
+ {data.title && (
+
+
+ {data.title}
+
+ )}
+ {data.spots.slice(0, 3).map((spot, index) => (
+ 0 && styles.listItemBorder]}
+ onPress={() => onPress(spot._id)}
+ >
+ {spot.imageUrl ? (
+
+ ) : (
+
+
+
+ )}
+
+
+ {spot.name}
+
+
+ {spot.rating != null && (
+ β {spot.rating.toFixed(1)}
+ )}
+ {spot.category && {spot.category}}
+
+
+
+
+ ))}
+ {data.spots.length > 3 && +{data.spots.length - 3} more}
+
+ );
+}
+
+function TrickListCard({
+ data,
+ onPress,
+}: {
+ data: {
+ _id: string;
+ name: string;
+ trickCount?: number;
+ progress?: number;
+ difficulty?: string;
+ };
+ onPress: () => void;
+}) {
+ return (
+
+
+
+
+
+ {data.name}
+
+
+
+ {data.trickCount != null && {data.trickCount} tricks}
+ {data.difficulty && (
+
+ {data.difficulty}
+
+ )}
+
+ {data.progress != null && (
+
+
+
+
+ {Math.round(data.progress)}%
+
+ )}
+
+
+ );
+}
+
+function TrickCard({
+ data,
+ onPress,
+}: {
+ data: {
+ _id: string;
+ name: string;
+ difficulty?: string;
+ imageUrl?: string;
+ description?: string;
+ };
+ onPress: () => void;
+}) {
+ return (
+
+ {data.imageUrl && }
+
+
+ {data.name}
+
+ {data.difficulty && (
+
+ {data.difficulty}
+
+ )}
+ {data.description && (
+
+ {data.description}
+
+ )}
+
+
+ );
+}
+
+function SpotDraftCard({
+ data,
+}: {
+ data: {
+ name: string;
+ category?: string;
+ address?: string;
+ status?: string;
+ };
+}) {
+ return (
+
+
+
+ Spot Draft Created
+
+
+
+ {data.name}
+
+ {data.address && (
+
+ {data.address}
+
+ )}
+ {data.category && (
+
+ {data.category}
+
+ )}
+
+
+ );
+}
+
+// --- Helpers ---
+
+function getDifficultyStyle(difficulty: string) {
+ switch (difficulty.toLowerCase()) {
+ case 'beginner':
+ return { backgroundColor: 'rgba(76, 175, 80, 0.2)' };
+ case 'intermediate':
+ return { backgroundColor: 'rgba(252, 241, 80, 0.2)' };
+ case 'advanced':
+ return { backgroundColor: 'rgba(244, 67, 54, 0.2)' };
+ case 'expert':
+ return { backgroundColor: 'rgba(156, 39, 176, 0.2)' };
+ default:
+ return {};
+ }
+}
+
+function getDifficultyColor(difficulty: string) {
+ switch (difficulty.toLowerCase()) {
+ case 'beginner':
+ return '#4CAF50';
+ case 'intermediate':
+ return '#FCF150';
+ case 'advanced':
+ return '#F44336';
+ case 'expert':
+ return '#9C27B0';
+ default:
+ return '#999';
+ }
+}
+
+// --- Main Component ---
+
+export function RichContentCard({ richContent }: { richContent: RichContent }) {
+ const handleNavigate = (type: string, id: string) => {
+ switch (type) {
+ case 'spot':
+ router.push(`/(tabs)/spots/${id}`);
+ break;
+ case 'tricklist':
+ router.push(`/(tabs)/trickbook/list/${id}`);
+ break;
+ case 'trick':
+ router.push('/(tabs)/trickbook');
+ break;
+ }
+ };
+
+ switch (richContent.type) {
+ case 'spot_card':
+ return (
+ handleNavigate('spot', richContent.data._id)}
+ />
+ );
+ case 'spots_list':
+ return (
+ handleNavigate('spot', spotId)}
+ />
+ );
+ case 'tricklist_card':
+ return (
+ handleNavigate('tricklist', richContent.data._id)}
+ />
+ );
+ case 'trick_card':
+ return (
+ handleNavigate('trick', richContent.data._id)}
+ />
+ );
+ case 'spot_draft_confirmation':
+ return ;
+ default:
+ return null;
+ }
+}
+
+// --- Styles ---
+
+const styles = StyleSheet.create({
+ card: {
+ maxWidth: MAX_CARD_WIDTH,
+ backgroundColor: '#2A2A2A',
+ borderRadius: 12,
+ overflow: 'hidden',
+ shadowColor: '#000',
+ shadowOffset: { width: 0, height: 2 },
+ shadowOpacity: 0.25,
+ shadowRadius: 4,
+ elevation: 3,
+ marginTop: 6,
+ },
+ cardImage: {
+ width: '100%',
+ height: 140,
+ backgroundColor: '#333',
+ },
+ cardImageSmall: {
+ width: '100%',
+ height: 100,
+ backgroundColor: '#333',
+ },
+ imagePlaceholder: {
+ alignItems: 'center',
+ justifyContent: 'center',
+ backgroundColor: '#333',
+ },
+ cardBody: {
+ padding: 12,
+ gap: 6,
+ },
+ cardHeaderRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 8,
+ },
+ cardTitle: {
+ fontSize: 15,
+ fontWeight: '600',
+ color: '#FFF',
+ flexShrink: 1,
+ },
+ cardSubtitle: {
+ fontSize: 13,
+ color: '#AAA',
+ },
+ cardDescription: {
+ fontSize: 13,
+ color: '#AAA',
+ lineHeight: 18,
+ },
+ cardMeta: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 8,
+ flexWrap: 'wrap',
+ },
+ metaItem: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 4,
+ },
+ metaText: {
+ fontSize: 13,
+ color: '#CCC',
+ },
+ metaTextSmall: {
+ fontSize: 12,
+ color: '#CCC',
+ },
+ metaTextSecondary: {
+ fontSize: 12,
+ color: '#999',
+ },
+ badge: {
+ paddingHorizontal: 8,
+ paddingVertical: 2,
+ borderRadius: 10,
+ },
+ categoryBadge: {
+ backgroundColor: 'rgba(252, 241, 80, 0.15)',
+ },
+ badgeText: {
+ fontSize: 11,
+ fontWeight: '600',
+ color: YELLOW,
+ textTransform: 'capitalize',
+ },
+ // List styles
+ listHeader: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 6,
+ padding: 12,
+ paddingBottom: 8,
+ },
+ listTitle: {
+ fontSize: 14,
+ fontWeight: '600',
+ color: '#FFF',
+ },
+ listItem: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ paddingHorizontal: 12,
+ paddingVertical: 8,
+ gap: 10,
+ },
+ listItemBorder: {
+ borderTopWidth: StyleSheet.hairlineWidth,
+ borderTopColor: '#444',
+ },
+ listItemImage: {
+ width: 40,
+ height: 40,
+ borderRadius: 8,
+ backgroundColor: '#333',
+ },
+ listItemInfo: {
+ flex: 1,
+ gap: 2,
+ },
+ listItemName: {
+ fontSize: 14,
+ fontWeight: '500',
+ color: '#FFF',
+ },
+ moreText: {
+ fontSize: 12,
+ color: '#999',
+ textAlign: 'center',
+ paddingVertical: 8,
+ },
+ // Progress bar
+ progressContainer: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 8,
+ marginTop: 4,
+ },
+ progressTrack: {
+ flex: 1,
+ height: 4,
+ backgroundColor: '#444',
+ borderRadius: 2,
+ overflow: 'hidden',
+ },
+ progressFill: {
+ height: '100%',
+ backgroundColor: YELLOW,
+ borderRadius: 2,
+ },
+ progressText: {
+ fontSize: 12,
+ fontWeight: '600',
+ color: YELLOW,
+ },
+ // Confirmation card
+ confirmationCard: {
+ borderWidth: 1,
+ borderColor: 'rgba(76, 175, 80, 0.3)',
+ },
+ confirmationHeader: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 6,
+ paddingHorizontal: 12,
+ paddingTop: 10,
+ },
+ confirmationTitle: {
+ fontSize: 13,
+ fontWeight: '600',
+ color: '#4CAF50',
+ },
+});
diff --git a/src/components/companion/KaoriStage.tsx b/src/components/companion/KaoriStage.tsx
new file mode 100644
index 0000000..5f22204
--- /dev/null
+++ b/src/components/companion/KaoriStage.tsx
@@ -0,0 +1,877 @@
+/**
+ * KaoriStage β interactive 3D companion stage
+ *
+ * Renders the Kaori VRM avatar with a fully procedural idle animation
+ * (breathing, sway, blink, eye tracking) ported from the website's
+ * kaori-live stage so web and mobile share one motion language.
+ *
+ * One-finger drag orbits the camera, pinch zooms. Textures inside the
+ * VRM must be PNG/JPEG β expo-gl's native decoder supports only those.
+ *
+ * NOTE: Canvas/useLoader MUST come from '@react-three/fiber/native'.
+ * Importing that entry installs the polyfills (FileLoader, TextureLoader,
+ * BlobManager, URL.createObjectURL) that make GLTFLoader work on Hermes.
+ */
+
+import { type VRM, VRMLoaderPlugin, VRMUtils } from '@pixiv/three-vrm';
+import { Canvas, useFrame, useLoader, useThree } from '@react-three/fiber/native';
+import * as Device from 'expo-device';
+import { Component, type ReactNode, Suspense, useEffect, useMemo, useRef, useState } from 'react';
+import { ActivityIndicator, Pressable, StyleSheet, Text, View } from 'react-native';
+import { Gesture, GestureDetector } from 'react-native-gesture-handler';
+import * as THREE from 'three';
+import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
+import { brandColors } from '@/constants/colors';
+import {
+ createTrickDemoState,
+ driveDemo,
+ isDemoActive,
+ type TrickDemoState,
+} from './trickAnimations';
+
+// three r170's GLTFLoader sniffs navigator.userAgent for Safari/Firefox
+// workarounds; React Native defines navigator WITHOUT userAgent, so the
+// probe throws ("Cannot read property 'match' of undefined"). Any
+// non-browser string routes it to the polyfilled TextureLoader path.
+if (typeof navigator !== 'undefined' && typeof navigator.userAgent !== 'string') {
+ try {
+ Object.defineProperty(navigator, 'userAgent', {
+ value: 'ReactNative',
+ configurable: true,
+ });
+ } catch {
+ // navigator is frozen β leave it; GLTFLoader will fail loudly instead
+ }
+}
+
+const KAORI_MODEL = require('../../../assets/models/kaori.vrm') as number;
+
+const CAMERA_TARGET = new THREE.Vector3(0, 0.95, 0);
+const MIN_DISTANCE = 1.1;
+const MAX_DISTANCE = 4.5;
+const MIN_POLAR = 0.35;
+const MAX_POLAR = 1.5;
+
+interface OrbitState {
+ azimuth: number;
+ polar: number;
+ distance: number;
+}
+
+const INITIAL_ORBIT: OrbitState = { azimuth: 0, polar: 1.32, distance: 2.4 };
+
+/** Character interaction state, driven by the voice/chat layer. */
+export type CompanionMode = 'idle' | 'listening' | 'thinking' | 'speaking';
+
+export type CompanionEmotion = 'neutral' | 'excited' | 'calm' | 'happy' | 'sad';
+
+/**
+ * Mutable state shared between the voice/chat layer (outside the Canvas)
+ * and the per-frame animation loop β same ref pattern as the orbit state.
+ */
+export interface CompanionVoiceState {
+ mode: CompanionMode;
+ emotion: CompanionEmotion;
+ emotionIntensity: number;
+}
+
+export const createVoiceState = (): CompanionVoiceState => ({
+ mode: 'idle',
+ emotion: 'neutral',
+ emotionIntensity: 0,
+});
+
+/** VRoid exports vary in expression naming β try candidates in order. */
+const EXPRESSION_CANDIDATES: Record = {
+ blink: ['blink', 'Blink', 'blinkLeft'],
+ happy: ['happy', 'Happy', 'relaxed', 'Relaxed'],
+ aa: ['aa', 'Aa', 'a', 'A', 'vowelA'],
+ oh: ['oh', 'Oh', 'o', 'O', 'vowelO'],
+ ee: ['ee', 'Ee', 'e', 'E', 'vowelE'],
+ ih: ['ih', 'Ih', 'i', 'I', 'vowelI'],
+ sad: ['sad', 'Sad', 'sorrow', 'Sorrow'],
+ surprised: ['surprised', 'Surprised'],
+};
+
+function setExpression(vrm: VRM, name: string, value: number) {
+ const manager = vrm.expressionManager;
+ if (!manager) return;
+ for (const candidate of EXPRESSION_CANDIDATES[name] ?? [name]) {
+ if (manager.getExpression(candidate)) {
+ manager.setValue(candidate, value);
+ return;
+ }
+ }
+}
+
+type Humanoid = NonNullable;
+type HumanBoneName = Parameters[0];
+
+function driveTorso(humanoid: Humanoid, t: number, mode: CompanionMode) {
+ const neck = humanoid.getNormalizedBoneNode('neck');
+ const spine = humanoid.getNormalizedBoneNode('spine');
+ const chest = humanoid.getNormalizedBoneNode('chest');
+ const hips = humanoid.getNormalizedBoneNode('hips');
+
+ const breathe = Math.sin(t * 1.2) * 0.015;
+ const idleSway = Math.sin(t * 0.4) * 0.02;
+ const headDrift = Math.sin(t * 0.55) * 0.04;
+
+ if (hips) {
+ hips.rotation.z = Math.sin(t * 0.3) * 0.015;
+ hips.rotation.y = Math.sin(t * 0.2) * 0.01;
+ }
+ if (neck) {
+ neck.rotation.y = headDrift;
+ neck.rotation.x = breathe * 0.8;
+ neck.rotation.z = Math.sin(t * 0.35) * 0.02;
+ }
+ if (spine) {
+ spine.rotation.z = idleSway;
+ spine.rotation.x = breathe * 0.4;
+ }
+ if (chest) chest.rotation.x = breathe * 0.6;
+
+ // Attentive lean while listening; pondering head-tilt while thinking
+ if (mode === 'listening') {
+ if (neck) neck.rotation.x += 0.08;
+ if (spine) spine.rotation.x = 0.04;
+ }
+ if (mode === 'thinking') {
+ if (neck) neck.rotation.y += Math.sin(t * 2.2) * 0.05;
+ if (spine) spine.rotation.x = 0.06;
+ }
+}
+
+/** Damp one axis of a named bone toward a target angle. */
+function dampBone(
+ humanoid: Humanoid,
+ bone: HumanBoneName,
+ axis: 'x' | 'z',
+ target: number,
+ damping: number,
+ dt: number,
+) {
+ const node = humanoid.getNormalizedBoneNode(bone);
+ if (!node) return;
+ node.rotation[axis] = THREE.MathUtils.damp(node.rotation[axis], target, damping, dt);
+}
+
+/**
+ * Conversational gesture poses (ported from kaori-live.js) β held ~2s each
+ * with smooth damped blending while speaking. Pose 0 is the resting pose.
+ * lUZ/rUZ = upper arm Z, lUX/rUX = upper arm X (forward), lFZ/rFZ = forearm
+ * Z, lHX/rHX = wrist tilt, nX/nY = neck nod/turn, sX = spine lean.
+ */
+const GESTURE_POSES = [
+ // Resting β both arms at sides, neutral
+ {
+ lUZ: -1.15,
+ rUZ: 1.2,
+ lUX: 0.08,
+ rUX: 0.05,
+ lFZ: -0.15,
+ rFZ: 0.15,
+ lHX: 0.1,
+ rHX: 0.1,
+ nX: 0,
+ nY: 0,
+ sX: 0,
+ },
+ // Right hand out β explaining, palm up
+ {
+ lUZ: -1.1,
+ rUZ: 0.7,
+ lUX: 0.1,
+ rUX: 0.4,
+ lFZ: -0.15,
+ rFZ: -0.3,
+ lHX: 0.1,
+ rHX: -0.3,
+ nX: 0.03,
+ nY: -0.04,
+ sX: 0.02,
+ },
+ // Left hand out β presenting, palm open
+ {
+ lUZ: -0.7,
+ rUZ: 1.15,
+ lUX: 0.4,
+ rUX: 0.08,
+ lFZ: 0.3,
+ rFZ: 0.15,
+ lHX: -0.3,
+ rHX: 0.1,
+ nX: 0.02,
+ nY: 0.05,
+ sX: 0.01,
+ },
+ // Both hands forward β emphasis, open palms
+ {
+ lUZ: -0.85,
+ rUZ: 0.85,
+ lUX: 0.35,
+ rUX: 0.35,
+ lFZ: 0.1,
+ rFZ: -0.1,
+ lHX: -0.2,
+ rHX: -0.2,
+ nX: 0.04,
+ nY: 0,
+ sX: 0.02,
+ },
+ // Right hand gesture β counting/listing
+ {
+ lUZ: -1.1,
+ rUZ: 0.6,
+ lUX: 0.1,
+ rUX: 0.5,
+ lFZ: -0.15,
+ rFZ: -0.45,
+ lHX: 0.1,
+ rHX: -0.15,
+ nX: 0.02,
+ nY: -0.06,
+ sX: 0.015,
+ },
+ // Nodding emphasis β both arms subtly forward
+ {
+ lUZ: -1.0,
+ rUZ: 1.0,
+ lUX: 0.2,
+ rUX: 0.2,
+ lFZ: -0.1,
+ rFZ: 0.1,
+ lHX: 0,
+ rHX: 0,
+ nX: 0.05,
+ nY: 0.02,
+ sX: 0.015,
+ },
+] as const;
+
+const GESTURE_POSE_INTERVAL = 2.0;
+
+function driveArms(humanoid: Humanoid, t: number, dt: number, mode: CompanionMode) {
+ const breathSway = Math.sin(t * 0.5) * 0.015;
+ const damping = 2.5;
+
+ // While speaking, cycle through conversational gestures; otherwise rest
+ const speaking = mode === 'speaking';
+ const poseIndex = speaking
+ ? 1 + (Math.floor(t / GESTURE_POSE_INTERVAL) % (GESTURE_POSES.length - 1))
+ : 0;
+ const pose = GESTURE_POSES[poseIndex];
+
+ dampBone(humanoid, 'leftUpperArm', 'z', pose.lUZ + breathSway, damping, dt);
+ dampBone(humanoid, 'leftUpperArm', 'x', pose.lUX, damping, dt);
+ dampBone(humanoid, 'rightUpperArm', 'z', pose.rUZ - breathSway, damping, dt);
+ dampBone(humanoid, 'rightUpperArm', 'x', pose.rUX, damping, dt);
+ dampBone(humanoid, 'leftLowerArm', 'z', pose.lFZ, damping, dt);
+ dampBone(humanoid, 'rightLowerArm', 'z', pose.rFZ, damping, dt);
+ dampBone(humanoid, 'leftHand', 'x', pose.lHX, damping, dt);
+ dampBone(humanoid, 'rightHand', 'x', pose.rHX, damping, dt);
+
+ // Gesture head/spine accents on top of the torso motion
+ const neck = humanoid.getNormalizedBoneNode('neck');
+ const spine = humanoid.getNormalizedBoneNode('spine');
+ const chest = humanoid.getNormalizedBoneNode('chest');
+ if (neck) {
+ neck.rotation.x += pose.nX;
+ neck.rotation.y += pose.nY;
+ }
+ if (speaking) {
+ if (spine) spine.rotation.x += pose.sX;
+ if (chest) chest.rotation.x += pose.sX * 0.5;
+ }
+}
+
+/** Relaxed-hand curl targets per finger joint, computed once at module load. */
+const FINGER_CURL_TARGETS: ReadonlyArray<{ bone: HumanBoneName; curl: number }> = (
+ ['left', 'right'] as const
+).flatMap((side) =>
+ (['Thumb', 'Index', 'Middle', 'Ring', 'Little'] as const).flatMap((finger) =>
+ (
+ [
+ ['Proximal', 0.3],
+ ['Intermediate', 0.35],
+ ['Distal', 0.25],
+ ] as const
+ ).map(([joint, amount]) => ({
+ bone: `${side}${finger}${joint}` as HumanBoneName,
+ curl: side === 'left' ? -amount : amount,
+ })),
+ ),
+);
+
+function driveFingers(humanoid: Humanoid, dt: number) {
+ for (const { bone, curl } of FINGER_CURL_TARGETS) {
+ dampBone(humanoid, bone, 'z', curl, 2, dt);
+ }
+}
+
+function driveFace(vrm: VRM, t: number, voice: CompanionVoiceState) {
+ // Natural blinking β every ~3.5s, quick 150ms close/open
+ const blinkInterval = 3.5;
+ const blinkDuration = 0.15;
+ const blinkT = t % blinkInterval;
+ const blinkValue = blinkT < blinkDuration ? Math.sin((blinkT / blinkDuration) * Math.PI) : 0;
+ setExpression(vrm, 'blink', blinkValue);
+
+ // Mouth shapes β simulated speech cycling (same frequencies as web stage)
+ if (voice.mode === 'speaking') {
+ setExpression(vrm, 'aa', Math.max(0, Math.sin(t * 5.5)) * 0.5);
+ setExpression(vrm, 'oh', Math.max(0, Math.sin(t * 4.2 + 1.2)) * 0.35);
+ setExpression(vrm, 'ee', Math.max(0, Math.sin(t * 6.8 + 2.5)) * 0.3);
+ } else {
+ setExpression(vrm, 'aa', 0);
+ setExpression(vrm, 'oh', 0);
+ setExpression(vrm, 'ee', 0);
+ }
+ setExpression(vrm, 'ih', voice.mode === 'thinking' ? 0.1 : 0);
+
+ // Emotion layer from Kith emotion_state events, on top of a resting smile
+ const restSmile = voice.mode === 'listening' ? 0.18 : voice.mode === 'speaking' ? 0.25 : 0.12;
+ const intensity = THREE.MathUtils.clamp(voice.emotionIntensity, 0, 1);
+ let happy = restSmile;
+ let sad = 0;
+ let surprised = 0;
+ if (voice.emotion === 'happy' || voice.emotion === 'excited') {
+ happy = Math.max(restSmile, intensity * 0.8);
+ if (voice.emotion === 'excited') surprised = intensity * 0.3;
+ } else if (voice.emotion === 'sad') {
+ happy = 0;
+ sad = intensity * 0.7;
+ }
+ setExpression(vrm, 'happy', happy);
+ setExpression(vrm, 'sad', sad);
+ setExpression(vrm, 'surprised', surprised);
+}
+
+/**
+ * Procedural character animation β port of the web stage's motion system
+ * (kaori-live.js): breathing/sway idle, attentive listening, pondering
+ * thinking, gesture-cycling speech with simulated mouth shapes, and an
+ * emotion layer driven by Kith emotion_state events.
+ */
+function driveCharacter(vrm: VRM, t: number, dt: number, voice: CompanionVoiceState) {
+ const humanoid = vrm.humanoid;
+ if (!humanoid) return;
+ driveTorso(humanoid, t, voice.mode);
+ driveArms(humanoid, t, dt, voice.mode);
+ driveFingers(humanoid, dt);
+ driveFace(vrm, t, voice);
+}
+
+/**
+ * fiber's texture polyfill hands three `image = { data: { localUri } }`
+ * objects, but three's texStorage2D+texSubImage2D upload of those silently
+ * produces empty textures on expo-gl (no GL error β verified on device:
+ * unlit red renders, unlit textured doesn't). Bypass: decode the image
+ * through expo-gl's one battle-tested path (plain texImage2D + localUri),
+ * read the raw RGBA back from a framebuffer, and hand three a real
+ * DataTexture with a typed array β an upload path verified working.
+ */
+function rebakeTexture(
+ glCtx: WebGL2RenderingContext,
+ source: THREE.Texture,
+ cache: Map,
+): THREE.Texture | null {
+ const cached = cache.get(source.uuid);
+ if (cached !== undefined) return cached;
+
+ const image = source.image as
+ | { data?: { localUri?: string }; width?: number; height?: number }
+ | undefined;
+ const localUri = image?.data?.localUri;
+ const width = image?.width ?? 0;
+ const height = image?.height ?? 0;
+ if (!localUri || !width || !height) {
+ cache.set(source.uuid, null);
+ return null;
+ }
+ if (width * height > 4 * 1024 * 1024) {
+ // Refuse pathological sizes β 4Mpx RGBA is a 16MB readback
+ cache.set(source.uuid, null);
+ return null;
+ }
+
+ // Decode natively into a scratch texture (documented EXGL localUri path)
+ glCtx.pixelStorei(glCtx.UNPACK_FLIP_Y_WEBGL, 0);
+ const scratch = glCtx.createTexture();
+ glCtx.bindTexture(glCtx.TEXTURE_2D, scratch);
+ (
+ glCtx.texImage2D as unknown as (
+ target: number,
+ level: number,
+ internalformat: number,
+ format: number,
+ type: number,
+ source: { localUri: string },
+ ) => void
+ )(glCtx.TEXTURE_2D, 0, glCtx.RGBA, glCtx.RGBA, glCtx.UNSIGNED_BYTE, { localUri });
+
+ // Read the decoded pixels back
+ const framebuffer = glCtx.createFramebuffer();
+ glCtx.bindFramebuffer(glCtx.FRAMEBUFFER, framebuffer);
+ glCtx.framebufferTexture2D(
+ glCtx.FRAMEBUFFER,
+ glCtx.COLOR_ATTACHMENT0,
+ glCtx.TEXTURE_2D,
+ scratch,
+ 0,
+ );
+ const complete = glCtx.checkFramebufferStatus(glCtx.FRAMEBUFFER) === glCtx.FRAMEBUFFER_COMPLETE;
+ const pixels = new Uint8Array(width * height * 4);
+ if (complete) {
+ glCtx.readPixels(0, 0, width, height, glCtx.RGBA, glCtx.UNSIGNED_BYTE, pixels);
+ }
+ glCtx.bindFramebuffer(glCtx.FRAMEBUFFER, null);
+ glCtx.deleteFramebuffer(framebuffer);
+ glCtx.deleteTexture(scratch);
+ if (!complete) {
+ cache.set(source.uuid, null);
+ return null;
+ }
+
+ const baked = new THREE.DataTexture(pixels, width, height, THREE.RGBAFormat);
+ baked.colorSpace = source.colorSpace;
+ baked.wrapS = source.wrapS;
+ baked.wrapT = source.wrapT;
+ baked.offset.copy(source.offset);
+ baked.repeat.copy(source.repeat);
+ baked.flipY = false;
+ baked.generateMipmaps = false;
+ baked.minFilter = THREE.LinearFilter;
+ baked.magFilter = THREE.LinearFilter;
+ baked.needsUpdate = true;
+ // Release the CPU copy once three has uploaded it to the GPU
+ baked.onUpdate = () => {
+ (baked.image as { data: Uint8Array | null }).data = null;
+ };
+ cache.set(source.uuid, baked);
+ return baked;
+}
+
+/**
+ * three-vrm's MToon ShaderMaterial does not survive expo-gl's shader
+ * compiler: on-device the program silently fails (character never draws)
+ * and the iOS Simulator's GL shader JIT hard-crashes (SIGBUS in
+ * cvmsServerElementBuild). Swap every MToon for an unlit textured material
+ * β the closest match to MToon's anime look β with textures rebaked
+ * through the working upload path.
+ */
+function downgradeMToonMaterials(root: THREE.Object3D, glCtx: WebGL2RenderingContext) {
+ const rebakeCache = new Map();
+ root.traverse((node) => {
+ const mesh = node as THREE.Mesh;
+ if (!mesh.isMesh) return;
+ const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
+ const swapped = materials.map((material) => {
+ const mtoon = material as THREE.Material & {
+ isMToonMaterial?: boolean;
+ map?: THREE.Texture | null;
+ color?: THREE.Color;
+ emissive?: THREE.Color;
+ emissiveMap?: THREE.Texture | null;
+ };
+ if (!mtoon?.isMToonMaterial && mtoon?.type !== 'MToonMaterial') return material;
+ const baked = mtoon.map ? rebakeTexture(glCtx, mtoon.map, rebakeCache) : null;
+ const replacement = new THREE.MeshBasicMaterial({
+ map: baked,
+ color: mtoon.color?.clone() ?? new THREE.Color(0xffffff),
+ transparent: mtoon.transparent,
+ opacity: mtoon.opacity,
+ alphaTest: mtoon.alphaTest,
+ side: mtoon.side,
+ depthWrite: mtoon.depthWrite,
+ depthTest: mtoon.depthTest,
+ });
+ replacement.name = mtoon.name;
+ mtoon.dispose();
+ return replacement;
+ });
+ mesh.material = Array.isArray(mesh.material) ? swapped : swapped[0];
+ });
+}
+
+function KaoriModel({
+ onReady,
+ voice,
+ demo,
+}: {
+ onReady: () => void;
+ voice: React.MutableRefObject;
+ demo: React.MutableRefObject;
+}) {
+ const gltf = useLoader(GLTFLoader, KAORI_MODEL as unknown as string, (loader) => {
+ (loader as GLTFLoader).register((parser) => new VRMLoaderPlugin(parser));
+ });
+ const vrm = (gltf.userData as { vrm: VRM }).vrm;
+ const camera = useThree((state) => state.camera);
+ const renderer = useThree((state) => state.gl);
+
+ // One-time scene preparation; useLoader caches the parsed model,
+ // so guard against re-running on remount.
+ const prepared = vrm.scene.userData as { kaoriStagePrepared?: boolean };
+ if (!prepared.kaoriStagePrepared) {
+ prepared.kaoriStagePrepared = true;
+ VRMUtils.removeUnnecessaryVertices(vrm.scene);
+ downgradeMToonMaterials(vrm.scene, renderer.getContext() as WebGL2RenderingContext);
+ // Skinned meshes keep their rest-pose bounding sphere; once bones move,
+ // stale bounds get frustum-culled and the whole character disappears.
+ vrm.scene.traverse((node) => {
+ node.frustumCulled = false;
+ });
+ }
+
+ useEffect(() => {
+ onReady();
+ }, [onReady]);
+
+ // Eyes follow the orbiting camera
+ useEffect(() => {
+ if (vrm.lookAt) vrm.lookAt.target = camera;
+ return () => {
+ if (vrm.lookAt) vrm.lookAt.target = null;
+ };
+ }, [vrm, camera]);
+
+ // Own time accumulator β r3f resets state.clock whenever frameloop
+ // toggles ('always'/'never'), which would snap the idle pose and force
+ // a blink on every screen refocus.
+ const elapsed = useRef(0);
+ useFrame((_, delta) => {
+ elapsed.current += delta;
+ if (isDemoActive(demo.current)) {
+ // The demo session owns the body; face keeps talking (mouth/blink/
+ // emotes) so she narrates while riding and performing.
+ const active = driveDemo(vrm, demo.current, delta);
+ driveFace(vrm, elapsed.current, voice.current);
+ vrm.scene.rotation.y = demo.current.rootYaw;
+ vrm.scene.position.y = demo.current.rootY;
+ if (!active) {
+ vrm.scene.rotation.y = 0;
+ vrm.scene.position.y = 0;
+ }
+ } else {
+ driveCharacter(vrm, elapsed.current, delta, voice.current);
+ }
+ vrm.update(delta);
+ });
+
+ return ;
+}
+
+/**
+ * Snowboard deck shape: a segmented box vertex-warped so the nose and
+ * tail round off (circular outline taper) and kick upward (rocker).
+ */
+function makeBoardGeometry(
+ length: number,
+ thickness: number,
+ width: number,
+ kick: number,
+): THREE.BufferGeometry {
+ const geometry = new THREE.BoxGeometry(length, thickness, width, 48, 1, 8);
+ const positions = geometry.attributes.position;
+ const half = length / 2;
+ const tipStart = 0.74; // outline starts rounding here (fraction of half-length)
+ const kickStart = 0.62; // tips start curving up here
+ for (let i = 0; i < positions.count; i++) {
+ const u = Math.abs(positions.getX(i)) / half;
+ if (u > tipStart) {
+ const v = (u - tipStart) / (1 - tipStart);
+ positions.setZ(i, positions.getZ(i) * Math.sqrt(Math.max(0, 1 - v * v)));
+ }
+ if (u > kickStart) {
+ const k = (u - kickStart) / (1 - kickStart);
+ positions.setY(i, positions.getY(i) + kick * k * k);
+ }
+ }
+ geometry.computeVertexNormals();
+ return geometry;
+}
+
+/** Stylized snowboard that appears under Kaori's feet during trick demos. */
+function TrickBoard({ demo }: { demo: React.MutableRefObject }) {
+ const groupRef = useRef(null);
+ const deckRef = useRef(null);
+ const baseRef = useRef(null);
+
+ const deckGeometry = useMemo(() => makeBoardGeometry(1.15, 0.03, 0.27, 0.09), []);
+ const baseGeometry = useMemo(() => makeBoardGeometry(1.19, 0.014, 0.3, 0.09), []);
+ useEffect(
+ () => () => {
+ deckGeometry.dispose();
+ baseGeometry.dispose();
+ },
+ [deckGeometry, baseGeometry],
+ );
+
+ useFrame(() => {
+ const group = groupRef.current;
+ if (!group) return;
+ const { boardOpacity, rootYaw, boardY } = demo.current;
+ group.visible = boardOpacity > 0.01;
+ if (!group.visible) return;
+ group.position.y = boardY + 0.045;
+ group.rotation.y = rootYaw;
+ if (deckRef.current) deckRef.current.opacity = boardOpacity;
+ if (baseRef.current) baseRef.current.opacity = boardOpacity;
+ });
+
+ return (
+
+ {/* Deck β sakura-pink topsheet (matches Kaori's jacket, pops against
+ the dark floor), long axis through the rider's feet */}
+
+
+
+ {/* White rails/base peeking out around the deck */}
+
+
+
+ {/* Binding hints */}
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function CameraRig({ orbit }: { orbit: React.MutableRefObject }) {
+ useFrame(({ camera }) => {
+ const { azimuth, polar, distance } = orbit.current;
+ camera.position.set(
+ CAMERA_TARGET.x + distance * Math.sin(polar) * Math.sin(azimuth),
+ CAMERA_TARGET.y + distance * Math.cos(polar),
+ CAMERA_TARGET.z + distance * Math.sin(polar) * Math.cos(azimuth),
+ );
+ camera.lookAt(CAMERA_TARGET);
+ });
+ return null;
+}
+
+function StageSet() {
+ return (
+ <>
+
+
+
+ {/* Ground disc */}
+
+
+
+
+ {/* Brand-yellow stage ring */}
+
+
+
+
+ >
+ );
+}
+
+interface StageErrorBoundaryProps {
+ children: ReactNode;
+ fallback: ReactNode;
+}
+
+class StageErrorBoundary extends Component {
+ state = { hasError: false };
+
+ static getDerivedStateFromError() {
+ return { hasError: true };
+ }
+
+ render() {
+ return this.state.hasError ? this.props.fallback : this.props.children;
+ }
+}
+
+export interface KaoriStageProps {
+ /** Keep rendering only while the screen is focused (battery / GL safety). */
+ active?: boolean;
+ /**
+ * Shared mutable voice/emotion state (see createVoiceState). The voice
+ * layer mutates it; the animation loop reads it every frame.
+ */
+ voiceState?: React.MutableRefObject;
+ /** Shared trick-demo state (see createTrickDemoState) β set trick+t to start. */
+ demoState?: React.MutableRefObject;
+}
+
+export function KaoriStage({ active = true, voiceState, demoState }: KaoriStageProps) {
+ const orbit = useRef({ ...INITIAL_ORBIT });
+ const pinchStartDistance = useRef(INITIAL_ORBIT.distance);
+ const internalVoice = useRef(createVoiceState());
+ const voice = voiceState ?? internalVoice;
+ const internalDemo = useRef(createTrickDemoState());
+ const demo = demoState ?? internalDemo;
+ const [ready, setReady] = useState(false);
+
+ // Bumping this key remounts the error boundary + Canvas for a clean retry
+ const [attempt, setAttempt] = useState(0);
+
+ // Simulator/emulator GL cannot compile these shaders β the iOS Simulator's
+ // shader JIT hard-crashes (SIGBUS in cvmsServerElementBuild). Real 3D is
+ // device-only; show an honest fallback everywhere else.
+ if (!Device.isDevice) {
+ return (
+
+ Kaori's 3D stage needs a real device
+
+ The simulator's OpenGL stack can't compile the stage shaders. Run this on a physical phone
+ to meet Kaori in 3D.
+
+
+ );
+ }
+
+ const handleRetry = () => {
+ // useLoader caches rejections module-wide β clear it or the retry
+ // would instantly re-throw the same cached error.
+ useLoader.clear(GLTFLoader, KAORI_MODEL as unknown as string);
+ setReady(false);
+ setAttempt((n) => n + 1);
+ };
+
+ const pan = Gesture.Pan()
+ .maxPointers(1)
+ .runOnJS(true)
+ .onChange((event) => {
+ const next = orbit.current;
+ next.azimuth -= event.changeX * 0.008;
+ next.polar = THREE.MathUtils.clamp(next.polar - event.changeY * 0.006, MIN_POLAR, MAX_POLAR);
+ });
+
+ const pinch = Gesture.Pinch()
+ .runOnJS(true)
+ .onStart(() => {
+ pinchStartDistance.current = orbit.current.distance;
+ })
+ .onUpdate((event) => {
+ orbit.current.distance = THREE.MathUtils.clamp(
+ pinchStartDistance.current / event.scale,
+ MIN_DISTANCE,
+ MAX_DISTANCE,
+ );
+ });
+
+ const gestures = Gesture.Simultaneous(pan, pinch);
+
+ const errorFallback = (
+
+ Kaori couldn't load
+
+ Something went wrong loading the 3D stage. You can retry, or chat with Kaori instead!
+
+
+ Try again
+
+
+ );
+
+ return (
+
+
+
+
+ {!ready && (
+
+
+ Kaori is getting readyβ¦
+
+ )}
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ backgroundColor: '#0b0e17',
+ },
+ canvas: {
+ flex: 1,
+ },
+ loadingOverlay: {
+ ...StyleSheet.absoluteFillObject,
+ alignItems: 'center',
+ justifyContent: 'center',
+ gap: 12,
+ backgroundColor: '#0b0e17',
+ },
+ loadingText: {
+ color: '#9aa3b5',
+ fontSize: 14,
+ },
+ fallback: {
+ flex: 1,
+ alignItems: 'center',
+ justifyContent: 'center',
+ gap: 8,
+ padding: 32,
+ backgroundColor: '#0b0e17',
+ },
+ fallbackTitle: {
+ color: '#ffffff',
+ fontSize: 17,
+ fontWeight: '600',
+ },
+ fallbackBody: {
+ color: '#9aa3b5',
+ fontSize: 14,
+ textAlign: 'center',
+ },
+ retryButton: {
+ marginTop: 12,
+ paddingHorizontal: 20,
+ paddingVertical: 10,
+ borderRadius: 12,
+ backgroundColor: brandColors.primary,
+ },
+ retryText: {
+ color: brandColors.primaryText,
+ fontSize: 14,
+ fontWeight: '600',
+ },
+});
diff --git a/src/components/companion/index.ts b/src/components/companion/index.ts
new file mode 100644
index 0000000..6da794d
--- /dev/null
+++ b/src/components/companion/index.ts
@@ -0,0 +1,14 @@
+export {
+ type CompanionEmotion,
+ type CompanionMode,
+ type CompanionVoiceState,
+ createVoiceState,
+ KaoriStage,
+} from './KaoriStage';
+export {
+ createTrickDemoState,
+ type DemoAction,
+ startAction,
+ type TrickDemoState,
+ type TrickId,
+} from './trickAnimations';
diff --git a/src/components/companion/riderFundamentals.ts b/src/components/companion/riderFundamentals.ts
new file mode 100644
index 0000000..5b65d16
--- /dev/null
+++ b/src/components/companion/riderFundamentals.ts
@@ -0,0 +1,225 @@
+/**
+ * Rider fundamentals β the reusable building blocks every board trick is
+ * made of. Trick timelines (trickAnimations.ts) compose these:
+ *
+ * - STANCE: on-board yaw, foot splay, relaxed riding crouch, idle bounce
+ * - CROUCH: anatomically-correct knee fold (thigh forward, shin back,
+ * sole flat) with the hip drop that keeps feet planted on the board
+ * - COIL (wind-up): counter-rotation distributed through hips β spine β
+ * chest β neck, arms swinging with it
+ * - ARMS: rest / wound-up / tucked / balance blending
+ * - JUMP: parabolic air arc
+ *
+ * All values were visually tuned on-device (VRM 1.0 VRoid rig, normalized
+ * humanoid bones, character facing +Z, her left side on +X).
+ */
+
+import type { VRM } from '@pixiv/three-vrm';
+
+export type Humanoid = NonNullable;
+
+// --- Easing / timeline helpers ---
+export const clamp01 = (v: number) => Math.min(1, Math.max(0, v));
+export const easeInOut = (u: number) => u * u * (3 - 2 * u);
+export const lerp = (a: number, b: number, u: number) => a + (b - a) * u;
+/** Progress 0β1 of t between phase bounds. */
+export const phase = (t: number, start: number, end: number) =>
+ clamp01((t - start) / (end - start));
+
+// --- Stance ---
+/** Riding stance yaw: board across the camera view, chest quartered to us. */
+export const STANCE_YAW = -1.05;
+/** Relaxed knee bend while standing on the board talking. */
+export const STANCE_CROUCH = 0.18;
+/** Foot splay at full stance weight (rad per leg). */
+export const STANCE_SPREAD = 0.25;
+
+// --- Crouch geometry ---
+/** Leg segment lengths (fraction of the VRM's ~0.85 hip height). */
+const THIGH_LEN = 0.42;
+const SHIN_LEN = 0.42;
+/** Flexion at crouch=1: thigh drives forward, shin folds back under. */
+const THIGH_FLEX = 1.1;
+const SHIN_FLEX = 1.9;
+
+/** How far the hips sink for a given crouch so feet stay on the board. */
+export function hipDropFor(crouch: number): number {
+ const thigh = THIGH_FLEX * crouch;
+ const shinWorld = (SHIN_FLEX - THIGH_FLEX) * crouch;
+ return THIGH_LEN * (1 - Math.cos(thigh)) + SHIN_LEN * (1 - Math.cos(shinWorld));
+}
+
+/** Parabolic jump arc: u 0β1 across the airtime, peaking at `height`. */
+export function jumpArc(u: number, height: number): number {
+ return u > 0 && u < 1 ? height * 4 * u * (1 - u) : 0;
+}
+
+/**
+ * A rider pose β every trick timeline outputs one of these per frame.
+ * All fields are normalized amounts the appliers translate to bones.
+ */
+export interface RiderPose {
+ /** Rotation progress 0β1 (multiplied by the trick's total spin). */
+ spin: number;
+ /** Root height above the board line (jump arc). */
+ height: number;
+ /** 0 straight legs β 1 full squat (hip drop is derived from this). */
+ crouch: number;
+ /** Upper-body coil around Y: negative = wound away from the spin. */
+ coil: number;
+ /** 0 rest β 1 knees-to-chest airborne tuck. */
+ tuck: number;
+ /** 0 β 1 arms out wide for landing balance. */
+ balance: number;
+ /** Head: lead the spin (yaw) and spot the landing (pitch down). */
+ headLead: number;
+ headSpot: number;
+}
+
+export const REST_POSE: RiderPose = {
+ spin: 0,
+ height: 0,
+ crouch: STANCE_CROUCH,
+ coil: 0,
+ tuck: 0,
+ balance: 0,
+ headLead: 0,
+ headSpot: 0,
+};
+
+/** Relaxed on-board bounce while she talks between moves. */
+export function stanceIdlePose(idleT: number): RiderPose {
+ return {
+ ...REST_POSE,
+ crouch: STANCE_CROUCH + Math.sin(idleT * 1.6) * 0.035,
+ coil: Math.sin(idleT * 0.7) * 0.05,
+ };
+}
+
+// --- Bone appliers ---
+
+function applyLegs(humanoid: Humanoid, crouch: number, stanceWeight: number) {
+ const spread = STANCE_SPREAD * stanceWeight;
+ for (const side of ['left', 'right'] as const) {
+ const upper = humanoid.getNormalizedBoneNode(`${side}UpperLeg`);
+ const lower = humanoid.getNormalizedBoneNode(`${side}LowerLeg`);
+ const foot = humanoid.getNormalizedBoneNode(`${side}Foot`);
+ if (upper) {
+ // Thigh pitches forward (knee travels toward the toe side)
+ upper.rotation.x = -crouch * THIGH_FLEX;
+ // Splay OUTWARD: her left leg sits on +X (she faces the camera)
+ upper.rotation.z = side === 'left' ? spread : -spread;
+ }
+ // Shin folds back under the thigh β the human knee hinge
+ if (lower) lower.rotation.x = crouch * SHIN_FLEX;
+ // Keep the sole flat on the board
+ if (foot) foot.rotation.x = -crouch * (SHIN_FLEX - THIGH_FLEX);
+ }
+}
+
+function applyTorsoAndHead(humanoid: Humanoid, pose: RiderPose) {
+ const hips = humanoid.getNormalizedBoneNode('hips');
+ const spine = humanoid.getNormalizedBoneNode('spine');
+ const chest = humanoid.getNormalizedBoneNode('chest');
+ const neck = humanoid.getNormalizedBoneNode('neck');
+
+ if (hips) {
+ hips.rotation.y = pose.coil * 0.35;
+ hips.rotation.z = 0;
+ }
+ if (spine) {
+ spine.rotation.y = pose.coil * 0.5;
+ spine.rotation.x = pose.crouch * 0.3 + pose.tuck * 0.25;
+ spine.rotation.z = 0;
+ }
+ if (chest) {
+ chest.rotation.y = pose.coil * 0.45;
+ chest.rotation.x = pose.crouch * 0.18;
+ }
+ if (neck) {
+ neck.rotation.y = pose.coil * 0.4 + pose.headLead;
+ neck.rotation.x = pose.headSpot - pose.tuck * 0.15;
+ neck.rotation.z = 0;
+ }
+}
+
+function applyArms(humanoid: Humanoid, pose: RiderPose) {
+ const rest = { uz: 1.15, ux: 0.06, fz: 0.15 };
+ const windup = clamp01(-pose.coil / 0.7);
+ const uzTarget = rest.uz - pose.tuck * 0.45 - pose.balance * 0.55 + windup * 0.1;
+ const uxTarget = rest.ux + pose.tuck * 0.35 + windup * 0.25;
+ const fzTarget = rest.fz + pose.tuck * 0.55;
+
+ for (const side of ['left', 'right'] as const) {
+ const sign = side === 'left' ? -1 : 1;
+ const upper = humanoid.getNormalizedBoneNode(`${side}UpperArm`);
+ const lower = humanoid.getNormalizedBoneNode(`${side}LowerArm`);
+ const hand = humanoid.getNormalizedBoneNode(`${side}Hand`);
+ if (upper) {
+ upper.rotation.z = sign * uzTarget;
+ upper.rotation.x = uxTarget;
+ upper.rotation.y = windup * 0.35;
+ }
+ if (lower) lower.rotation.z = sign * fzTarget;
+ if (hand) hand.rotation.x = 0.1;
+ }
+}
+
+/** Apply a full rider pose to the skeleton (legs scaled by stance weight). */
+export function applyRiderPose(humanoid: Humanoid, pose: RiderPose, stanceWeight: number) {
+ applyLegs(humanoid, pose.crouch * stanceWeight, stanceWeight);
+ applyTorsoAndHead(humanoid, pose);
+ applyArms(humanoid, pose);
+}
+
+/** Zero out the bones the idle system never touches (legs, arm Y). */
+export function resetRiderBones(humanoid: Humanoid) {
+ applyLegs(humanoid, 0, 0);
+ for (const side of ['left', 'right'] as const) {
+ const upper = humanoid.getNormalizedBoneNode(`${side}UpperArm`);
+ if (upper) upper.rotation.y = 0;
+ }
+}
+
+// --- Fundamental mini-demos (reused as spoken-phase segments) ---
+
+/** Wind-up: sink deep + coil against the spin, hold, unwind to stance. */
+export function windUpDemoPose(t: number): RiderPose {
+ const sink = phase(t, 0, 1.1);
+ const unwind = phase(t, 1.5, 2.6);
+ const amount = easeInOut(sink) * (1 - easeInOut(unwind));
+ return {
+ ...REST_POSE,
+ crouch: lerp(STANCE_CROUCH, 0.66, amount),
+ coil: -0.7 * amount,
+ headLead: -0.15 * amount,
+ };
+}
+export const WIND_UP_DEMO_DURATION = 2.6;
+
+/** Pop: quick sink, small straight hop, absorb back to stance. */
+export function popDemoPose(t: number): RiderPose {
+ const sink = phase(t, 0, 0.55);
+ const hopTime = phase(t, 0.55, 1.05);
+ const absorb = phase(t, 1.05, 1.6);
+ const height = jumpArc(hopTime, 0.28);
+ let crouch = lerp(STANCE_CROUCH, 0.6, easeInOut(sink));
+ if (hopTime > 0) crouch = lerp(0.6, 0.1, easeInOut(hopTime));
+ if (absorb > 0) crouch = lerp(0.1, STANCE_CROUCH, easeInOut(absorb));
+ return { ...REST_POSE, height, crouch };
+}
+export const POP_DEMO_DURATION = 1.6;
+
+/** Landing absorb: drop deep like eating an impact, rise back to stance. */
+export function landDemoPose(t: number): RiderPose {
+ const drop = phase(t, 0, 0.45);
+ const rise = phase(t, 0.9, 1.8);
+ const amount = easeInOut(drop) * (1 - easeInOut(rise));
+ return {
+ ...REST_POSE,
+ crouch: lerp(STANCE_CROUCH, 0.72, amount),
+ balance: amount * 0.8,
+ headSpot: 0.2 * amount,
+ };
+}
+export const LAND_DEMO_DURATION = 1.8;
diff --git a/src/components/companion/trickAnimations.ts b/src/components/companion/trickAnimations.ts
new file mode 100644
index 0000000..32e1c56
--- /dev/null
+++ b/src/components/companion/trickAnimations.ts
@@ -0,0 +1,234 @@
+/**
+ * Trick timelines + the demo state machine for the Kaori 3D stage.
+ *
+ * Every trick is a timeline of RiderPoses composed from the shared
+ * fundamentals (riderFundamentals.ts): stance, crouch + hip drop, coil
+ * wind-up, tuck, balance, jump arc. Adding a trick = one TRICKS entry.
+ *
+ * The demo runs as an on-board SESSION: while Kaori explains, she loops
+ * the full trick with short stance breathers, and her spoken sentences
+ * can cue segment demos (wind-up / pop / landing) in the gaps.
+ *
+ * Technique per Shred School / Snowboard Addiction / Whitelines trick
+ * tips. Frontside for a regular rider spins counter-clockwise from above
+ * (+Y yaw in three.js).
+ */
+
+import type { VRM } from '@pixiv/three-vrm';
+import {
+ applyRiderPose,
+ clamp01,
+ easeInOut,
+ hipDropFor,
+ jumpArc,
+ LAND_DEMO_DURATION,
+ landDemoPose,
+ lerp,
+ POP_DEMO_DURATION,
+ phase,
+ popDemoPose,
+ REST_POSE,
+ type RiderPose,
+ resetRiderBones,
+ STANCE_CROUCH,
+ STANCE_YAW,
+ stanceIdlePose,
+ WIND_UP_DEMO_DURATION,
+ windUpDemoPose,
+} from './riderFundamentals';
+
+export type TrickId = 'frontside-360';
+
+export type DemoAction = 'none' | 'full' | 'setup' | 'pop' | 'land';
+
+export interface TrickTimeline {
+ duration: number;
+ /** Total root rotation over the trick (radians; + is frontside/CCW). */
+ totalSpin: number;
+ poseAt: (t: number) => RiderPose;
+}
+
+// --- Frontside 360 ---
+const FS360_SETUP_END = 1.5;
+const FS360_POP_END = 1.8;
+const FS360_AIR_END = 3.1;
+const FS360_LAND_END = 3.7;
+const FS360_SETTLE_END = 4.2;
+
+function frontside360PoseAt(t: number): RiderPose {
+ const setup = phase(t, 0, FS360_SETUP_END);
+ const pop = phase(t, FS360_SETUP_END, FS360_POP_END);
+ const air = phase(t, FS360_POP_END, FS360_AIR_END);
+ const land = phase(t, FS360_AIR_END, FS360_LAND_END);
+ const settle = phase(t, FS360_LAND_END, FS360_SETTLE_END);
+
+ const spin = pop > 0 ? clamp01(0.08 * pop + 0.84 * easeInOut(air) + 0.08 * land) : 0;
+ const height = jumpArc(phase(t, FS360_POP_END - 0.08, FS360_AIR_END + 0.15), 0.55);
+
+ // Knees sink DEEP going into it, explode at pop, tuck, absorb, settle
+ let crouch = lerp(STANCE_CROUCH, 0.68, easeInOut(setup));
+ if (pop > 0) crouch = lerp(0.68, 0.06, easeInOut(pop));
+ if (air > 0) crouch = lerp(0.06, 0.32, easeInOut(air));
+ if (land > 0) crouch = lerp(0.32, 0.72, easeInOut(clamp01(land * 2)));
+ if (land > 0.5) crouch = lerp(0.72, 0.35, easeInOut((land - 0.5) * 2));
+ if (settle > 0) crouch = lerp(0.35, STANCE_CROUCH, easeInOut(settle));
+
+ // Coil: wind away during setup, whip through at pop, neutral by landing
+ let coil = -0.7 * easeInOut(setup);
+ if (pop > 0) coil = lerp(-0.7, 0.35, easeInOut(pop));
+ if (air > 0) coil = lerp(0.35, 0.1, air);
+ if (land > 0) coil = lerp(0.1, 0, land);
+
+ const tuck = air > 0 && land === 0 ? Math.sin(Math.PI * air) : 0;
+ const balance = land > 0 ? Math.sin(Math.PI * clamp01(land + settle * 0.4)) * (1 - settle) : 0;
+
+ // Head: lead the spin through the air, spot the landing from ~270Β°
+ const spotting = spin > 0.72;
+ const headLead = air > 0 && !spotting ? 0.45 : 0;
+ const headSpot = spotting && land < 1 ? 0.3 : 0;
+
+ return { spin, height, crouch, coil, tuck, balance, headLead, headSpot };
+}
+
+export const TRICKS: Record = {
+ 'frontside-360': {
+ duration: FS360_SETTLE_END,
+ totalSpin: Math.PI * 2,
+ poseAt: frontside360PoseAt,
+ },
+};
+
+// --- Demo state machine ---
+
+export interface TrickDemoState {
+ /** On-board demo session β enter when the demo reply starts. */
+ session: boolean;
+ trick: TrickId;
+ action: DemoAction;
+ actionT: number;
+ /** Time spent in stance since the last action β drives the auto-loop. */
+ gapT: number;
+ /** Smoothed on-board weight (drives board fade + stance width + yaw). */
+ stance: number;
+ idleT: number;
+ /** Outputs for the frame loop + board renderer. */
+ rootYaw: number;
+ rootY: number;
+ /** Board height β follows the jump but NOT the crouch hip-drop. */
+ boardY: number;
+ boardOpacity: number;
+}
+
+export const createTrickDemoState = (): TrickDemoState => ({
+ session: false,
+ trick: 'frontside-360',
+ action: 'none',
+ actionT: 0,
+ gapT: 0,
+ stance: 0,
+ idleT: 0,
+ rootYaw: 0,
+ rootY: 0,
+ boardY: 0,
+ boardOpacity: 0,
+});
+
+/** True while the demo system should own the body. */
+export const isDemoActive = (state: TrickDemoState) =>
+ state.session || state.action !== 'none' || state.stance > 0.005;
+
+/** Breather in stance between auto-looped trick runs. */
+const LOOP_GAP_SECONDS = 0.9;
+
+function actionDuration(state: TrickDemoState): number {
+ switch (state.action) {
+ case 'full':
+ return TRICKS[state.trick].duration;
+ case 'setup':
+ return WIND_UP_DEMO_DURATION;
+ case 'pop':
+ return POP_DEMO_DURATION;
+ case 'land':
+ return LAND_DEMO_DURATION;
+ default:
+ return 0;
+ }
+}
+
+function actionPose(state: TrickDemoState): RiderPose {
+ switch (state.action) {
+ case 'full':
+ return TRICKS[state.trick].poseAt(state.actionT);
+ case 'setup':
+ return windUpDemoPose(state.actionT);
+ case 'pop':
+ return popDemoPose(state.actionT);
+ case 'land':
+ return landDemoPose(state.actionT);
+ default:
+ return stanceIdlePose(state.idleT);
+ }
+}
+
+/** Start an action (cued by what Kaori is saying). Full runs are never cut. */
+export function startAction(state: TrickDemoState, action: Exclude) {
+ if (state.action === 'full' && state.actionT < TRICKS[state.trick].duration) return;
+ state.action = action;
+ state.actionT = 0;
+}
+
+/**
+ * Advance the demo by dt. Returns false once the session has ended and
+ * the stance has fully blended out (caller resumes idle animation).
+ */
+export function driveDemo(vrm: VRM, state: TrickDemoState, dt: number): boolean {
+ // Smooth the on-board weight toward the session target
+ const target = state.session ? 1 : 0;
+ state.stance += (target - state.stance) * Math.min(1, 2.5 * dt);
+ state.idleT += dt;
+
+ if (state.action !== 'none') {
+ state.gapT = 0;
+ state.actionT += dt;
+ if (state.actionT >= actionDuration(state)) {
+ state.action = 'none';
+ state.actionT = 0;
+ }
+ } else if (state.session) {
+ // Keep performing while she explains: after a short breather in
+ // stance, run the full trick again β sentence cues can still fire
+ // early or mix in phase demos during the gap.
+ state.gapT += dt;
+ if (state.gapT > LOOP_GAP_SECONDS) {
+ state.action = 'full';
+ state.actionT = 0;
+ state.gapT = 0;
+ }
+ }
+
+ const pose = state.action === 'none' && !state.session ? REST_POSE : actionPose(state);
+ const stanceEase = easeInOut(clamp01(state.stance));
+ const effectiveCrouch = pose.crouch * stanceEase;
+
+ const humanoid = vrm.humanoid;
+ if (humanoid) {
+ applyRiderPose(humanoid, pose, stanceEase);
+ }
+
+ state.rootYaw = STANCE_YAW * stanceEase + TRICKS[state.trick].totalSpin * pose.spin;
+ // Hips sink with the knee fold so the feet stay planted on the board
+ state.rootY = pose.height - hipDropFor(effectiveCrouch);
+ state.boardY = pose.height;
+ state.boardOpacity = stanceEase;
+
+ if (!state.session && state.stance < 0.005 && state.action === 'none') {
+ state.stance = 0;
+ state.rootYaw = 0;
+ state.rootY = 0;
+ state.boardY = 0;
+ state.boardOpacity = 0;
+ if (humanoid) resetRiderBones(humanoid);
+ return false;
+ }
+ return true;
+}
diff --git a/src/components/feed/CommentsBottomSheet.tsx b/src/components/feed/CommentsBottomSheet.tsx
index a60aea8..83d0b21 100644
--- a/src/components/feed/CommentsBottomSheet.tsx
+++ b/src/components/feed/CommentsBottomSheet.tsx
@@ -11,7 +11,7 @@ import {
Dimensions,
FlatList,
Image,
- KeyboardAvoidingView,
+ Keyboard,
Modal,
Platform,
Pressable,
@@ -52,6 +52,33 @@ export default function CommentsBottomSheet({ visible, post, onClose }: Comments
const [hasMore, setHasMore] = useState(true);
const inputRef = useRef(null);
const slideAnim = useRef(new Animated.Value(SHEET_HEIGHT)).current;
+ const keyboardAnim = useRef(new Animated.Value(0)).current;
+
+ // Track keyboard to shift sheet above it
+ useEffect(() => {
+ const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow';
+ const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide';
+
+ const showSub = Keyboard.addListener(showEvent, (e) => {
+ Animated.timing(keyboardAnim, {
+ toValue: e.endCoordinates.height,
+ duration: e.duration || 250,
+ useNativeDriver: false,
+ }).start();
+ });
+ const hideSub = Keyboard.addListener(hideEvent, (e) => {
+ Animated.timing(keyboardAnim, {
+ toValue: 0,
+ duration: (e && e.duration) || 250,
+ useNativeDriver: false,
+ }).start();
+ });
+
+ return () => {
+ showSub.remove();
+ hideSub.remove();
+ };
+ }, []);
// Fetch comments when modal opens
useEffect(() => {
@@ -202,6 +229,7 @@ export default function CommentsBottomSheet({ visible, post, onClose }: Comments
styles.sheet,
{
transform: [{ translateY: slideAnim }],
+ bottom: keyboardAnim,
paddingBottom: insets.bottom,
},
]}
@@ -250,43 +278,38 @@ export default function CommentsBottomSheet({ visible, post, onClose }: Comments
/>
{/* Input */}
-
-
- {user ? (
- <>
-
-
- {submitting ? (
-
- ) : (
-
- )}
-
- >
- ) : (
- Sign in to comment
- )}
-
-
+
+ {user ? (
+ <>
+
+
+ {submitting ? (
+
+ ) : (
+
+ )}
+
+ >
+ ) : (
+ Sign in to comment
+ )}
+
);
diff --git a/src/components/home/ActionCard.tsx b/src/components/home/ActionCard.tsx
new file mode 100644
index 0000000..e8a560d
--- /dev/null
+++ b/src/components/home/ActionCard.tsx
@@ -0,0 +1,71 @@
+/**
+ * ActionCard - Primary action button for the home screen
+ * Used in a row of 3 to surface the core app features
+ */
+
+import { Ionicons } from '@expo/vector-icons';
+import { Pressable, StyleSheet, Text, View } from 'react-native';
+import { Card } from '@/components/ui';
+import { brandColors } from '@/constants/colors';
+import { useThemeContext } from '@/lib/providers/ThemeProvider';
+
+interface ActionCardProps {
+ icon: keyof typeof Ionicons.glyphMap;
+ label: string;
+ sublabel: string;
+ onPress: () => void;
+}
+
+export function ActionCard({ icon, label, sublabel, onPress }: ActionCardProps) {
+ const { theme, isDark } = useThemeContext();
+ const iconColor = isDark ? brandColors.primary : brandColors.primaryText;
+ const iconBg = isDark ? `${brandColors.primary}20` : `${brandColors.primaryText}12`;
+
+ return (
+
+ {({ pressed }) => (
+
+
+
+
+
+ {label}
+
+
+ {sublabel}
+
+
+ )}
+
+ );
+}
+
+const styles = StyleSheet.create({
+ wrapper: {
+ flex: 1,
+ },
+ card: {
+ alignItems: 'center',
+ justifyContent: 'center',
+ minHeight: 120,
+ },
+ iconContainer: {
+ width: 48,
+ height: 48,
+ borderRadius: 24,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ label: {
+ fontSize: 14,
+ fontWeight: '700',
+ marginTop: 10,
+ textAlign: 'center',
+ },
+ sublabel: {
+ fontSize: 11,
+ fontWeight: '400',
+ marginTop: 3,
+ textAlign: 'center',
+ },
+});
diff --git a/src/components/home/CompanionWidget.tsx b/src/components/home/CompanionWidget.tsx
new file mode 100644
index 0000000..a9b8cb6
--- /dev/null
+++ b/src/components/home/CompanionWidget.tsx
@@ -0,0 +1,158 @@
+/**
+ * CompanionWidget - Kaori AI companion card for the home screen
+ * Shows avatar, name, and quick-launch to chat
+ */
+
+import { Ionicons } from '@expo/vector-icons';
+import { Image, Pressable, StyleSheet, Text, View } from 'react-native';
+import { Card } from '@/components/ui';
+import { brandColors } from '@/constants/colors';
+import { useThemeContext } from '@/lib/providers/ThemeProvider';
+
+interface Bot {
+ _id: string;
+ name: string;
+ bio?: string;
+ imageUri?: string;
+}
+
+interface CompanionWidgetProps {
+ bot: Bot | null;
+ onPress: () => void;
+ /** Optional: opens the companion's interactive 3D stage */
+ onView3D?: () => void;
+}
+
+export function CompanionWidget({ bot, onPress, onView3D }: CompanionWidgetProps) {
+ const { theme } = useThemeContext();
+
+ if (!bot) return null;
+
+ return (
+
+ {({ pressed }) => (
+
+
+
+ {bot.imageUri ? (
+
+ ) : (
+
+ π€
+
+ )}
+
+
+
+
+
+
+ {bot.name}
+
+ Your AI companion
+
+
+
+ {onView3D && (
+
+
+
+ )}
+
+
+
+ Chat
+
+
+
+ )}
+
+ );
+}
+
+const styles = StyleSheet.create({
+ card: {
+ borderLeftWidth: 3,
+ },
+ row: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 14,
+ },
+ avatarWrapper: {
+ position: 'relative',
+ },
+ avatar: {
+ width: 48,
+ height: 48,
+ borderRadius: 24,
+ },
+ avatarPlaceholder: {
+ width: 48,
+ height: 48,
+ borderRadius: 24,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ avatarEmoji: {
+ fontSize: 22,
+ },
+ chipBadge: {
+ position: 'absolute',
+ bottom: -2,
+ right: -2,
+ width: 18,
+ height: 18,
+ borderRadius: 9,
+ backgroundColor: '#FCF150',
+ borderWidth: 2,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ textContainer: {
+ flex: 1,
+ },
+ name: {
+ fontSize: 16,
+ fontWeight: '600',
+ },
+ subtitle: {
+ fontSize: 13,
+ marginTop: 2,
+ },
+ stageButton: {
+ width: 34,
+ height: 34,
+ borderRadius: 12,
+ alignItems: 'center',
+ justifyContent: 'center',
+ marginRight: -6,
+ },
+ chatButton: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 6,
+ paddingHorizontal: 14,
+ paddingVertical: 8,
+ borderRadius: 12,
+ },
+ chatButtonText: {
+ fontSize: 14,
+ fontWeight: '600',
+ },
+});
diff --git a/src/components/home/FeedCTA.tsx b/src/components/home/FeedCTA.tsx
new file mode 100644
index 0000000..bbdf8d0
--- /dev/null
+++ b/src/components/home/FeedCTA.tsx
@@ -0,0 +1,45 @@
+/**
+ * FeedCTA - Secondary call-to-action to browse the feed
+ */
+
+import { Ionicons } from '@expo/vector-icons';
+import { Pressable, StyleSheet, Text, View } from 'react-native';
+import { Card } from '@/components/ui';
+import { brandColors } from '@/constants/colors';
+import { useThemeContext } from '@/lib/providers/ThemeProvider';
+
+interface FeedCTAProps {
+ onPress: () => void;
+}
+
+export function FeedCTA({ onPress }: FeedCTAProps) {
+ const { theme, isDark } = useThemeContext();
+ const iconColor = isDark ? brandColors.primary : brandColors.primaryText;
+
+ return (
+
+ {({ pressed }) => (
+
+
+
+ Check out the Feed
+
+
+
+ )}
+
+ );
+}
+
+const styles = StyleSheet.create({
+ row: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 12,
+ },
+ text: {
+ fontSize: 15,
+ fontWeight: '600',
+ flex: 1,
+ },
+});
diff --git a/src/components/home/index.ts b/src/components/home/index.ts
index fe10752..fa533f2 100644
--- a/src/components/home/index.ts
+++ b/src/components/home/index.ts
@@ -3,7 +3,10 @@
* Components specific to the Home dashboard screen
*/
+export { ActionCard } from './ActionCard';
export { ActivityCard, CompactActivityCard } from './ActivityCard';
+export { CompanionWidget } from './CompanionWidget';
+export { FeedCTA } from './FeedCTA';
export { GoalCard } from './GoalCard';
export { QuickActionButton, QuickActions } from './QuickActions';
export { CompactStatItem, StatsCard } from './StatsCard';
diff --git a/src/components/ui/Button.tsx b/src/components/ui/Button.tsx
index a4e55e1..ace4f45 100644
--- a/src/components/ui/Button.tsx
+++ b/src/components/ui/Button.tsx
@@ -106,18 +106,18 @@ export function Button({
return (
[
+ style={(state) => [
styles.button,
{
height: sizeStyles.height,
paddingHorizontal: size === 'icon' ? 0 : sizeStyles.paddingH,
width: size === 'icon' ? sizeStyles.width : fullWidth ? '100%' : undefined,
- backgroundColor: pressed ? variantStyles.bgPressed : variantStyles.bg,
+ backgroundColor: state.pressed ? variantStyles.bgPressed : variantStyles.bg,
borderColor: variantStyles.border,
borderWidth: variant === 'outline' ? 1 : 0,
opacity: isDisabled ? 0.5 : 1,
},
- style,
+ typeof style === 'function' ? style(state) : style,
]}
{...props}
>
@@ -205,19 +205,19 @@ export function IconButton({
return (
[
+ style={(state) => [
styles.iconButton,
{
width: sizeStyles.dimension,
height: sizeStyles.dimension,
borderRadius: sizeStyles.dimension / 2,
- backgroundColor: pressed
+ backgroundColor: state.pressed
? variant === 'ghost'
? theme.surface
: `${variantStyles.bg}CC`
: variantStyles.bg,
},
- style,
+ typeof style === 'function' ? style(state) : style,
]}
{...props}
>
diff --git a/src/constants/api.ts b/src/constants/api.ts
index ccd8893..5b69c08 100644
--- a/src/constants/api.ts
+++ b/src/constants/api.ts
@@ -7,7 +7,7 @@
const isDevelopment = __DEV__;
// Use your computer's local IP for physical devices (localhost only works on simulators)
-const DEV_API_HOST = '192.168.5.131';
+const DEV_API_HOST = '192.168.1.242';
export const API_CONFIG = {
// Base URLs
@@ -15,6 +15,9 @@ export const API_CONFIG = {
socketUrl: isDevelopment ? `http://${DEV_API_HOST}:9000` : 'https://api.thetrickbook.com',
+ // Kith voice sidecar β streams Kaori's TTS audio + emotion events (3D stage)
+ kithWsUrl: isDevelopment ? `ws://${DEV_API_HOST}:3040/ws` : 'wss://api.thetrickbook.com/kith/ws',
+
// Bunny.net CDN for video streaming
bunnyCdnHostname: 'vz-9b8a66dd-b7b.b-cdn.net',
diff --git a/src/hooks/useKithVoice.ts b/src/hooks/useKithVoice.ts
new file mode 100644
index 0000000..2a73025
--- /dev/null
+++ b/src/hooks/useKithVoice.ts
@@ -0,0 +1,128 @@
+/**
+ * useKithVoice β owns the Kith WebSocket session, the gapless TTS player,
+ * and the shared CompanionVoiceState ref that drives the 3D character.
+ *
+ * Event β animation mapping (mirrors kaori-live.js):
+ * tts_start β speaking Β· queue-drain grace β idle
+ * emotion_state β expression layer Β· barge_in β listening
+ *
+ * Kith speaks a reply SENTENCE BY SENTENCE (one turn cycle per sentence).
+ * The hook counts assistant sentence starts so callers can synchronize
+ * choreography with what Kaori is currently saying.
+ */
+
+import { useEffect, useRef, useState } from 'react';
+import {
+ type CompanionEmotion,
+ type CompanionVoiceState,
+ createVoiceState,
+} from '@/components/companion/KaoriStage';
+import { API_CONFIG } from '@/constants/api';
+import { type KithEvent, KithVoiceSession } from '@/lib/companion/kithVoice';
+import { TtsPlayer } from '@/lib/companion/ttsPlayer';
+
+const KITH_EMOTIONS: Record = {
+ neutral: 'neutral',
+ excited: 'excited',
+ calm: 'calm',
+ happy: 'happy',
+ sad: 'sad',
+};
+
+export interface KithVoiceCallbacks {
+ /** Fired when Kaori starts SPEAKING sentence n (0-based) of the reply. */
+ onAssistantSentence?: (index: number) => void;
+ /** Fired when the reply's audio has fully finished playing. */
+ onReplyDone?: () => void;
+}
+
+export function useKithVoice(callbacks?: KithVoiceCallbacks) {
+ const voiceState = useRef(createVoiceState());
+ const playerRef = useRef(null);
+ const sessionRef = useRef(null);
+ const sentenceIndex = useRef(0);
+ const callbacksRef = useRef(callbacks);
+ callbacksRef.current = callbacks;
+ const [voiceReady, setVoiceReady] = useState(false);
+
+ useEffect(() => {
+ const player = new TtsPlayer();
+ playerRef.current = player;
+ player.onDrained = () => {
+ if (voiceState.current.mode === 'speaking') {
+ voiceState.current.mode = 'idle';
+ }
+ callbacksRef.current?.onReplyDone?.();
+ };
+
+ const applyEvent = (event: KithEvent) => {
+ switch (event.type) {
+ case '_ready':
+ setVoiceReady(true);
+ break;
+ case 'turn_start':
+ if (event.role === 'assistant') {
+ callbacksRef.current?.onAssistantSentence?.(sentenceIndex.current);
+ sentenceIndex.current += 1;
+ }
+ break;
+ case 'tts_start':
+ voiceState.current.mode = 'speaking';
+ break;
+ case 'tts_audio_chunk':
+ if (event.audioB64) {
+ voiceState.current.mode = 'speaking';
+ player.enqueueChunk(event.audioB64);
+ }
+ break;
+ // tts_end/turn_end are per-sentence boundaries β end-of-reply is
+ // detected by the player's queue-drain grace (onDrained).
+ case 'emotion_state':
+ voiceState.current.emotion = KITH_EMOTIONS[event.state ?? ''] ?? 'neutral';
+ voiceState.current.emotionIntensity =
+ typeof event.intensity === 'number' ? event.intensity : 0;
+ break;
+ case 'barge_in_detected':
+ player.interrupt();
+ voiceState.current.mode = 'listening';
+ break;
+ default:
+ break;
+ }
+ };
+
+ const session = new KithVoiceSession(API_CONFIG.kithWsUrl, applyEvent);
+ session.connect();
+ sessionRef.current = session;
+
+ return () => {
+ session.destroy();
+ player.destroy();
+ playerRef.current = null;
+ sessionRef.current = null;
+ };
+ }, []);
+
+ return {
+ /** Shared mutable state β pass to */
+ voiceState,
+ /** True once the Kith session is live (voice available). */
+ voiceReady,
+ /** Current session id for the x-kith-session request header. */
+ getSessionId: () => sessionRef.current?.sessionId ?? '',
+ /** Call when sending a message β resets the sentence counter. */
+ beginReply: () => {
+ sentenceIndex.current = 0;
+ },
+ /** User is about to talk / wants Kaori to stop. */
+ bargeIn: () => {
+ playerRef.current?.interrupt();
+ sessionRef.current?.bargeIn();
+ },
+ /** Restore the playback audio session after the mic releases it. */
+ reassertPlayback: () => playerRef.current?.reassertSession(),
+ setMode: (mode: CompanionVoiceState['mode']) => {
+ voiceState.current.mode = mode;
+ },
+ };
+}
diff --git a/src/hooks/useVoiceInput.ts b/src/hooks/useVoiceInput.ts
new file mode 100644
index 0000000..fcecd6e
--- /dev/null
+++ b/src/hooks/useVoiceInput.ts
@@ -0,0 +1,97 @@
+/**
+ * Voice input hook β mirrors the web stage's SpeechRecognition behavior
+ * (kaori-live.js toggleVoiceInput): live interim transcript, auto-submit
+ * after 2s of silence, manual stop submits what was heard.
+ */
+
+import { ExpoSpeechRecognitionModule, useSpeechRecognitionEvent } from 'expo-speech-recognition';
+import { useCallback, useRef, useState } from 'react';
+
+const SILENCE_TIMEOUT_MS = 2000;
+
+export interface VoiceInputOptions {
+ /** Live transcript (interim + finals) β mirror into the input field. */
+ onTranscript: (text: string) => void;
+ /** Fired once after silence/manual stop with the final utterance. */
+ onSubmit: (text: string) => void;
+ /** Listening started/stopped β hook point for barge-in. */
+ onListeningChange?: (listening: boolean) => void;
+}
+
+export function useVoiceInput(options: VoiceInputOptions) {
+ const [listening, setListening] = useState(false);
+ const finalText = useRef('');
+ const silenceTimer = useRef | null>(null);
+ const optionsRef = useRef(options);
+ optionsRef.current = options;
+
+ const clearSilenceTimer = () => {
+ if (silenceTimer.current) clearTimeout(silenceTimer.current);
+ silenceTimer.current = null;
+ };
+
+ const resetSilenceTimer = () => {
+ clearSilenceTimer();
+ silenceTimer.current = setTimeout(() => {
+ // Silent for 2s β done talking; "end" event finalizes + submits
+ ExpoSpeechRecognitionModule.stop();
+ }, SILENCE_TIMEOUT_MS);
+ };
+
+ useSpeechRecognitionEvent('result', (event) => {
+ const transcript = event.results[0]?.transcript ?? '';
+ if (event.isFinal) {
+ finalText.current = `${finalText.current} ${transcript}`.trim();
+ optionsRef.current.onTranscript(finalText.current);
+ } else {
+ optionsRef.current.onTranscript(`${finalText.current} ${transcript}`.trim());
+ }
+ resetSilenceTimer();
+ });
+
+ useSpeechRecognitionEvent('start', () => {
+ setListening(true);
+ optionsRef.current.onListeningChange?.(true);
+ });
+
+ useSpeechRecognitionEvent('end', () => {
+ clearSilenceTimer();
+ setListening(false);
+ optionsRef.current.onListeningChange?.(false);
+ const text = finalText.current.trim();
+ finalText.current = '';
+ if (text) optionsRef.current.onSubmit(text);
+ });
+
+ useSpeechRecognitionEvent('error', (event) => {
+ // 'no-speech' is normal β user hasn't said anything yet
+ if (event.error !== 'no-speech') {
+ clearSilenceTimer();
+ setListening(false);
+ optionsRef.current.onListeningChange?.(false);
+ }
+ });
+
+ const toggleVoiceInput = useCallback(async () => {
+ if (listening) {
+ // Manual stop β "end" event submits whatever was heard
+ ExpoSpeechRecognitionModule.stop();
+ return;
+ }
+ const permissions = await ExpoSpeechRecognitionModule.requestPermissionsAsync();
+ if (!permissions.granted) return;
+
+ finalText.current = '';
+ ExpoSpeechRecognitionModule.start({
+ lang: 'en-US',
+ interimResults: true,
+ continuous: true,
+ maxAlternatives: 1,
+ requiresOnDeviceRecognition: false,
+ });
+ // If the user never speaks, stop after 5s (matches web behavior)
+ silenceTimer.current = setTimeout(() => ExpoSpeechRecognitionModule.stop(), 5000);
+ }, [listening]);
+
+ return { listening, toggleVoiceInput };
+}
diff --git a/src/lib/api/auth.ts b/src/lib/api/auth.ts
index 6bcdfdc..9d5d7f6 100644
--- a/src/lib/api/auth.ts
+++ b/src/lib/api/auth.ts
@@ -54,7 +54,7 @@ export interface User {
favoriteCourse?: string;
otherHobbies?: string;
avatarType?: 'icon' | 'upload';
- avatarIcon?: { id: string; emoji: string; bg: string };
+ avatarIcon?: { id: string; emoji: string; bg: string } | null;
stance?: string;
homeSpot?: string;
bio?: string;
diff --git a/src/lib/api/homies.ts b/src/lib/api/homies.ts
index 78eddea..27f795a 100644
--- a/src/lib/api/homies.ts
+++ b/src/lib/api/homies.ts
@@ -10,8 +10,9 @@ import { apiClient } from './client';
export interface Homie {
_id: string;
name: string;
- email: string;
+ email?: string;
username?: string;
+ bio?: string;
imageUri?: string | null;
sports?: string[];
network?: boolean;
@@ -74,6 +75,52 @@ export async function getDiscoverableUsers(): Promise {
}
}
+/**
+ * Search discoverable users with pagination
+ */
+export interface PaginatedUsersResponse {
+ users: Homie[];
+ pagination: {
+ page: number;
+ limit: number;
+ total: number;
+ pages: number;
+ hasMore: boolean;
+ };
+}
+
+export async function searchDiscoverableUsers(
+ query: string = '',
+ page: number = 1,
+ limit: number = 20,
+): Promise {
+ try {
+ const params = new URLSearchParams({ page: String(page), limit: String(limit) });
+ if (query) params.append('q', query);
+ const response = await apiClient.get(
+ `${ENDPOINTS.homies.discoverable}?${params}`,
+ );
+
+ // Handle both old flat array and new paginated response formats
+ if (Array.isArray(response)) {
+ return {
+ users: response,
+ pagination: {
+ page: 1,
+ limit: response.length,
+ total: response.length,
+ pages: 1,
+ hasMore: false,
+ },
+ };
+ }
+
+ return response;
+ } catch (_error) {
+ return { users: [], pagination: { page: 1, limit: 20, total: 0, pages: 0, hasMore: false } };
+ }
+}
+
/**
* Get user's homies list
*/
diff --git a/src/lib/api/messages.ts b/src/lib/api/messages.ts
index 8e464be..a5bd2ca 100644
--- a/src/lib/api/messages.ts
+++ b/src/lib/api/messages.ts
@@ -22,6 +22,18 @@ export interface SharedContent {
preview?: SharedContentPreview;
}
+export type RichContentType =
+ | 'spot_card'
+ | 'spots_list'
+ | 'tricklist_card'
+ | 'trick_card'
+ | 'spot_draft_confirmation';
+
+export interface RichContent {
+ type: RichContentType;
+ data: any;
+}
+
export interface Message {
_id: string;
conversationId: string;
@@ -29,6 +41,7 @@ export interface Message {
content: string | null;
type?: 'text' | 'shared';
sharedContent?: SharedContent | null;
+ richContent?: RichContent;
status: 'sent' | 'read';
readAt?: string;
createdAt: string;
diff --git a/src/lib/api/trickbook.ts b/src/lib/api/trickbook.ts
index dde1b82..73b25b1 100644
--- a/src/lib/api/trickbook.ts
+++ b/src/lib/api/trickbook.ts
@@ -317,7 +317,7 @@ export async function getTrickList(
notes: t.notes,
createdAt: t.createdAt,
})),
- isPublic: false,
+ isPublic: tricks[0]?.isPublic || false,
} as TrickList;
} catch (_error) {
return null;
@@ -372,3 +372,26 @@ export async function editTrick(
return false;
}
}
+
+// Toggle tricklist public/private visibility
+export async function toggleTrickListVisibility(
+ listId: string,
+ isPublic: boolean,
+ token: string,
+): Promise {
+ try {
+ const response = await fetch(`${API_BASE}/listings/${listId}/visibility`, {
+ method: 'PUT',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'x-auth-token': token,
+ },
+ body: JSON.stringify({ isPublic }),
+ });
+
+ if (!response.ok) throw new Error('Failed to update visibility');
+ return response.json();
+ } catch (_error) {
+ return null;
+ }
+}
diff --git a/src/lib/companion/kithVoice.ts b/src/lib/companion/kithVoice.ts
new file mode 100644
index 0000000..8120141
--- /dev/null
+++ b/src/lib/companion/kithVoice.ts
@@ -0,0 +1,104 @@
+/**
+ * Kith voice session β WebSocket client for the Kith TTS sidecar.
+ *
+ * Protocol (kith-voice/src/server.ts):
+ * - connect to /ws β server sends { type: '_ready', sessionId }
+ * - client β server: { type: 'speak', text } | { type: 'barge-in' }
+ * - server β client: KithEvent JSON (tts_start / tts_audio_chunk /
+ * tts_end / turn_start / turn_end / emotion_state / barge_in_detected /
+ * stt_partial / stt_final / viseme_frame / reconnect / error)
+ *
+ * The sessionId is passed to the backend as the `x-kith-session` header on
+ * POST /bot-chat/message; the backend then fires Kaori's reply text at
+ * Kith, and the audio streams back over this socket.
+ */
+
+export interface KithEvent {
+ type: string;
+ sessionId?: string;
+ turnId?: string;
+ chunkId?: string;
+ role?: 'user' | 'assistant';
+ audioB64?: string;
+ mimeType?: string;
+ text?: string;
+ state?: string;
+ intensity?: number;
+ message?: string;
+ timestamp?: number;
+}
+
+const RECONNECT_DELAY_MS = 3000;
+
+export class KithVoiceSession {
+ private ws: WebSocket | null = null;
+ private closed = false;
+ private reconnectTimer: ReturnType | null = null;
+
+ sessionId = '';
+
+ constructor(
+ private readonly url: string,
+ private readonly onEvent: (event: KithEvent) => void,
+ ) {}
+
+ connect() {
+ if (this.closed) return;
+ const ws = new WebSocket(this.url);
+ this.ws = ws;
+
+ ws.onmessage = (message) => {
+ let event: KithEvent;
+ try {
+ event = JSON.parse(String(message.data));
+ } catch {
+ return;
+ }
+ if (event.type === '_ready' && event.sessionId) {
+ this.sessionId = event.sessionId;
+ if (__DEV__) console.log('[kith] session ready:', event.sessionId);
+ }
+ this.onEvent(event);
+ };
+
+ ws.onclose = (event) => {
+ if (__DEV__) console.log('[kith] ws closed', event.code, event.reason || '');
+ this.sessionId = '';
+ this.ws = null;
+ if (!this.closed) {
+ this.reconnectTimer = setTimeout(() => this.connect(), RECONNECT_DELAY_MS);
+ }
+ };
+
+ ws.onerror = () => {
+ // onclose follows and handles the reconnect
+ };
+ }
+
+ private send(payload: Record) {
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) {
+ this.ws.send(JSON.stringify(payload));
+ }
+ }
+
+ /** Ask Kith to speak text directly (bypasses the chat brain). */
+ speak(text: string) {
+ this.send({ type: 'speak', text });
+ }
+
+ /** Interrupt Kaori mid-speech (user started talking / tapped stop). */
+ bargeIn() {
+ this.send({ type: 'barge-in' });
+ }
+
+ destroy() {
+ this.closed = true;
+ if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
+ if (this.ws) {
+ this.ws.onclose = null;
+ this.ws.close();
+ this.ws = null;
+ }
+ this.sessionId = '';
+ }
+}
diff --git a/src/lib/companion/ttsPlayer.ts b/src/lib/companion/ttsPlayer.ts
new file mode 100644
index 0000000..e58a030
--- /dev/null
+++ b/src/lib/companion/ttsPlayer.ts
@@ -0,0 +1,154 @@
+/**
+ * Gapless streamed-TTS player.
+ *
+ * Kith streams independently-decodable mp3_44100_128 chunks (base64) over
+ * WebSocket β one full turn cycle PER SENTENCE (turn_start β tts_start β
+ * chunk β tts_end β turn_end). A single native AudioBufferQueueSourceNode
+ * lives for the whole conversation: it plays whatever is queued, renders
+ * silence while empty, and resumes when the next sentence arrives β that
+ * makes sentence boundaries gapless and avoids per-turn node leaks (a
+ * drained queue node never reaches FINISHED, so per-turn nodes would stay
+ * alive forever).
+ *
+ * Library quirks this code works around (react-native-audio-api 0.13.1):
+ * - `start()` with no args THROWS (default offset -1 trips the arg
+ * guard) β must call start(0, 0).
+ * - `onEnded` never fires for queue nodes; drain detection must use
+ * `onBufferEnded` + `isLastBufferInQueue`.
+ * - react-native-worklets is pinned to 0.5.1 (Expo SDK 54) which is
+ * below audio-api's optional >=0.6 peer β its worklet nodes are
+ * disabled at build time. This player only uses decodeAudioData +
+ * AudioBufferQueueSourceNode, which don't need worklets. Do NOT bump
+ * worklets to silence the build warning.
+ *
+ * Decodes are serialized on a promise chain: decodeAudioData resolves out
+ * of order otherwise and would scramble speech.
+ */
+
+import { AudioContext, AudioManager } from 'react-native-audio-api';
+
+type QueueNode = ReturnType;
+
+/** Silence gap after the last queued buffer before we call the turn done. */
+const DRAIN_IDLE_GRACE_MS = 500;
+
+function base64ToArrayBuffer(b64: string): ArrayBuffer {
+ const binary = global.atob(b64);
+ const bytes = new Uint8Array(binary.length);
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
+ return bytes.buffer;
+}
+
+function configurePlaybackSession() {
+ // Play through the ringer switch; spoken-audio mode tunes buffering
+ AudioManager.setAudioSessionOptions({ iosCategory: 'playback', iosMode: 'spokenAudio' });
+ AudioManager.setAudioSessionActivity(true);
+}
+
+export class TtsPlayer {
+ private context: AudioContext | null = null;
+ private queueNode: QueueNode | null = null;
+ private started = false;
+ private chain: Promise = Promise.resolve();
+ private generation = 0;
+ private drainTimer: ReturnType | null = null;
+
+ /** Fires when the reply's audio has fully drained (plus a short grace). */
+ onDrained?: () => void;
+
+ private ensureContext(): AudioContext {
+ if (!this.context) {
+ configurePlaybackSession();
+ // Match mp3_44100_128 to avoid resampling
+ this.context = new AudioContext({ sampleRate: 44100 });
+ }
+ return this.context;
+ }
+
+ /**
+ * Re-assert the playback audio session β speech recognition swaps the
+ * session to playAndRecord/measurement while the mic is open and never
+ * restores it.
+ */
+ reassertSession() {
+ configurePlaybackSession();
+ }
+
+ private cancelDrainTimer() {
+ if (this.drainTimer) {
+ clearTimeout(this.drainTimer);
+ this.drainTimer = null;
+ }
+ }
+
+ private scheduleDrainIdle() {
+ this.cancelDrainTimer();
+ this.drainTimer = setTimeout(() => {
+ this.drainTimer = null;
+ this.onDrained?.();
+ }, DRAIN_IDLE_GRACE_MS);
+ }
+
+ private ensureNode(ctx: AudioContext): QueueNode {
+ if (!this.queueNode) {
+ const node = ctx.createBufferQueueSource();
+ node.connect(ctx.destination);
+ node.onBufferEnded = (event: { isLastBufferInQueue?: boolean }) => {
+ // Queue ran dry β if no new sentence arrives shortly, the reply
+ // is over. A new enqueue cancels the timer.
+ if (event?.isLastBufferInQueue) this.scheduleDrainIdle();
+ };
+ this.queueNode = node;
+ this.started = false;
+ }
+ return this.queueNode;
+ }
+
+ /** Enqueue one base64 mp3 chunk (call per tts_audio_chunk event). */
+ enqueueChunk(audioB64: string) {
+ const generation = this.generation;
+ this.cancelDrainTimer();
+ this.chain = this.chain.then(async () => {
+ if (generation !== this.generation) return; // interrupted mid-decode
+ try {
+ const ctx = this.ensureContext();
+ const node = this.ensureNode(ctx);
+ const buffer = await ctx.decodeAudioData(base64ToArrayBuffer(audioB64));
+ if (generation !== this.generation) return;
+ this.cancelDrainTimer();
+ node.enqueueBuffer(buffer);
+ if (!this.started) {
+ // start() with no args throws in 0.13.1 β offset must be 0
+ node.start(0, 0);
+ this.started = true;
+ }
+ } catch (error) {
+ console.warn('[TtsPlayer] chunk decode/enqueue failed:', error);
+ }
+ });
+ }
+
+ /** Hard stop (barge-in): drop queued audio immediately. */
+ interrupt() {
+ this.generation += 1;
+ this.cancelDrainTimer();
+ const node = this.queueNode;
+ this.queueNode = null;
+ this.started = false;
+ this.chain = Promise.resolve();
+ if (node) {
+ node.onBufferEnded = null;
+ try {
+ node.stop();
+ } catch {
+ // already stopped
+ }
+ }
+ }
+
+ destroy() {
+ this.interrupt();
+ this.context?.close();
+ this.context = null;
+ }
+}
diff --git a/src/lib/notifications/index.ts b/src/lib/notifications/index.ts
index 011a07c..0b9949f 100644
--- a/src/lib/notifications/index.ts
+++ b/src/lib/notifications/index.ts
@@ -19,6 +19,7 @@ export {
clearSoftAskDeferred,
getOsPermission,
markSoftAskDeferred,
+ type OsPermission,
requestOsPermission,
shouldShowSoftAsk,
} from './permissions';
diff --git a/src/lib/notifications/permissions.ts b/src/lib/notifications/permissions.ts
index 9445bac..675c5cc 100644
--- a/src/lib/notifications/permissions.ts
+++ b/src/lib/notifications/permissions.ts
@@ -30,7 +30,6 @@ export async function requestOsPermission(): Promise {
allowAlert: true,
allowBadge: true,
allowSound: true,
- allowAnnouncements: false,
},
});
if (ios?.status === Notifications.IosAuthorizationStatus.PROVISIONAL) return 'provisional';
diff --git a/src/lib/stores/authStore.ts b/src/lib/stores/authStore.ts
index da0c915..e698b0c 100644
--- a/src/lib/stores/authStore.ts
+++ b/src/lib/stores/authStore.ts
@@ -10,6 +10,8 @@ import { apiClient } from '@/lib/api/client';
export interface User {
id: string;
+ /** MongoDB document id β some API responses return `_id` instead of `id` */
+ _id?: string;
name: string;
email: string;
imageUri?: string | null;
@@ -35,11 +37,17 @@ export interface User {
favoriteCourse?: string;
otherHobbies?: string;
avatarType?: 'icon' | 'upload';
- avatarIcon?: { id: string; emoji: string; bg: string };
+ avatarIcon?: { id: string; emoji: string; bg: string } | null;
stance?: string;
homeSpot?: string;
bio?: string;
};
+ stats?: {
+ spots: number;
+ followers: number;
+ following: number;
+ tricks: number;
+ };
role?: string;
network?: boolean;
}
diff --git a/src/types/assets.d.ts b/src/types/assets.d.ts
new file mode 100644
index 0000000..ba1be3d
--- /dev/null
+++ b/src/types/assets.d.ts
@@ -0,0 +1,10 @@
+/**
+ * Asset module declarations
+ * Metro resolves these to asset module ids (numbers) at runtime;
+ * expo-asset / @react-three/fiber's native loaders accept them directly.
+ */
+
+declare module '*.vrm' {
+ const assetModule: number;
+ export default assetModule;
+}