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 ( + + + + { + // expo-gl only implements UNPACK_FLIP_Y_WEBGL β€” silence the + // warning spam from three probing other pixelStorei params. + const context = gl.getContext(); + const original = context.pixelStorei.bind(context); + context.pixelStorei = ((pname: number, param: number | boolean) => { + if (pname === context.UNPACK_FLIP_Y_WEBGL) original(pname, param as number); + }) as typeof context.pixelStorei; + // Surface shader compile/link failures β€” a failed material + // otherwise silently skips drawing (invisible character). + gl.debug.onShaderError = (glCtx, _program, vs, fs) => { + const vsLog = glCtx.getShaderInfoLog(vs); + const fsLog = glCtx.getShaderInfoLog(fs); + console.error('[KaoriStage] Shader error:', vsLog || '(vs ok)', fsLog || '(fs ok)'); + }; + }} + style={styles.canvas} + > + + + + + + setReady(true)} voice={voice} /> + + + {!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; +}