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
247 lines
9.2 KiB
TypeScript
247 lines
9.2 KiB
TypeScript
import React, { useEffect, useRef, useState } from "react";
|
|
import { Animated, Easing, Platform, StyleSheet, View } from "react-native";
|
|
import Svg, { Circle, Ellipse, Path } from "react-native-svg";
|
|
|
|
import type { TimmyMood } from "@/context/TimmyContext";
|
|
|
|
type FaceTarget = {
|
|
eyeScaleY: number;
|
|
pupilScale: number;
|
|
smileAmount: number;
|
|
};
|
|
|
|
const FACE_TARGETS: Record<TimmyMood, FaceTarget> = {
|
|
idle: { eyeScaleY: 0.44, pupilScale: 0.90, smileAmount: 0.08 },
|
|
thinking: { eyeScaleY: 0.30, pupilScale: 0.72, smileAmount: 0.00 },
|
|
working: { eyeScaleY: 0.75, pupilScale: 1.05, smileAmount: 0.18 },
|
|
speaking: { eyeScaleY: 0.92, pupilScale: 1.25, smileAmount: 0.38 },
|
|
};
|
|
|
|
const BASE_PUPIL_R = 2.8;
|
|
const BASE_EYE_RY = 5;
|
|
const SVG_VIEW = 100;
|
|
const SVG_CX = SVG_VIEW / 2;
|
|
const SVG_CY = SVG_VIEW / 2;
|
|
const HEAD_R = 36;
|
|
const EYE_L_X = SVG_CX - 11;
|
|
const EYE_R_X = SVG_CX + 11;
|
|
const EYE_CY = SVG_CY - 4;
|
|
const EYE_RX = 5;
|
|
const HAT_BRIM_Y = SVG_CY - HEAD_R - 2;
|
|
|
|
function buildMouthPath(smileAmount: number): string {
|
|
const s = Math.max(-1, Math.min(1, smileAmount));
|
|
const mouthY = SVG_CY + 18;
|
|
const halfW = 18;
|
|
const ctrlDy = -s * 8;
|
|
const x1 = SVG_CX - halfW;
|
|
const x2 = SVG_CX + halfW;
|
|
return `M ${x1} ${mouthY} Q ${SVG_CX} ${mouthY + ctrlDy} ${x2} ${mouthY}`;
|
|
}
|
|
|
|
type Props = {
|
|
mood: TimmyMood;
|
|
size?: number;
|
|
};
|
|
|
|
export function TimmyFace({ mood, size = 220 }: Props) {
|
|
const eyeScaleYAnim = useRef(new Animated.Value(FACE_TARGETS.idle.eyeScaleY)).current;
|
|
const pupilScaleAnim = useRef(new Animated.Value(FACE_TARGETS.idle.pupilScale)).current;
|
|
const smileAnim = useRef(new Animated.Value(FACE_TARGETS.idle.smileAmount)).current;
|
|
const mouthOscAnim = useRef(new Animated.Value(0)).current;
|
|
const bobAnim = useRef(new Animated.Value(0)).current;
|
|
|
|
const speakingLoopRef = useRef<Animated.CompositeAnimation | null>(null);
|
|
const bobLoopRef = useRef<Animated.CompositeAnimation | null>(null);
|
|
|
|
useEffect(() => {
|
|
const target = FACE_TARGETS[mood];
|
|
|
|
Animated.parallel([
|
|
Animated.spring(eyeScaleYAnim, { toValue: target.eyeScaleY, friction: 6, tension: 80, useNativeDriver: false }),
|
|
Animated.spring(pupilScaleAnim, { toValue: target.pupilScale, friction: 6, tension: 80, useNativeDriver: false }),
|
|
Animated.spring(smileAnim, { toValue: target.smileAmount, friction: 6, tension: 80, useNativeDriver: false }),
|
|
]).start();
|
|
|
|
if (mood === "speaking") {
|
|
speakingLoopRef.current?.stop();
|
|
const osc = Animated.loop(
|
|
Animated.sequence([
|
|
Animated.timing(mouthOscAnim, { toValue: 0.3, duration: 250, easing: Easing.inOut(Easing.sin), useNativeDriver: false }),
|
|
Animated.timing(mouthOscAnim, { toValue: -0.1, duration: 250, easing: Easing.inOut(Easing.sin), useNativeDriver: false }),
|
|
])
|
|
);
|
|
speakingLoopRef.current = osc;
|
|
osc.start();
|
|
} else {
|
|
speakingLoopRef.current?.stop();
|
|
speakingLoopRef.current = null;
|
|
Animated.spring(mouthOscAnim, { toValue: 0, friction: 8, tension: 60, useNativeDriver: false }).start();
|
|
}
|
|
|
|
bobLoopRef.current?.stop();
|
|
const speed = mood === "working" ? 400 : mood === "thinking" ? 700 : 1200;
|
|
const amount = mood === "idle" ? 3 : 6;
|
|
const bob = Animated.loop(
|
|
Animated.sequence([
|
|
Animated.timing(bobAnim, { toValue: amount, duration: speed, easing: Easing.inOut(Easing.sin), useNativeDriver: true }),
|
|
Animated.timing(bobAnim, { toValue: -amount, duration: speed, easing: Easing.inOut(Easing.sin), useNativeDriver: true }),
|
|
])
|
|
);
|
|
bobLoopRef.current = bob;
|
|
bob.start();
|
|
|
|
return () => {
|
|
speakingLoopRef.current?.stop();
|
|
bobLoopRef.current?.stop();
|
|
};
|
|
}, [mood]);
|
|
|
|
return (
|
|
<Animated.View
|
|
style={[styles.container, { width: size, height: size, transform: [{ translateY: bobAnim }] }]}
|
|
>
|
|
<StaticFaceLayer size={size} mood={mood} />
|
|
<AnimatedMouth smileAnim={smileAnim} mouthOscAnim={mouthOscAnim} size={size} />
|
|
<AnimatedEyelids eyeScaleYAnim={eyeScaleYAnim} size={size} />
|
|
<AnimatedPupils pupilScaleAnim={pupilScaleAnim} size={size} />
|
|
</Animated.View>
|
|
);
|
|
}
|
|
|
|
function StaticFaceLayer({ size, mood }: { size: number; mood: TimmyMood }) {
|
|
const glowColors: Record<TimmyMood, string> = {
|
|
idle: "#6B7280",
|
|
thinking: "#3B82F6",
|
|
working: "#F59E0B",
|
|
speaking: "#7C3AED",
|
|
};
|
|
const glowColor = glowColors[mood];
|
|
|
|
return (
|
|
<Svg width={size} height={size} viewBox={`0 0 ${SVG_VIEW} ${SVG_VIEW}`}>
|
|
{/* Hat brim */}
|
|
<Ellipse cx={SVG_CX} cy={HAT_BRIM_Y} rx={28} ry={4} fill="#3a0880" stroke="#7C3AED" strokeWidth={0.5} />
|
|
{/* Hat cone */}
|
|
<Path d={`M ${SVG_CX - 18} ${HAT_BRIM_Y} L ${SVG_CX} ${HAT_BRIM_Y - 22} L ${SVG_CX + 18} ${HAT_BRIM_Y} Z`} fill="#3a0880" stroke="#7C3AED" strokeWidth={0.5} />
|
|
{/* Hat band */}
|
|
<Ellipse cx={SVG_CX} cy={HAT_BRIM_Y - 1} rx={18} ry={2.5} fill="#FFD700" opacity={0.9} />
|
|
{/* Star */}
|
|
<Circle cx={SVG_CX} cy={HAT_BRIM_Y - 22} r={2.5} fill="#FFD700" />
|
|
{/* Head */}
|
|
<Circle cx={SVG_CX} cy={SVG_CY} r={HEAD_R} fill="#d8a878" stroke="#c89060" strokeWidth={0.5} />
|
|
{/* Robe */}
|
|
<Path d={`M ${SVG_CX - 18} ${SVG_CY + HEAD_R} Q ${SVG_CX - 28} ${SVG_CY + HEAD_R + 30} ${SVG_CX - 22} ${SVG_CY + HEAD_R + 50} L ${SVG_CX + 22} ${SVG_CY + HEAD_R + 50} Q ${SVG_CX + 28} ${SVG_CY + HEAD_R + 30} ${SVG_CX + 18} ${SVG_CY + HEAD_R} Z`} fill="#5c14b0" stroke="#7C3AED" strokeWidth={0.5} />
|
|
{/* Belt */}
|
|
<Ellipse cx={SVG_CX} cy={SVG_CY + HEAD_R + 28} rx={19} ry={3} fill="#FFD700" opacity={0.9} />
|
|
{/* Beard */}
|
|
<Ellipse cx={SVG_CX} cy={SVG_CY + HEAD_R - 4} rx={13} ry={7} fill="#aaa8a0" opacity={0.9} />
|
|
<Path d={`M ${SVG_CX - 10} ${SVG_CY + HEAD_R} Q ${SVG_CX} ${SVG_CY + HEAD_R + 18} ${SVG_CX} ${SVG_CY + HEAD_R + 22} Q ${SVG_CX} ${SVG_CY + HEAD_R + 18} ${SVG_CX + 10} ${SVG_CY + HEAD_R} Z`} fill="#aaa8a0" opacity={0.85} />
|
|
{/* Side hair */}
|
|
<Circle cx={SVG_CX - 32} cy={SVG_CY - 4} r={8} fill="#c8c4bc" opacity={0.8} />
|
|
<Circle cx={SVG_CX + 32} cy={SVG_CY - 4} r={8} fill="#c8c4bc" opacity={0.8} />
|
|
{/* Eye whites */}
|
|
<Ellipse cx={EYE_L_X} cy={EYE_CY} rx={EYE_RX} ry={BASE_EYE_RY} fill="#f5f2e8" />
|
|
<Ellipse cx={EYE_R_X} cy={EYE_CY} rx={EYE_RX} ry={BASE_EYE_RY} fill="#f5f2e8" />
|
|
{/* Magic orb */}
|
|
<Circle cx={SVG_CX + HEAD_R - 2} cy={SVG_CY + 5} r={5} fill="#ff8800" opacity={0.9} />
|
|
<Circle cx={SVG_CX + HEAD_R - 2} cy={SVG_CY + 5} r={3} fill="#ffcc00" opacity={0.8} />
|
|
</Svg>
|
|
);
|
|
}
|
|
|
|
function AnimatedMouth({
|
|
smileAnim,
|
|
mouthOscAnim,
|
|
size,
|
|
}: {
|
|
smileAnim: Animated.Value;
|
|
mouthOscAnim: Animated.Value;
|
|
size: number;
|
|
}) {
|
|
const [path, setPath] = useState(buildMouthPath(FACE_TARGETS.idle.smileAmount));
|
|
|
|
useEffect(() => {
|
|
const combined = Animated.add(smileAnim, mouthOscAnim);
|
|
const id = combined.addListener(({ value }) => {
|
|
setPath(buildMouthPath(value));
|
|
});
|
|
return () => combined.removeListener(id);
|
|
}, [smileAnim, mouthOscAnim]);
|
|
|
|
return (
|
|
<View style={[StyleSheet.absoluteFill, { pointerEvents: "none" }]}>
|
|
<Svg width={size} height={size} viewBox={`0 0 ${SVG_VIEW} ${SVG_VIEW}`}>
|
|
<Path d={path} stroke="#8a4a28" strokeWidth={2.5} fill="none" strokeLinecap="round" />
|
|
</Svg>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function AnimatedEyelids({
|
|
eyeScaleYAnim,
|
|
size,
|
|
}: {
|
|
eyeScaleYAnim: Animated.Value;
|
|
size: number;
|
|
}) {
|
|
const [lidRy, setLidRy] = useState(BASE_EYE_RY * (1 - FACE_TARGETS.idle.eyeScaleY));
|
|
const [lidCyOffset, setLidCyOffset] = useState(0);
|
|
|
|
useEffect(() => {
|
|
const id = eyeScaleYAnim.addListener(({ value }) => {
|
|
const newLidRy = BASE_EYE_RY * (1 - value);
|
|
setLidRy(newLidRy);
|
|
setLidCyOffset(-(BASE_EYE_RY - newLidRy));
|
|
});
|
|
return () => eyeScaleYAnim.removeListener(id);
|
|
}, [eyeScaleYAnim]);
|
|
|
|
return (
|
|
<View style={[StyleSheet.absoluteFill, { pointerEvents: "none" }]}>
|
|
<Svg width={size} height={size} viewBox={`0 0 ${SVG_VIEW} ${SVG_VIEW}`}>
|
|
<Ellipse cx={EYE_L_X} cy={EYE_CY + lidCyOffset} rx={EYE_RX + 0.5} ry={lidRy + 0.5} fill="#d8a878" />
|
|
<Ellipse cx={EYE_R_X} cy={EYE_CY + lidCyOffset} rx={EYE_RX + 0.5} ry={lidRy + 0.5} fill="#d8a878" />
|
|
</Svg>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function AnimatedPupils({
|
|
pupilScaleAnim,
|
|
size,
|
|
}: {
|
|
pupilScaleAnim: Animated.Value;
|
|
size: number;
|
|
}) {
|
|
const [pupilR, setPupilR] = useState(BASE_PUPIL_R * FACE_TARGETS.idle.pupilScale);
|
|
|
|
useEffect(() => {
|
|
const id = pupilScaleAnim.addListener(({ value }) => {
|
|
setPupilR(BASE_PUPIL_R * value);
|
|
});
|
|
return () => pupilScaleAnim.removeListener(id);
|
|
}, [pupilScaleAnim]);
|
|
|
|
return (
|
|
<View style={[StyleSheet.absoluteFill, { pointerEvents: "none" }]}>
|
|
<Svg width={size} height={size} viewBox={`0 0 ${SVG_VIEW} ${SVG_VIEW}`}>
|
|
{/* Pupils */}
|
|
<Circle cx={EYE_L_X} cy={EYE_CY + 1} r={pupilR} fill="#07070f" />
|
|
<Circle cx={EYE_R_X} cy={EYE_CY + 1} r={pupilR} fill="#07070f" />
|
|
{/* Highlights */}
|
|
<Circle cx={EYE_L_X + 1.5} cy={EYE_CY - 0.5} r={0.8} fill="#ffffff" opacity={0.85} />
|
|
<Circle cx={EYE_R_X + 1.5} cy={EYE_CY - 0.5} r={0.8} fill="#ffffff" opacity={0.85} />
|
|
</Svg>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
container: {
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
position: "relative",
|
|
},
|
|
});
|