Files
alexpaynex cf1819f34b 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
2026-03-19 23:55:16 +00:00

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",
},
});