feat(flow): input feather, center puppet, smiley nav, arc buttons

- Input bar: show bouncing feather icon while typing (circle static); send
  after pause or on submit; debounced isTyping state
- Move puppet to center empty state (replacing feather); hide when there
  are messages
- Add smiley button next to mic; same as Talk (web: location, native: modal)
- Puppet actions: place four buttons in arc above puppet with icons; increase
  spacing between buttons and puppet
This commit is contained in:
Ada
2026-02-07 01:06:44 -08:00
parent 1e6c06bfef
commit 6ac492983a
8 changed files with 546 additions and 71 deletions

View File

@@ -1,16 +1,35 @@
/**
* FlowPuppetSlot - Slot for FlowScreen to show interactive AI puppet.
* Composes PuppetView and optional action buttons; does not depend on FlowScreen logic.
* Talk button: on web opens AI Studio in current tab (site blocks iframe); on native opens in-app WebView.
*/
import React, { useState, useCallback } from 'react';
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
import { View, Text, StyleSheet, TouchableOpacity, Modal, Platform } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { PuppetView } from './PuppetView';
import type { FlowPuppetSlotProps, PuppetAction } from './types';
import { colors } from '../../theme/colors';
import { borderRadius, spacing } from '../../theme/colors';
import { borderRadius, spacing, shadows } from '../../theme/colors';
import { Ionicons } from '@expo/vector-icons';
const ACTIONS: PuppetAction[] = ['smile', 'jump', 'shake'];
const isWeb = Platform.OS === 'web';
// Only load WebView on native (it does not support web platform)
const WebView = isWeb
? null
: require('react-native-webview').WebView;
const PUPPET_ACTIONS: PuppetAction[] = ['smile', 'jump', 'shake'];
const TALK_WEB_URL = 'https://aistudio.google.com/apps/drive/1L39svCbfbRc48Eby64Q0rSbSoQZiWQBp?showPreview=true&showAssistant=true&fullscreenApplet=true';
const ACTION_CONFIG: Record<string, { label: string; icon: keyof typeof Ionicons.glyphMap }> = {
smile: { label: 'Smile', icon: 'happy-outline' },
jump: { label: 'Jump', icon: 'arrow-up-circle-outline' },
shake: { label: 'Shake', icon: 'swap-horizontal' },
talk: { label: 'Talk', icon: 'chatbubble-ellipses-outline' },
};
export function FlowPuppetSlot({
currentAction,
@@ -19,6 +38,7 @@ export function FlowPuppetSlot({
showActionButtons = true,
}: FlowPuppetSlotProps) {
const [localAction, setLocalAction] = useState<PuppetAction>(currentAction);
const [showTalkWeb, setShowTalkWeb] = useState(false);
const effectiveAction = currentAction !== 'idle' ? currentAction : localAction;
@@ -38,21 +58,72 @@ export function FlowPuppetSlot({
return (
<View style={styles.wrapper}>
<PuppetView action={effectiveAction} isTalking={isTalking} />
{/* Buttons in an arc above puppet, arc follows puppet shape; extra spacing to puppet */}
{showActionButtons && (
<View style={styles.actions}>
{ACTIONS.map((act) => (
<View style={styles.actionsRow}>
{PUPPET_ACTIONS.map((act, index) => {
const config = ACTION_CONFIG[act];
const isCenter = index === 1 || index === 2;
return (
<View key={act} style={[styles.arcSlot, isCenter && styles.arcSlotCenter]}>
<TouchableOpacity
style={styles.actionBtn}
onPress={() => handleAction(act)}
activeOpacity={0.8}
>
<Ionicons name={config.icon} size={22} color={colors.nautical.teal} />
<Text style={styles.actionLabel}>{config.label}</Text>
</TouchableOpacity>
</View>
);
})}
<View style={styles.arcSlot}>
<TouchableOpacity
key={act}
style={styles.actionBtn}
onPress={() => handleAction(act)}
style={[styles.actionBtn, styles.talkBtn]}
onPress={() => {
if (isWeb && typeof (globalThis as any).window !== 'undefined') {
(globalThis as any).window.location.href = TALK_WEB_URL;
} else {
setShowTalkWeb(true);
}
}}
activeOpacity={0.8}
>
<Text style={styles.actionLabel}>{act}</Text>
<Ionicons name={ACTION_CONFIG.talk.icon} size={22} color={colors.nautical.teal} />
<Text style={[styles.actionLabel, styles.talkLabel]}>Talk</Text>
</TouchableOpacity>
))}
</View>
</View>
)}
<PuppetView action={effectiveAction} isTalking={isTalking} />
<Modal
visible={showTalkWeb}
animationType="slide"
onRequestClose={() => setShowTalkWeb(false)}
>
<SafeAreaView style={styles.webModal} edges={['top']}>
<View style={styles.webModalHeader}>
<TouchableOpacity
style={styles.webModalClose}
onPress={() => setShowTalkWeb(false)}
hitSlop={{ top: 12, bottom: 12, left: 12, right: 12 }}
>
<Ionicons name="close" size={28} color={colors.flow.text} />
</TouchableOpacity>
<Text style={styles.webModalTitle} numberOfLines={1}>AI Studio Talk</Text>
</View>
{WebView ? (
<WebView
source={{ uri: TALK_WEB_URL }}
style={styles.webView}
onError={(e) => console.warn('WebView error:', e.nativeEvent)}
/>
) : (
<View style={styles.webView} />
)}
</SafeAreaView>
</Modal>
</View>
);
}
@@ -63,23 +134,71 @@ const styles = StyleSheet.create({
justifyContent: 'center',
paddingVertical: spacing.lg,
},
actions: {
actionsRow: {
flexDirection: 'row',
marginTop: spacing.lg,
flexWrap: 'wrap',
justifyContent: 'center',
alignItems: 'flex-end',
marginBottom: spacing.xxl,
gap: spacing.sm,
},
arcSlot: {
alignItems: 'center',
marginBottom: 0,
},
arcSlotCenter: {
marginBottom: 14,
},
actionBtn: {
paddingHorizontal: spacing.md,
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
minWidth: 56,
paddingVertical: spacing.sm,
borderRadius: borderRadius.lg,
paddingHorizontal: spacing.sm,
borderRadius: borderRadius.xl,
backgroundColor: colors.flow.cardBackground,
borderWidth: 1,
borderColor: colors.flow.cardBorder,
...shadows.soft,
},
actionLabel: {
fontSize: 12,
fontSize: 11,
fontWeight: '600',
color: colors.flow.primary,
marginTop: 4,
textTransform: 'capitalize',
},
talkLabel: {
color: colors.nautical.teal,
},
talkBtn: {
borderColor: colors.nautical.teal,
backgroundColor: colors.nautical.paleAqua,
},
webModal: {
flex: 1,
backgroundColor: colors.flow.cardBackground,
},
webModalHeader: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.sm,
paddingVertical: spacing.sm,
borderBottomWidth: 1,
borderBottomColor: colors.flow.cardBorder,
},
webModalClose: {
padding: spacing.xs,
marginRight: spacing.sm,
},
webModalTitle: {
fontSize: 18,
fontWeight: '600',
color: colors.flow.text,
flex: 1,
},
webView: {
flex: 1,
},
});

View File

@@ -51,7 +51,7 @@ export function PuppetView({ action, isTalking }: PuppetViewProps) {
const loop = Animated.loop(
Animated.sequence([
Animated.timing(smileScale, {
toValue: 1.12,
toValue: 1.18,
duration: 400,
useNativeDriver: true,
easing: Easing.out(Easing.ease),
@@ -314,26 +314,26 @@ const styles = StyleSheet.create({
backgroundColor: '#0c4a6e',
},
mouthSmile: {
width: 22,
height: 6,
borderBottomLeftRadius: 11,
borderBottomRightRadius: 11,
width: 28,
height: 10,
borderBottomLeftRadius: 14,
borderBottomRightRadius: 14,
borderTopLeftRadius: 0,
borderTopRightRadius: 0,
},
mouthOpen: {
width: 18,
height: 6,
height: 8,
top: BODY_SIZE * 0.51,
borderRadius: 3,
backgroundColor: 'rgba(12, 74, 110, 0.9)',
},
mouthBigSmile: {
width: 32,
height: 10,
top: BODY_SIZE * 0.51,
borderBottomLeftRadius: 16,
borderBottomRightRadius: 16,
width: 42,
height: 24,
top: BODY_SIZE * 0.50,
borderBottomLeftRadius: 21,
borderBottomRightRadius: 21,
borderTopLeftRadius: 0,
borderTopRightRadius: 0,
},