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:
69
artifacts/mobile/components/ConnectionBadge.tsx
Normal file
69
artifacts/mobile/components/ConnectionBadge.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import React from "react";
|
||||
import { Animated, StyleSheet, Text, View } from "react-native";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import { Colors } from "@/constants/colors";
|
||||
import type { ConnectionStatus } from "@/context/TimmyContext";
|
||||
|
||||
const C = Colors.dark;
|
||||
|
||||
const STATUS_CONFIG: Record<ConnectionStatus, { color: string; label: string }> = {
|
||||
connecting: { color: "#F59E0B", label: "Connecting" },
|
||||
connected: { color: "#10B981", label: "Live" },
|
||||
disconnected: { color: "#6B7280", label: "Offline" },
|
||||
error: { color: "#EF4444", label: "Error" },
|
||||
};
|
||||
|
||||
export function ConnectionBadge({ status }: { status: ConnectionStatus }) {
|
||||
const pulseAnim = useRef(new Animated.Value(1)).current;
|
||||
|
||||
useEffect(() => {
|
||||
if (status === "connecting") {
|
||||
const pulse = Animated.loop(
|
||||
Animated.sequence([
|
||||
Animated.timing(pulseAnim, { toValue: 0.3, duration: 600, useNativeDriver: true }),
|
||||
Animated.timing(pulseAnim, { toValue: 1, duration: 600, useNativeDriver: true }),
|
||||
])
|
||||
);
|
||||
pulse.start();
|
||||
return () => pulse.stop();
|
||||
} else {
|
||||
pulseAnim.setValue(1);
|
||||
}
|
||||
}, [status]);
|
||||
|
||||
const config = STATUS_CONFIG[status];
|
||||
|
||||
return (
|
||||
<View style={styles.badge}>
|
||||
<Animated.View
|
||||
style={[styles.dot, { backgroundColor: config.color, opacity: pulseAnim }]}
|
||||
/>
|
||||
<Text style={[styles.label, { color: config.color }]}>{config.label}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
badge: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 5,
|
||||
backgroundColor: C.surface,
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 20,
|
||||
borderWidth: 1,
|
||||
borderColor: C.border,
|
||||
},
|
||||
dot: {
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: 3,
|
||||
},
|
||||
label: {
|
||||
fontSize: 11,
|
||||
fontFamily: "Inter_500Medium",
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
});
|
||||
54
artifacts/mobile/components/ErrorBoundary.tsx
Normal file
54
artifacts/mobile/components/ErrorBoundary.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import React, { Component, ComponentType, PropsWithChildren } from "react";
|
||||
|
||||
import { ErrorFallback, ErrorFallbackProps } from "@/components/ErrorFallback";
|
||||
|
||||
export type ErrorBoundaryProps = PropsWithChildren<{
|
||||
FallbackComponent?: ComponentType<ErrorFallbackProps>;
|
||||
onError?: (error: Error, stackTrace: string) => void;
|
||||
}>;
|
||||
|
||||
type ErrorBoundaryState = { error: Error | null };
|
||||
|
||||
/**
|
||||
* This is a special case for for using the class components. Error boundaries must be class components because React only provides error boundary functionality through lifecycle methods (componentDidCatch and getDerivedStateFromError) which are not available in functional components.
|
||||
* https://react.dev/reference/react/Component#catching-rendering-errors-with-an-error-boundary
|
||||
*/
|
||||
export class ErrorBoundary extends Component<
|
||||
ErrorBoundaryProps,
|
||||
ErrorBoundaryState
|
||||
> {
|
||||
state: ErrorBoundaryState = { error: null };
|
||||
|
||||
static defaultProps: {
|
||||
FallbackComponent: ComponentType<ErrorFallbackProps>;
|
||||
} = {
|
||||
FallbackComponent: ErrorFallback,
|
||||
};
|
||||
|
||||
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
||||
return { error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: { componentStack: string }): void {
|
||||
if (typeof this.props.onError === "function") {
|
||||
this.props.onError(error, info.componentStack);
|
||||
}
|
||||
}
|
||||
|
||||
resetError = (): void => {
|
||||
this.setState({ error: null });
|
||||
};
|
||||
|
||||
render() {
|
||||
const { FallbackComponent } = this.props;
|
||||
|
||||
return this.state.error && FallbackComponent ? (
|
||||
<FallbackComponent
|
||||
error={this.state.error}
|
||||
resetError={this.resetError}
|
||||
/>
|
||||
) : (
|
||||
this.props.children
|
||||
);
|
||||
}
|
||||
}
|
||||
286
artifacts/mobile/components/ErrorFallback.tsx
Normal file
286
artifacts/mobile/components/ErrorFallback.tsx
Normal file
@@ -0,0 +1,286 @@
|
||||
import { Feather } from "@expo/vector-icons";
|
||||
import { reloadAppAsync } from "expo";
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
Modal,
|
||||
Platform,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
useColorScheme,
|
||||
} from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
|
||||
export type ErrorFallbackProps = {
|
||||
error: Error;
|
||||
resetError: () => void;
|
||||
};
|
||||
|
||||
export function ErrorFallback({ error, resetError }: ErrorFallbackProps) {
|
||||
const colorScheme = useColorScheme();
|
||||
const isDark = colorScheme === "dark";
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
const theme = {
|
||||
background: isDark ? "#000000" : "#FFFFFF",
|
||||
backgroundSecondary: isDark ? "#1C1C1E" : "#F2F2F7",
|
||||
text: isDark ? "#FFFFFF" : "#000000",
|
||||
textSecondary: isDark ? "rgba(255, 255, 255, 0.7)" : "rgba(0, 0, 0, 0.7)",
|
||||
link: "#007AFF",
|
||||
buttonText: "#FFFFFF",
|
||||
};
|
||||
|
||||
const [isModalVisible, setIsModalVisible] = useState(false);
|
||||
|
||||
const handleRestart = async () => {
|
||||
try {
|
||||
await reloadAppAsync();
|
||||
} catch (restartError) {
|
||||
console.error("Failed to restart app:", restartError);
|
||||
resetError();
|
||||
}
|
||||
};
|
||||
|
||||
const formatErrorDetails = (): string => {
|
||||
let details = `Error: ${error.message}\n\n`;
|
||||
if (error.stack) {
|
||||
details += `Stack Trace:\n${error.stack}`;
|
||||
}
|
||||
return details;
|
||||
};
|
||||
|
||||
const monoFont = Platform.select({
|
||||
ios: "Menlo",
|
||||
android: "monospace",
|
||||
default: "monospace",
|
||||
});
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: theme.background }]}>
|
||||
{__DEV__ ? (
|
||||
<Pressable
|
||||
onPress={() => setIsModalVisible(true)}
|
||||
accessibilityLabel="View error details"
|
||||
accessibilityRole="button"
|
||||
style={({ pressed }) => [
|
||||
styles.topButton,
|
||||
{
|
||||
top: insets.top + 16,
|
||||
backgroundColor: theme.backgroundSecondary,
|
||||
opacity: pressed ? 0.8 : 1,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Feather name="alert-circle" size={20} color={theme.text} />
|
||||
</Pressable>
|
||||
) : null}
|
||||
|
||||
<View style={styles.content}>
|
||||
<Text style={[styles.title, { color: theme.text }]}>
|
||||
Something went wrong
|
||||
</Text>
|
||||
|
||||
<Text style={[styles.message, { color: theme.textSecondary }]}>
|
||||
Please reload the app to continue.
|
||||
</Text>
|
||||
|
||||
<Pressable
|
||||
onPress={handleRestart}
|
||||
style={({ pressed }) => [
|
||||
styles.button,
|
||||
{
|
||||
backgroundColor: theme.link,
|
||||
opacity: pressed ? 0.9 : 1,
|
||||
transform: [{ scale: pressed ? 0.98 : 1 }],
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.buttonText, { color: theme.buttonText }]}>
|
||||
Try Again
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{__DEV__ ? (
|
||||
<Modal
|
||||
visible={isModalVisible}
|
||||
animationType="slide"
|
||||
transparent={true}
|
||||
onRequestClose={() => setIsModalVisible(false)}
|
||||
>
|
||||
<View style={styles.modalOverlay}>
|
||||
<View
|
||||
style={[
|
||||
styles.modalContainer,
|
||||
{ backgroundColor: theme.background },
|
||||
]}
|
||||
>
|
||||
<View
|
||||
style={[
|
||||
styles.modalHeader,
|
||||
{
|
||||
borderBottomColor: isDark
|
||||
? "rgba(255, 255, 255, 0.1)"
|
||||
: "rgba(0, 0, 0, 0.1)",
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.modalTitle, { color: theme.text }]}>
|
||||
Error Details
|
||||
</Text>
|
||||
<Pressable
|
||||
onPress={() => setIsModalVisible(false)}
|
||||
accessibilityLabel="Close error details"
|
||||
accessibilityRole="button"
|
||||
style={({ pressed }) => [
|
||||
styles.closeButton,
|
||||
{ opacity: pressed ? 0.6 : 1 },
|
||||
]}
|
||||
>
|
||||
<Feather name="x" size={24} color={theme.text} />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<ScrollView
|
||||
style={styles.modalScrollView}
|
||||
contentContainerStyle={[
|
||||
styles.modalScrollContent,
|
||||
{ paddingBottom: insets.bottom + 16 },
|
||||
]}
|
||||
showsVerticalScrollIndicator
|
||||
>
|
||||
<View
|
||||
style={[
|
||||
styles.errorContainer,
|
||||
{ backgroundColor: theme.backgroundSecondary },
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.errorText,
|
||||
{
|
||||
color: theme.text,
|
||||
fontFamily: monoFont,
|
||||
},
|
||||
]}
|
||||
selectable
|
||||
>
|
||||
{formatErrorDetails()}
|
||||
</Text>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
padding: 24,
|
||||
},
|
||||
content: {
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 16,
|
||||
width: "100%",
|
||||
maxWidth: 600,
|
||||
},
|
||||
title: {
|
||||
fontSize: 28,
|
||||
fontWeight: "700",
|
||||
textAlign: "center",
|
||||
lineHeight: 40,
|
||||
},
|
||||
message: {
|
||||
fontSize: 16,
|
||||
textAlign: "center",
|
||||
lineHeight: 24,
|
||||
},
|
||||
topButton: {
|
||||
position: "absolute",
|
||||
right: 16,
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 8,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
zIndex: 10,
|
||||
},
|
||||
button: {
|
||||
paddingVertical: 16,
|
||||
borderRadius: 8,
|
||||
paddingHorizontal: 24,
|
||||
minWidth: 200,
|
||||
shadowColor: "#000",
|
||||
shadowOffset: {
|
||||
width: 0,
|
||||
height: 2,
|
||||
},
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
elevation: 3,
|
||||
},
|
||||
buttonText: {
|
||||
fontWeight: "600",
|
||||
textAlign: "center",
|
||||
fontSize: 16,
|
||||
},
|
||||
modalOverlay: {
|
||||
flex: 1,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||
justifyContent: "flex-end",
|
||||
},
|
||||
modalContainer: {
|
||||
width: "100%",
|
||||
height: "90%",
|
||||
borderTopLeftRadius: 16,
|
||||
borderTopRightRadius: 16,
|
||||
},
|
||||
modalHeader: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: 16,
|
||||
paddingTop: 16,
|
||||
paddingBottom: 12,
|
||||
borderBottomWidth: 1,
|
||||
},
|
||||
modalTitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: "600",
|
||||
},
|
||||
closeButton: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
modalScrollView: {
|
||||
flex: 1,
|
||||
},
|
||||
modalScrollContent: {
|
||||
padding: 16,
|
||||
},
|
||||
errorContainer: {
|
||||
width: "100%",
|
||||
borderRadius: 8,
|
||||
overflow: "hidden",
|
||||
padding: 16,
|
||||
},
|
||||
errorText: {
|
||||
fontSize: 12,
|
||||
lineHeight: 18,
|
||||
width: "100%",
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import {
|
||||
KeyboardAwareScrollView,
|
||||
KeyboardAwareScrollViewProps,
|
||||
} from "react-native-keyboard-controller";
|
||||
import { Platform, ScrollView, ScrollViewProps } from "react-native";
|
||||
|
||||
type Props = KeyboardAwareScrollViewProps & ScrollViewProps;
|
||||
|
||||
export function KeyboardAwareScrollViewCompat({
|
||||
children,
|
||||
keyboardShouldPersistTaps = "handled",
|
||||
...props
|
||||
}: Props) {
|
||||
if (Platform.OS === "web") {
|
||||
return (
|
||||
<ScrollView keyboardShouldPersistTaps={keyboardShouldPersistTaps} {...props}>
|
||||
{children}
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<KeyboardAwareScrollView
|
||||
keyboardShouldPersistTaps={keyboardShouldPersistTaps}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</KeyboardAwareScrollView>
|
||||
);
|
||||
}
|
||||
246
artifacts/mobile/components/TimmyFace.tsx
Normal file
246
artifacts/mobile/components/TimmyFace.tsx
Normal file
@@ -0,0 +1,246 @@
|
||||
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",
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user