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:
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user