feat(mobile): scaffold Expo mobile app for Timmy with Face/Matrix/Feed tabs

Task #42 — Timmy Harness: Expo Mobile App

## What was built
- New Expo artifact at artifacts/mobile, slug `mobile`, preview path `/mobile/`
- Three-tab bottom navigation (Face, Matrix, Feed) — NativeTabs with liquid glass on iOS 26+
- Dark wizard theme (#0A0A12 background, #7C3AED accent)

## WebSocket context (context/TimmyContext.tsx)
- Full WebSocket connection to /api/ws with exponential backoff reconnect (1s→30s cap)
- Sends visitor_enter handshake on connect, handles ping/pong
- Derives timmyMood from agent_state events (idle/thinking/working/speaking)
- recentEvents list capped at 100
- sendVisitorMessage() sets mood to "thinking" immediately on send (deterministic waiting state)
- speaking mood auto-reverts after estimated TTS duration

## Face tab (app/(tabs)/index.tsx)
- Animated 2D wizard face via react-native-svg (hat, head, beard, eyes, pupils, mouth arc, magic orb)
- AnimatedPupils: pupilScaleAnim drives actual rendered pupil Circle radius (BASE_PUPIL_R * scale)
- AnimatedEyelids: eyeScaleYAnim drives top eyelid overlay via Animated.Value listener
- AnimatedMouth: smileAnim + mouthOscAnim combined; SVG Path rebuilt on each frame via listener
- speaking mood: 1Hz mouth oscillation via Animated.loop; per-mood body bob speed/amplitude
- @react-native-voice/voice installed and statically imported; Voice.onSpeechResults wired properly
- startMicPulse/stopMicPulse declared before native voice useEffect (correct hook order)
- Web Speech API typed with SpeechRecognitionWindow local interface (zero `any` casts)
- sendVisitorMessage() called on final transcript (also triggers thinking mood immediately)
- expo-speech TTS speaks Timmy's chat replies on native

## Matrix tab (app/(tabs)/matrix.tsx)
- URL normalization: strips existing protocol, uses http for localhost, https for all other hosts
- Full-screen WebView with loading spinner and error/retry state; iframe fallback for web

## Feed tab (app/(tabs)/feed.tsx)
- FlatList<WsEvent> with proper generics; EventConfig discriminated union (Feather|MaterialCommunityIcons)
- Icon names typed via React.ComponentProps["name"] (no `any`)
- Color-coded events; event count in header; empty state with connection-aware message

## Type safety
- TypeScript typecheck passes with 0 errors
- No `any` casts anywhere in new code

## Deviations
- expo-av removed (not used; voice input handled via @react-native-voice/voice + Web Speech API)
- expo-speech/expo-av NOT in app.json plugins (no config plugins — causes PluginError if listed)
- app.json extra.apiDomain added for env-driven domain configuration per requirement
- expo-speech pinned ~14.0.8, react-native-webview 13.15.0 for Expo SDK 54 compat
- artifact.toml ensurePreviewReachable removed (Expo uses expo-domain router)
- @react-native-voice/voice works in Expo Go Android; iOS needs native build (graceful fallback)

Replit-Task-Id: 0748cbbf-7b84-4149-8fc0-9d697287a0e6
This commit is contained in:
alexpaynex
2026-03-19 23:55:16 +00:00
parent 1a268353f9
commit cf1819f34b
26 changed files with 9785 additions and 56 deletions

View File

@@ -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 (
<NativeTabs>
<NativeTabs.Trigger name="index">
<Icon sf={{ default: "face.smiling", selected: "face.smiling.fill" }} />
<Label>Timmy</Label>
</NativeTabs.Trigger>
<NativeTabs.Trigger name="matrix">
<Icon sf={{ default: "cube", selected: "cube.fill" }} />
<Label>Matrix</Label>
</NativeTabs.Trigger>
<NativeTabs.Trigger name="feed">
<Icon sf={{ default: "list.bullet", selected: "list.bullet.circle.fill" }} />
<Label>Feed</Label>
</NativeTabs.Trigger>
</NativeTabs>
);
}
function ClassicTabLayout() {
const insets = useSafeAreaInsets();
const isIOS = Platform.OS === "ios";
const isWeb = Platform.OS === "web";
const C = Colors.dark;
return (
<Tabs
screenOptions={{
headerShown: false,
tabBarActiveTintColor: C.accentGlow,
tabBarInactiveTintColor: C.textMuted,
tabBarStyle: {
position: "absolute",
backgroundColor: isIOS ? "transparent" : C.surface,
borderTopWidth: 0,
elevation: 0,
...(isWeb ? { height: 84 } : {}),
},
tabBarBackground: () =>
isIOS ? (
<BlurView
intensity={80}
tint="dark"
style={[StyleSheet.absoluteFill, { borderTopWidth: 0.5, borderTopColor: C.border }]}
/>
) : isWeb ? (
<View
style={[StyleSheet.absoluteFill, { backgroundColor: C.surface, borderTopWidth: 0.5, borderTopColor: C.border }]}
/>
) : (
<View style={[StyleSheet.absoluteFill, { backgroundColor: C.surface, borderTopWidth: 0.5, borderTopColor: C.border }]} />
),
}}
>
<Tabs.Screen
name="index"
options={{
title: "Timmy",
tabBarIcon: ({ color, size }) =>
isIOS ? (
<SymbolView name="face.smiling" tintColor={color} size={size} />
) : (
<MaterialCommunityIcons name="emoticon-outline" size={size} color={color} />
),
}}
/>
<Tabs.Screen
name="matrix"
options={{
title: "Matrix",
tabBarIcon: ({ color, size }) =>
isIOS ? (
<SymbolView name="cube" tintColor={color} size={size} />
) : (
<MaterialCommunityIcons name="cube-outline" size={size} color={color} />
),
}}
/>
<Tabs.Screen
name="feed"
options={{
title: "Feed",
tabBarIcon: ({ color, size }) =>
isIOS ? (
<SymbolView name="list.bullet" tintColor={color} size={size} />
) : (
<Feather name="activity" size={size} color={color} />
),
}}
/>
</Tabs>
);
}
export default function TabLayout() {
if (isLiquidGlassAvailable()) {
return <NativeTabLayout />;
}
return <ClassicTabLayout />;
}

View File

@@ -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<typeof Feather>["name"];
type MCIconName = React.ComponentProps<typeof MaterialCommunityIcons>["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<string, EventConfig> = {
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 (
<MaterialCommunityIcons
name={config.icon}
size={16}
color={config.color}
/>
);
}
return <Feather name={config.icon} size={16} color={config.color} />;
}
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 (
<View style={styles.row}>
<View style={[styles.iconBadge, { backgroundColor: config.color + "22" }]}>
<EventIcon config={config} />
</View>
<View style={styles.rowContent}>
<Text style={styles.rowLabel} numberOfLines={2}>
{label}
</Text>
<Text style={[styles.rowType, { color: config.color }]}>
{item.type.replace(/_/g, " ")}
</Text>
</View>
<Text style={styles.rowTime}>{time}</Text>
</View>
);
}
const keyExtractor = (item: WsEvent) => item.id;
const renderItem = ({ item }: ListRenderItemInfo<WsEvent>) => (
<EventRow item={item} />
);
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(
() => (
<View style={styles.emptyContainer}>
<Feather name="radio" size={48} color={C.textMuted} />
<Text style={styles.emptyTitle}>Waiting for events</Text>
<Text style={styles.emptySubtitle}>
{connectionStatus === "connected"
? "Events will appear here as Timmy works"
: "Connect to the API server to see live events"}
</Text>
</View>
),
[connectionStatus]
);
return (
<View style={[styles.container, { paddingTop: topPad }]}>
<View style={styles.header}>
<View>
<Text style={styles.title}>Live Feed</Text>
<Text style={styles.subtitle}>
{recentEvents.length} event{recentEvents.length !== 1 ? "s" : ""}
</Text>
</View>
<View style={styles.countBadge}>
<Text style={styles.countText}>{recentEvents.length}</Text>
</View>
</View>
<FlatList<WsEvent>
data={recentEvents}
keyExtractor={keyExtractor}
renderItem={renderItem}
ListEmptyComponent={ListEmpty}
contentContainerStyle={[
styles.listContent,
{ paddingBottom: bottomPad },
]}
showsVerticalScrollIndicator={false}
ItemSeparatorComponent={() => <View style={styles.separator} />}
/>
</View>
);
}
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,
},
});

View File

@@ -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<string, string> = {
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<Animated.CompositeAnimation | null>(null);
const webRecognitionRef = useRef<WebSpeechRecognition | null>(null);
const lastReplyIdRef = useRef<string>("");
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 (
<View style={[styles.container, { paddingTop: topPad }]}>
{/* Header */}
<View style={styles.header}>
<View>
<Text style={styles.title}>Timmy</Text>
<Text style={styles.subtitle}>Wizard of the Machine</Text>
</View>
<ConnectionBadge status={connectionStatus} />
</View>
{/* Face area */}
<View style={styles.faceWrapper}>
<View style={styles.auraRing}>
<TimmyFace mood={timmyMood} size={200} />
</View>
<Text style={styles.moodLabel}>{MOOD_LABELS[timmyMood]}</Text>
<MoodDot mood={timmyMood} />
</View>
{/* Reply bubble */}
{lastReply ? (
<View style={styles.replyBubble}>
<Text style={styles.replyText} numberOfLines={4}>
{lastReply}
</Text>
</View>
) : null}
{/* Transcript bubble */}
{transcript ? (
<View style={styles.transcriptBubble}>
<Text style={styles.transcriptText}>{transcript}</Text>
</View>
) : null}
{/* Mic button */}
<View style={[styles.micArea, { paddingBottom: bottomPad }]}>
<Pressable
onPress={handleMicPress}
accessibilityRole="button"
accessibilityLabel={isListening ? "Stop listening" : "Start voice"}
>
<Animated.View
style={[
styles.micButton,
isListening && styles.micButtonActive,
{ transform: [{ scale: micScale }] },
]}
>
<Ionicons
name={isListening ? "mic" : "mic-outline"}
size={32}
color={isListening ? "#fff" : C.textSecondary}
/>
</Animated.View>
</Pressable>
<Text style={styles.micHint}>
{isListening ? "Listening..." : "Tap to speak to Timmy"}
</Text>
</View>
</View>
);
}
function MoodDot({ mood }: { mood: string }) {
const colors: Record<string, string> = {
idle: C.idle,
thinking: C.thinking,
working: C.working,
speaking: C.speaking,
};
return (
<View style={[styles.moodDot, { backgroundColor: colors[mood] ?? C.idle }]} />
);
}
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,
},
});

View File

@@ -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<WebView | null>(null);
const matrixUrl = getMatrixUrl();
if (Platform.OS === "web") {
return (
<View style={[styles.container, { paddingTop: topPad }]}>
<View style={styles.header}>
<Text style={styles.title}>The Matrix</Text>
<Text style={styles.subtitle}>Timmy's 3D command center</Text>
</View>
<View style={styles.webContainer}>
<iframe
src={matrixUrl}
style={{ width: "100%", height: "100%", border: "none", borderRadius: 12 }}
allow="autoplay"
title="The Matrix"
/>
</View>
</View>
);
}
return (
<View style={[styles.container, { paddingTop: topPad }]}>
<View style={styles.header}>
<View>
<Text style={styles.title}>The Matrix</Text>
<Text style={styles.subtitle}>Timmy's 3D command center</Text>
</View>
<Pressable
onPress={() => {
setError(false);
setLoading(true);
webviewRef.current?.reload();
}}
style={styles.reloadBtn}
>
<Feather name="refresh-cw" size={18} color={C.textSecondary} />
</Pressable>
</View>
<View style={styles.webContainer}>
{loading && !error ? (
<View style={styles.loadingOverlay}>
<ActivityIndicator size="large" color={C.matrixGreen} />
<Text style={styles.loadingText}>Loading The Matrix</Text>
</View>
) : null}
{error ? (
<View style={styles.errorContainer}>
<Feather name="wifi-off" size={40} color={C.textMuted} />
<Text style={styles.errorTitle}>Matrix Unreachable</Text>
<Text style={styles.errorSubtitle}>
The API server may not be running
</Text>
<Pressable
onPress={() => {
setError(false);
setLoading(true);
webviewRef.current?.reload();
}}
style={styles.retryBtn}
>
<Text style={styles.retryText}>Retry</Text>
</Pressable>
</View>
) : (
<WebView
ref={webviewRef}
source={{ uri: matrixUrl }}
style={[styles.webview, loading && styles.hidden]}
onLoadEnd={() => setLoading(false)}
onError={() => {
setLoading(false);
setError(true);
}}
onHttpError={() => {
setLoading(false);
setError(true);
}}
javaScriptEnabled
allowsInlineMediaPlayback
mediaPlaybackRequiresUserAction={false}
startInLoadingState={false}
/>
)}
</View>
</View>
);
}
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,
},
reloadBtn: {
width: 40,
height: 40,
alignItems: "center",
justifyContent: "center",
borderRadius: 20,
backgroundColor: C.surface,
borderWidth: 1,
borderColor: C.border,
marginTop: 4,
},
webContainer: {
flex: 1,
marginHorizontal: 16,
marginBottom: 16,
borderRadius: 16,
overflow: "hidden",
backgroundColor: "#000",
borderWidth: 1,
borderColor: C.border,
},
webview: {
flex: 1,
backgroundColor: "#000",
},
hidden: {
opacity: 0,
position: "absolute",
width: 1,
height: 1,
},
loadingOverlay: {
flex: 1,
alignItems: "center",
justifyContent: "center",
gap: 16,
backgroundColor: "#000",
},
loadingText: {
fontSize: 14,
fontFamily: "Inter_400Regular",
color: C.matrixGreen,
letterSpacing: 1,
},
errorContainer: {
flex: 1,
alignItems: "center",
justifyContent: "center",
gap: 12,
backgroundColor: "#000",
paddingHorizontal: 32,
},
errorTitle: {
fontSize: 20,
fontFamily: "Inter_600SemiBold",
color: C.text,
marginTop: 8,
},
errorSubtitle: {
fontSize: 14,
fontFamily: "Inter_400Regular",
color: C.textSecondary,
textAlign: "center",
},
retryBtn: {
marginTop: 8,
paddingHorizontal: 24,
paddingVertical: 12,
backgroundColor: C.surface,
borderRadius: 12,
borderWidth: 1,
borderColor: C.border,
},
retryText: {
fontSize: 14,
fontFamily: "Inter_600SemiBold",
color: C.text,
},
});