diff --git a/artifacts/mobile/.gitignore b/artifacts/mobile/.gitignore new file mode 100644 index 0000000..621de6b --- /dev/null +++ b/artifacts/mobile/.gitignore @@ -0,0 +1,41 @@ +# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files + +# dependencies +node_modules/ + +# Expo +.expo/ +dist/ +web-build/ +expo-env.d.ts + +# Native +ios/ +android/ +*.orig.* +*.jks +*.p8 +*.p12 +*.key +*.mobileprovision + +# Metro +.metro-health-check* + +# debug +npm-debug.* +yarn-debug.* +yarn-error.* + +# macOS +.DS_Store +*.pem + +# local env files +.env*.local + +# typescript +*.tsbuildinfo +.vercel + +.env diff --git a/artifacts/mobile/.replit-artifact/artifact.toml b/artifacts/mobile/.replit-artifact/artifact.toml new file mode 100644 index 0000000..32277a5 --- /dev/null +++ b/artifacts/mobile/.replit-artifact/artifact.toml @@ -0,0 +1,26 @@ +kind = "mobile" +previewPath = "/mobile/" +title = "Timmy Mobile" +version = "1.0.0" +id = "artifacts/mobile" +router = "expo-domain" + +[[integratedSkills]] +name = "expo" +version = "1.0.0" + +[[services]] +name = "expo" +paths = [ "/mobile/" ] +localPort = 18115 + +[services.development] +run = "pnpm --filter @workspace/mobile run dev" + +[services.production] +build = [ "pnpm", "--filter", "@workspace/mobile", "run", "build" ] +run = [ "pnpm", "--filter", "@workspace/mobile", "run", "serve" ] + +[services.env] +PORT = "18115" +BASE_PATH = "/mobile/" diff --git a/artifacts/mobile/app.json b/artifacts/mobile/app.json new file mode 100644 index 0000000..0653863 --- /dev/null +++ b/artifacts/mobile/app.json @@ -0,0 +1,47 @@ +{ + "expo": { + "name": "Timmy Mobile", + "slug": "mobile", + "version": "1.0.0", + "orientation": "portrait", + "icon": "./assets/images/icon.png", + "scheme": "mobile", + "userInterfaceStyle": "dark", + "newArchEnabled": true, + "splash": { + "image": "./assets/images/splash-icon.png", + "resizeMode": "contain", + "backgroundColor": "#0A0A12" + }, + "ios": { + "supportsTablet": false + }, + "android": { + "adaptiveIcon": { + "foregroundImage": "./assets/images/icon.png", + "backgroundColor": "#0A0A12" + } + }, + "web": { + "favicon": "./assets/images/icon.png", + "backgroundColor": "#0A0A12" + }, + "plugins": [ + [ + "expo-router", + { + "origin": "https://replit.com/" + } + ], + "expo-font", + "expo-web-browser" + ], + "extra": { + "apiDomain": "${EXPO_PUBLIC_DOMAIN}" + }, + "experiments": { + "typedRoutes": true, + "reactCompiler": true + } + } +} diff --git a/artifacts/mobile/app/(tabs)/_layout.tsx b/artifacts/mobile/app/(tabs)/_layout.tsx new file mode 100644 index 0000000..0b6061f --- /dev/null +++ b/artifacts/mobile/app/(tabs)/_layout.tsx @@ -0,0 +1,112 @@ +import { BlurView } from "expo-blur"; +import { isLiquidGlassAvailable } from "expo-glass-effect"; +import { Tabs } from "expo-router"; +import { Icon, Label, NativeTabs } from "expo-router/unstable-native-tabs"; +import { SymbolView } from "expo-symbols"; +import { Feather, MaterialCommunityIcons } from "@expo/vector-icons"; +import React from "react"; +import { Platform, StyleSheet, View, useColorScheme } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +import { Colors } from "@/constants/colors"; + +function NativeTabLayout() { + return ( + + + + + + + + + + + + + + + ); +} + +function ClassicTabLayout() { + const insets = useSafeAreaInsets(); + const isIOS = Platform.OS === "ios"; + const isWeb = Platform.OS === "web"; + const C = Colors.dark; + + return ( + + isIOS ? ( + + ) : isWeb ? ( + + ) : ( + + ), + }} + > + + isIOS ? ( + + ) : ( + + ), + }} + /> + + isIOS ? ( + + ) : ( + + ), + }} + /> + + isIOS ? ( + + ) : ( + + ), + }} + /> + + ); +} + +export default function TabLayout() { + if (isLiquidGlassAvailable()) { + return ; + } + return ; +} diff --git a/artifacts/mobile/app/(tabs)/feed.tsx b/artifacts/mobile/app/(tabs)/feed.tsx new file mode 100644 index 0000000..91cbf38 --- /dev/null +++ b/artifacts/mobile/app/(tabs)/feed.tsx @@ -0,0 +1,282 @@ +import { Feather, MaterialCommunityIcons } from "@expo/vector-icons"; +import React, { useCallback } from "react"; +import { + FlatList, + ListRenderItemInfo, + Platform, + StyleSheet, + Text, + View, +} from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +import { Colors } from "@/constants/colors"; +import { useTimmy, type WsEvent } from "@/context/TimmyContext"; + +const C = Colors.dark; + +type FeatherIconName = React.ComponentProps["name"]; +type MCIconName = React.ComponentProps["name"]; + +type EventConfig = + | { + color: string; + iconFamily: "Feather"; + icon: FeatherIconName; + label: (e: WsEvent) => string; + } + | { + color: string; + iconFamily: "MaterialCommunityIcons"; + icon: MCIconName; + label: (e: WsEvent) => string; + }; + +const EVENT_CONFIG: Record = { + job_started: { + color: C.jobStarted, + icon: "play-circle", + iconFamily: "Feather", + label: (e) => `Job started${e.agentId ? ` · ${e.agentId}` : ""}`, + }, + job_completed: { + color: C.jobCompleted, + icon: "check-circle", + iconFamily: "Feather", + label: (e) => `Job completed${e.agentId ? ` · ${e.agentId}` : ""}`, + }, + chat: { + color: C.chat, + icon: "message-circle", + iconFamily: "Feather", + label: (e) => + e.agentId === "timmy" + ? `Timmy: ${e.text ?? ""}` + : `${e.agentId ?? "visitor"}: ${e.text ?? ""}`, + }, + agent_state: { + color: C.agentState, + icon: "cpu", + iconFamily: "Feather", + label: (e) => `${e.agentId ?? "agent"} → ${e.state ?? "?"}`, + }, + visitor_count: { + color: "#8B5CF6", + icon: "users", + iconFamily: "Feather", + label: (e) => `${e.count ?? 0} visitor${e.count !== 1 ? "s" : ""} online`, + }, +}; + +const FALLBACK_CONFIG: EventConfig = { + color: C.textMuted, + icon: "radio", + iconFamily: "Feather", + label: (e) => e.type, +}; + +function EventIcon({ config }: { config: EventConfig }) { + if (config.iconFamily === "MaterialCommunityIcons") { + return ( + + ); + } + return ; +} + +function EventRow({ item }: { item: WsEvent }) { + const config = EVENT_CONFIG[item.type] ?? FALLBACK_CONFIG; + const label = config.label(item); + const time = new Date(item.timestamp).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); + + return ( + + + + + + + {label} + + + {item.type.replace(/_/g, " ")} + + + {time} + + ); +} + +const keyExtractor = (item: WsEvent) => item.id; +const renderItem = ({ item }: ListRenderItemInfo) => ( + +); + +export default function FeedScreen() { + const { recentEvents, connectionStatus } = useTimmy(); + const insets = useSafeAreaInsets(); + const topPad = Platform.OS === "web" ? 67 : insets.top; + const bottomPad = Platform.OS === "web" ? 84 + 34 : insets.bottom + 84; + + const ListEmpty = useCallback( + () => ( + + + Waiting for events + + {connectionStatus === "connected" + ? "Events will appear here as Timmy works" + : "Connect to the API server to see live events"} + + + ), + [connectionStatus] + ); + + return ( + + + + Live Feed + + {recentEvents.length} event{recentEvents.length !== 1 ? "s" : ""} + + + + {recentEvents.length} + + + + + data={recentEvents} + keyExtractor={keyExtractor} + renderItem={renderItem} + ListEmptyComponent={ListEmpty} + contentContainerStyle={[ + styles.listContent, + { paddingBottom: bottomPad }, + ]} + showsVerticalScrollIndicator={false} + ItemSeparatorComponent={() => } + /> + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: C.background, + }, + header: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "flex-start", + paddingHorizontal: 24, + paddingTop: 12, + paddingBottom: 16, + }, + title: { + fontSize: 28, + fontFamily: "Inter_700Bold", + color: C.text, + letterSpacing: -0.5, + }, + subtitle: { + fontSize: 13, + fontFamily: "Inter_400Regular", + color: C.textSecondary, + marginTop: 2, + }, + countBadge: { + backgroundColor: C.surface, + borderRadius: 20, + paddingHorizontal: 12, + paddingVertical: 4, + borderWidth: 1, + borderColor: C.border, + alignSelf: "flex-start", + marginTop: 8, + }, + countText: { + fontSize: 13, + fontFamily: "Inter_600SemiBold", + color: C.textSecondary, + }, + listContent: { + paddingHorizontal: 16, + flexGrow: 1, + }, + row: { + flexDirection: "row", + alignItems: "flex-start", + gap: 12, + paddingVertical: 12, + paddingHorizontal: 4, + }, + iconBadge: { + width: 36, + height: 36, + borderRadius: 10, + alignItems: "center", + justifyContent: "center", + flexShrink: 0, + }, + rowContent: { + flex: 1, + gap: 3, + }, + rowLabel: { + fontSize: 13, + fontFamily: "Inter_400Regular", + color: C.text, + lineHeight: 18, + }, + rowType: { + fontSize: 11, + fontFamily: "Inter_500Medium", + textTransform: "uppercase", + letterSpacing: 0.5, + }, + rowTime: { + fontSize: 11, + fontFamily: "Inter_400Regular", + color: C.textMuted, + flexShrink: 0, + marginTop: 2, + }, + separator: { + height: 1, + backgroundColor: C.border, + opacity: 0.5, + }, + emptyContainer: { + flex: 1, + alignItems: "center", + justifyContent: "center", + paddingTop: 80, + gap: 12, + paddingHorizontal: 40, + }, + emptyTitle: { + fontSize: 18, + fontFamily: "Inter_600SemiBold", + color: C.textSecondary, + marginTop: 8, + }, + emptySubtitle: { + fontSize: 14, + fontFamily: "Inter_400Regular", + color: C.textMuted, + textAlign: "center", + lineHeight: 20, + }, +}); diff --git a/artifacts/mobile/app/(tabs)/index.tsx b/artifacts/mobile/app/(tabs)/index.tsx new file mode 100644 index 0000000..5545de7 --- /dev/null +++ b/artifacts/mobile/app/(tabs)/index.tsx @@ -0,0 +1,435 @@ +import * as Haptics from "expo-haptics"; +import * as Speech from "expo-speech"; +import Voice, { SpeechResultsEvent, SpeechErrorEvent } from "@react-native-voice/voice"; +import { Ionicons } from "@expo/vector-icons"; +import React, { useCallback, useEffect, useRef, useState } from "react"; +import { + Animated, + Platform, + Pressable, + StyleSheet, + Text, + View, +} from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +import { ConnectionBadge } from "@/components/ConnectionBadge"; +import { TimmyFace } from "@/components/TimmyFace"; +import { Colors } from "@/constants/colors"; +import { useTimmy } from "@/context/TimmyContext"; + +const C = Colors.dark; + +const MOOD_LABELS: Record = { + idle: "Contemplating the cosmos", + thinking: "Deep in thought", + working: "Casting spells", + speaking: "Sharing wisdom", +}; + +type WebSpeechRecognition = { + continuous: boolean; + interimResults: boolean; + lang: string; + onresult: ((e: WebSpeechResultEvent) => void) | null; + onerror: (() => void) | null; + onend: (() => void) | null; + start: () => void; + stop: () => void; +}; + +type WebSpeechResultEvent = { + results: WebSpeechResultList; +}; + +type WebSpeechResultList = { + length: number; + [index: number]: WebSpeechResult; +}; + +type WebSpeechResult = { + isFinal: boolean; + 0: { transcript: string }; +}; + +type SpeechRecognitionWindow = Window & { + SpeechRecognition?: new () => WebSpeechRecognition; + webkitSpeechRecognition?: new () => WebSpeechRecognition; +}; + +export default function FaceScreen() { + const { timmyMood, connectionStatus, sendVisitorMessage, recentEvents } = + useTimmy(); + const insets = useSafeAreaInsets(); + const [isListening, setIsListening] = useState(false); + const [transcript, setTranscript] = useState(""); + const [lastReply, setLastReply] = useState(""); + const micScale = useRef(new Animated.Value(1)).current; + const micPulseRef = useRef(null); + const webRecognitionRef = useRef(null); + const lastReplyIdRef = useRef(""); + + const topPad = Platform.OS === "web" ? 67 : insets.top; + const bottomPad = Platform.OS === "web" ? 84 + 34 : insets.bottom + 84; + + // Detect incoming Timmy replies and speak them + useEffect(() => { + const latestChat = recentEvents.find( + (e) => e.type === "chat" && (e.agentId === "timmy" || !e.agentId) + ); + if (latestChat?.text && latestChat.id !== lastReplyIdRef.current) { + lastReplyIdRef.current = latestChat.id; + setLastReply(latestChat.text); + if (Platform.OS !== "web") { + Speech.speak(latestChat.text, { + onDone: () => {}, + onError: () => {}, + pitch: 0.9, + rate: 0.85, + }); + } + } + }, [recentEvents]); + + // Declare mic pulse helpers first so native voice useEffect can reference them + const startMicPulse = useCallback(() => { + micPulseRef.current?.stop(); + const pulse = Animated.loop( + Animated.sequence([ + Animated.timing(micScale, { + toValue: 1.2, + duration: 400, + useNativeDriver: true, + }), + Animated.timing(micScale, { + toValue: 0.95, + duration: 400, + useNativeDriver: true, + }), + ]) + ); + micPulseRef.current = pulse; + pulse.start(); + }, [micScale]); + + const stopMicPulse = useCallback(() => { + micPulseRef.current?.stop(); + micPulseRef.current = null; + Animated.spring(micScale, { toValue: 1, useNativeDriver: true }).start(); + }, [micScale]); + + // Native voice setup (after helpers are declared) + useEffect(() => { + if (Platform.OS === "web") return; + + const onResults = (e: SpeechResultsEvent) => { + const text = e.value?.[0] ?? ""; + setTranscript(text); + if (text) { + sendVisitorMessage(text); + } + setIsListening(false); + stopMicPulse(); + setTimeout(() => setTranscript(""), 3000); + }; + + const onPartialResults = (e: SpeechResultsEvent) => { + setTranscript(e.value?.[0] ?? ""); + }; + + const onError = (_e: SpeechErrorEvent) => { + setIsListening(false); + stopMicPulse(); + }; + + Voice.onSpeechResults = onResults; + Voice.onSpeechPartialResults = onPartialResults; + Voice.onSpeechError = onError; + + return () => { + Voice.destroy().catch(() => {}); + }; + }, [sendVisitorMessage, stopMicPulse]); + + const startNativeVoice = useCallback(async () => { + try { + await Voice.start("en-US"); + setIsListening(true); + startMicPulse(); + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); + } catch { + setTranscript("Voice recognition unavailable"); + setTimeout(() => setTranscript(""), 2000); + } + }, [startMicPulse]); + + const stopNativeVoice = useCallback(async () => { + try { + await Voice.stop(); + } catch {} + setIsListening(false); + stopMicPulse(); + }, [stopMicPulse]); + + const startWebVoice = useCallback(() => { + const w = typeof window !== "undefined" ? (window as SpeechRecognitionWindow) : null; + const SpeechRecognitionCtor = w?.SpeechRecognition ?? w?.webkitSpeechRecognition; + + if (!SpeechRecognitionCtor) { + setTranscript("Voice not supported in this browser"); + setTimeout(() => setTranscript(""), 2500); + return; + } + + const rec = new SpeechRecognitionCtor(); + rec.continuous = false; + rec.interimResults = true; + rec.lang = "en-US"; + + rec.onresult = (e: WebSpeechResultEvent) => { + const parts: string[] = []; + for (let i = 0; i < e.results.length; i++) { + parts.push(e.results[i][0].transcript); + } + const t = parts.join(""); + setTranscript(t); + if (e.results[e.results.length - 1].isFinal) { + sendVisitorMessage(t); + setIsListening(false); + stopMicPulse(); + setTimeout(() => setTranscript(""), 3000); + } + }; + rec.onerror = () => { + setIsListening(false); + stopMicPulse(); + }; + rec.onend = () => { + setIsListening(false); + stopMicPulse(); + }; + + webRecognitionRef.current = rec; + rec.start(); + setIsListening(true); + startMicPulse(); + }, [sendVisitorMessage, startMicPulse, stopMicPulse]); + + const stopWebVoice = useCallback(() => { + webRecognitionRef.current?.stop(); + webRecognitionRef.current = null; + }, []); + + const handleMicPress = useCallback(() => { + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + if (Platform.OS === "web") { + if (isListening) stopWebVoice(); + else startWebVoice(); + } else { + if (isListening) stopNativeVoice(); + else startNativeVoice(); + } + }, [ + isListening, + startWebVoice, + stopWebVoice, + startNativeVoice, + stopNativeVoice, + ]); + + return ( + + {/* Header */} + + + Timmy + Wizard of the Machine + + + + + {/* Face area */} + + + + + {MOOD_LABELS[timmyMood]} + + + + {/* Reply bubble */} + {lastReply ? ( + + + {lastReply} + + + ) : null} + + {/* Transcript bubble */} + {transcript ? ( + + {transcript} + + ) : null} + + {/* Mic button */} + + + + + + + + {isListening ? "Listening..." : "Tap to speak to Timmy"} + + + + ); +} + +function MoodDot({ mood }: { mood: string }) { + const colors: Record = { + idle: C.idle, + thinking: C.thinking, + working: C.working, + speaking: C.speaking, + }; + return ( + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: C.background, + }, + header: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "flex-start", + paddingHorizontal: 24, + paddingTop: 12, + paddingBottom: 8, + }, + title: { + fontSize: 28, + fontFamily: "Inter_700Bold", + color: C.text, + letterSpacing: -0.5, + }, + subtitle: { + fontSize: 13, + fontFamily: "Inter_400Regular", + color: C.textSecondary, + marginTop: 2, + }, + faceWrapper: { + flex: 1, + alignItems: "center", + justifyContent: "center", + gap: 16, + }, + auraRing: { + alignItems: "center", + justifyContent: "center", + shadowColor: "#7C3AED", + shadowOffset: { width: 0, height: 0 }, + shadowOpacity: 0.3, + shadowRadius: 40, + elevation: 0, + }, + moodLabel: { + fontSize: 15, + fontFamily: "Inter_500Medium", + color: C.textSecondary, + letterSpacing: 0.2, + textAlign: "center", + }, + moodDot: { + width: 8, + height: 8, + borderRadius: 4, + }, + replyBubble: { + marginHorizontal: 20, + marginBottom: 12, + backgroundColor: C.surfaceElevated, + borderRadius: 16, + padding: 14, + borderWidth: 1, + borderColor: C.border, + borderTopLeftRadius: 4, + }, + replyText: { + fontSize: 14, + fontFamily: "Inter_400Regular", + color: C.text, + lineHeight: 20, + }, + transcriptBubble: { + marginHorizontal: 20, + marginBottom: 8, + backgroundColor: C.surface, + borderRadius: 16, + padding: 12, + borderWidth: 1, + borderColor: C.accent + "44", + alignSelf: "flex-end", + maxWidth: "80%", + borderBottomRightRadius: 4, + }, + transcriptText: { + fontSize: 14, + fontFamily: "Inter_400Regular", + color: C.accentGlow, + lineHeight: 20, + }, + micArea: { + alignItems: "center", + paddingTop: 16, + gap: 10, + }, + micButton: { + width: 72, + height: 72, + borderRadius: 36, + backgroundColor: C.surface, + borderWidth: 1.5, + borderColor: C.border, + alignItems: "center", + justifyContent: "center", + shadowColor: "#000", + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.3, + shadowRadius: 8, + elevation: 6, + }, + micButtonActive: { + backgroundColor: C.micActive, + borderColor: C.micActive, + shadowColor: C.micActive, + shadowOpacity: 0.5, + shadowRadius: 16, + }, + micHint: { + fontSize: 12, + fontFamily: "Inter_400Regular", + color: C.textMuted, + }, +}); diff --git a/artifacts/mobile/app/(tabs)/matrix.tsx b/artifacts/mobile/app/(tabs)/matrix.tsx new file mode 100644 index 0000000..64c0c41 --- /dev/null +++ b/artifacts/mobile/app/(tabs)/matrix.tsx @@ -0,0 +1,226 @@ +import { Feather } from "@expo/vector-icons"; +import React, { useRef, useState } from "react"; +import { + ActivityIndicator, + Platform, + Pressable, + StyleSheet, + Text, + View, +} from "react-native"; +import { WebView } from "react-native-webview"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +import { Colors } from "@/constants/colors"; + +const C = Colors.dark; + +function getMatrixUrl(): string { + const domain = process.env.EXPO_PUBLIC_DOMAIN ?? ""; + if (!domain) return "http://localhost:8080/"; + const stripped = domain.replace(/^https?:\/\//, "").replace(/\/$/, ""); + const proto = stripped.startsWith("localhost") ? "http" : "https"; + return `${proto}://${stripped}/`; +} + +export default function MatrixScreen() { + const insets = useSafeAreaInsets(); + const topPad = Platform.OS === "web" ? 67 : insets.top; + const [loading, setLoading] = useState(true); + const [error, setError] = useState(false); + const webviewRef = useRef(null); + const matrixUrl = getMatrixUrl(); + + if (Platform.OS === "web") { + return ( + + + The Matrix + Timmy's 3D command center + + +