feat: Introduce a comprehensive set of AI agent skills, agents, and workflows, along with new UI components and application structure.
This commit is contained in:
1 parent
a2a512d41d
commit
93df87d7e9
317 files changed
+63716
-8727
No files matched your search
@@ -0,0 +1,183 @@
|
||||
import * as Haptics from 'expo-haptics';
|
||||
import { Send, Square, X } from 'lucide-react-native';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Platform,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
View
|
||||
} from 'react-native';
|
||||
|
||||
interface ChatInputProps {
|
||||
onSend: (text: string) => void;
|
||||
onStop?: () => void;
|
||||
isLoading?: boolean;
|
||||
editingText?: string | null;
|
||||
onCancelEdit?: () => void;
|
||||
}
|
||||
|
||||
export function ChatInput({ onSend, onStop, isLoading, editingText, onCancelEdit }: ChatInputProps) {
|
||||
const [text, setText] = useState('');
|
||||
|
||||
// When editingText changes, fill the input
|
||||
useEffect(() => {
|
||||
if (editingText !== null && editingText !== undefined) {
|
||||
setText(editingText);
|
||||
}
|
||||
}, [editingText]);
|
||||
|
||||
const handleSend = () => {
|
||||
if (text.trim() && !isLoading) {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
onSend(text.trim());
|
||||
setText('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleStop = () => {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
|
||||
onStop?.();
|
||||
};
|
||||
|
||||
const handleCancelEdit = () => {
|
||||
setText('');
|
||||
onCancelEdit?.();
|
||||
};
|
||||
|
||||
const handleKeyPress = (e: any) => {
|
||||
if (Platform.OS === 'web') {
|
||||
if (e.nativeEvent.key === 'Enter' && !e.nativeEvent.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const isEditing = editingText !== null && editingText !== undefined;
|
||||
|
||||
return (
|
||||
<View style={styles.outerContainer}>
|
||||
{/* Edit mode banner */}
|
||||
{isEditing && (
|
||||
<View style={styles.editBanner}>
|
||||
<Text style={styles.editBannerText}>Editing message</Text>
|
||||
<TouchableOpacity onPress={handleCancelEdit} style={styles.editCancelBtn}>
|
||||
<X size={16} color="#6B7280" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={styles.container}>
|
||||
<View style={[styles.inputWrapper, isEditing && styles.inputWrapperEditing]}>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="Send a message..."
|
||||
placeholderTextColor="#9CA3AF"
|
||||
value={text}
|
||||
onChangeText={setText}
|
||||
multiline
|
||||
onKeyPress={handleKeyPress}
|
||||
editable={!isLoading}
|
||||
/>
|
||||
|
||||
{/* Send or Stop button */}
|
||||
{isLoading ? (
|
||||
<TouchableOpacity
|
||||
style={styles.stopButton}
|
||||
onPress={handleStop}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Square size={16} color="#FFFFFF" />
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
<TouchableOpacity
|
||||
style={[styles.sendButton, !text.trim() && styles.sendButtonDisabled]}
|
||||
onPress={handleSend}
|
||||
disabled={!text.trim()}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Send size={18} color={text.trim() ? '#FFFFFF' : '#9CA3AF'} />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
outerContainer: {
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: '#E5E7EB',
|
||||
},
|
||||
editBanner: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 8,
|
||||
backgroundColor: '#EFF6FF',
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#DBEAFE',
|
||||
},
|
||||
editBannerText: {
|
||||
fontSize: 13,
|
||||
color: '#2563EB',
|
||||
fontWeight: '500',
|
||||
},
|
||||
editCancelBtn: {
|
||||
padding: 4,
|
||||
},
|
||||
container: {
|
||||
padding: 12,
|
||||
paddingBottom: Platform.OS === 'ios' ? 24 : 12,
|
||||
},
|
||||
inputWrapper: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-end',
|
||||
backgroundColor: '#F3F4F6',
|
||||
borderRadius: 24,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 6,
|
||||
minHeight: 44,
|
||||
maxHeight: 120,
|
||||
},
|
||||
inputWrapperEditing: {
|
||||
borderWidth: 1.5,
|
||||
borderColor: '#2563EB',
|
||||
},
|
||||
input: {
|
||||
flex: 1,
|
||||
fontSize: 15,
|
||||
lineHeight: 22,
|
||||
paddingTop: 8,
|
||||
paddingBottom: 8,
|
||||
color: '#111827',
|
||||
},
|
||||
sendButton: {
|
||||
width: 34,
|
||||
height: 34,
|
||||
borderRadius: 17,
|
||||
backgroundColor: '#111827',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginLeft: 8,
|
||||
marginBottom: 2,
|
||||
},
|
||||
sendButtonDisabled: {
|
||||
backgroundColor: '#E5E7EB',
|
||||
},
|
||||
stopButton: {
|
||||
width: 34,
|
||||
height: 34,
|
||||
borderRadius: 17,
|
||||
backgroundColor: '#EF4444',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginLeft: 8,
|
||||
marginBottom: 2,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import * as Clipboard from 'expo-clipboard';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
import { Copy, Pencil, RefreshCw, Trash2 } from 'lucide-react-native';
|
||||
import React from 'react';
|
||||
import { StyleSheet, TouchableOpacity, View } from 'react-native';
|
||||
|
||||
interface MessageActionsProps {
|
||||
role: 'user' | 'assistant' | 'system';
|
||||
content: string;
|
||||
onEdit?: () => void;
|
||||
onRegenerate?: () => void;
|
||||
onDelete?: () => void;
|
||||
isStreaming?: boolean;
|
||||
}
|
||||
|
||||
export function MessageActions({
|
||||
role,
|
||||
content,
|
||||
onEdit,
|
||||
onRegenerate,
|
||||
onDelete,
|
||||
isStreaming,
|
||||
}: MessageActionsProps) {
|
||||
const handleCopy = async () => {
|
||||
await Clipboard.setStringAsync(content);
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||
};
|
||||
|
||||
const handleEdit = () => {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
onEdit?.();
|
||||
};
|
||||
|
||||
const handleRegenerate = () => {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
|
||||
onRegenerate?.();
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
|
||||
onDelete?.();
|
||||
};
|
||||
|
||||
if (isStreaming) return null;
|
||||
|
||||
const isUser = role === 'user';
|
||||
|
||||
return (
|
||||
<View style={[styles.container, isUser ? styles.containerUser : styles.containerAssistant]}>
|
||||
<TouchableOpacity style={styles.actionBtn} onPress={handleCopy} activeOpacity={0.6}>
|
||||
<Copy size={14} color="#9CA3AF" />
|
||||
</TouchableOpacity>
|
||||
|
||||
{isUser && onEdit && (
|
||||
<TouchableOpacity style={styles.actionBtn} onPress={handleEdit} activeOpacity={0.6}>
|
||||
<Pencil size={14} color="#9CA3AF" />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
{!isUser && onRegenerate && (
|
||||
<TouchableOpacity style={styles.actionBtn} onPress={handleRegenerate} activeOpacity={0.6}>
|
||||
<RefreshCw size={14} color="#9CA3AF" />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
{onDelete && (
|
||||
<TouchableOpacity style={styles.actionBtn} onPress={handleDelete} activeOpacity={0.6}>
|
||||
<Trash2 size={14} color="#9CA3AF" />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
gap: 2,
|
||||
marginTop: 4,
|
||||
paddingHorizontal: 16,
|
||||
},
|
||||
containerUser: {
|
||||
justifyContent: 'flex-end',
|
||||
},
|
||||
containerAssistant: {
|
||||
justifyContent: 'flex-start',
|
||||
},
|
||||
actionBtn: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 8,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,265 @@
|
||||
import { ChatMessage } from '@/store/useAppStore';
|
||||
import Markdown from '@ronradtke/react-native-markdown-display';
|
||||
import { Bot, User } from 'lucide-react-native';
|
||||
import React, { useState } from 'react';
|
||||
import { StyleSheet, Text, useWindowDimensions, View } from 'react-native';
|
||||
import { MessageActions } from './MessageActions';
|
||||
|
||||
interface MessageBubbleProps {
|
||||
message: ChatMessage;
|
||||
onEdit?: () => void;
|
||||
onRegenerate?: () => void;
|
||||
onDelete?: () => void;
|
||||
isStreaming?: boolean;
|
||||
isLastAssistant?: boolean;
|
||||
}
|
||||
|
||||
export function MessageBubble({
|
||||
message,
|
||||
onEdit,
|
||||
onRegenerate,
|
||||
onDelete,
|
||||
isStreaming,
|
||||
isLastAssistant,
|
||||
}: MessageBubbleProps) {
|
||||
const { width } = useWindowDimensions();
|
||||
const isLargeScreen = width > 768;
|
||||
const isUser = message.role === 'user';
|
||||
const [showActions, setShowActions] = useState(false);
|
||||
|
||||
const showingStreaming = isStreaming && isLastAssistant;
|
||||
const shouldShowActions = !isUser || showActions;
|
||||
|
||||
const renderContent = () => {
|
||||
if (isUser) {
|
||||
return (
|
||||
<Text style={[styles.text, styles.textUser]}>
|
||||
{message.content}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
if (!message.content && showingStreaming) {
|
||||
return null; // TypingIndicator handled externally
|
||||
}
|
||||
|
||||
return (
|
||||
<Markdown style={markdownStyles}>
|
||||
{message.content}
|
||||
</Markdown>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<View
|
||||
onTouchStart={() => isUser && setShowActions(true)}
|
||||
>
|
||||
<View style={[
|
||||
styles.container,
|
||||
isUser ? styles.containerUser : styles.containerAssistant,
|
||||
]}>
|
||||
{/* Avatar */}
|
||||
{!isUser && (
|
||||
<View style={styles.avatarAssistant}>
|
||||
<Bot size={16} color="#6B7280" />
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={[
|
||||
styles.bubble,
|
||||
isUser ? styles.bubbleUser : styles.bubbleAssistant,
|
||||
{ maxWidth: isLargeScreen ? '70%' : '85%' },
|
||||
]}>
|
||||
{renderContent()}
|
||||
</View>
|
||||
|
||||
{isUser && (
|
||||
<View style={styles.avatarUser}>
|
||||
<User size={16} color="#FFFFFF" />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Timestamp + edited badge */}
|
||||
<View style={[styles.metaRow, isUser ? styles.metaRowUser : styles.metaRowAssistant]}>
|
||||
{!isUser && <View style={{ width: 32 }} />}
|
||||
<Text style={styles.timestamp}>
|
||||
{new Date(message.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
{message.isEdited ? ' · edited' : ''}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Action buttons */}
|
||||
{(shouldShowActions || !isUser) && message.content.length > 0 && (
|
||||
<MessageActions
|
||||
role={message.role}
|
||||
content={message.content}
|
||||
onEdit={isUser ? onEdit : undefined}
|
||||
onRegenerate={!isUser && isLastAssistant ? onRegenerate : undefined}
|
||||
onDelete={onDelete}
|
||||
isStreaming={showingStreaming}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
width: '100%',
|
||||
paddingHorizontal: 16,
|
||||
marginTop: 12,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-end',
|
||||
gap: 8,
|
||||
},
|
||||
containerUser: {
|
||||
justifyContent: 'flex-end',
|
||||
},
|
||||
containerAssistant: {
|
||||
justifyContent: 'flex-start',
|
||||
},
|
||||
avatarAssistant: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 14,
|
||||
backgroundColor: '#F3F4F6',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: 2,
|
||||
},
|
||||
avatarUser: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 14,
|
||||
backgroundColor: '#374151',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: 2,
|
||||
},
|
||||
bubble: {
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 10,
|
||||
borderRadius: 18,
|
||||
},
|
||||
bubbleUser: {
|
||||
backgroundColor: '#111827',
|
||||
borderBottomRightRadius: 4,
|
||||
},
|
||||
bubbleAssistant: {
|
||||
backgroundColor: '#F3F4F6',
|
||||
borderBottomLeftRadius: 4,
|
||||
},
|
||||
text: {
|
||||
fontSize: 15,
|
||||
lineHeight: 22,
|
||||
},
|
||||
textUser: {
|
||||
color: '#FFFFFF',
|
||||
},
|
||||
metaRow: {
|
||||
flexDirection: 'row',
|
||||
paddingHorizontal: 16,
|
||||
marginTop: 2,
|
||||
gap: 8,
|
||||
},
|
||||
metaRowUser: {
|
||||
justifyContent: 'flex-end',
|
||||
paddingRight: 52,
|
||||
},
|
||||
metaRowAssistant: {
|
||||
justifyContent: 'flex-start',
|
||||
},
|
||||
timestamp: {
|
||||
fontSize: 11,
|
||||
color: '#9CA3AF',
|
||||
},
|
||||
});
|
||||
|
||||
const markdownStyles = StyleSheet.create({
|
||||
body: {
|
||||
fontSize: 15,
|
||||
lineHeight: 22,
|
||||
color: '#111827',
|
||||
},
|
||||
strong: {
|
||||
fontWeight: '700',
|
||||
},
|
||||
em: {
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
code_inline: {
|
||||
backgroundColor: '#E5E7EB',
|
||||
color: '#1F2937',
|
||||
paddingHorizontal: 5,
|
||||
paddingVertical: 1,
|
||||
borderRadius: 4,
|
||||
fontSize: 13,
|
||||
fontFamily: 'monospace',
|
||||
},
|
||||
fence: {
|
||||
backgroundColor: '#1F2937',
|
||||
color: '#E5E7EB',
|
||||
padding: 12,
|
||||
borderRadius: 8,
|
||||
fontSize: 13,
|
||||
fontFamily: 'monospace',
|
||||
marginVertical: 8,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
code_block: {
|
||||
backgroundColor: '#1F2937',
|
||||
color: '#E5E7EB',
|
||||
padding: 12,
|
||||
borderRadius: 8,
|
||||
fontSize: 13,
|
||||
fontFamily: 'monospace',
|
||||
marginVertical: 8,
|
||||
},
|
||||
blockquote: {
|
||||
borderLeftWidth: 3,
|
||||
borderLeftColor: '#D1D5DB',
|
||||
paddingLeft: 12,
|
||||
marginVertical: 4,
|
||||
color: '#6B7280',
|
||||
},
|
||||
bullet_list: {
|
||||
marginVertical: 4,
|
||||
},
|
||||
ordered_list: {
|
||||
marginVertical: 4,
|
||||
},
|
||||
list_item: {
|
||||
marginVertical: 2,
|
||||
},
|
||||
heading1: {
|
||||
fontSize: 22,
|
||||
fontWeight: '700',
|
||||
marginVertical: 8,
|
||||
color: '#111827',
|
||||
},
|
||||
heading2: {
|
||||
fontSize: 19,
|
||||
fontWeight: '700',
|
||||
marginVertical: 6,
|
||||
color: '#111827',
|
||||
},
|
||||
heading3: {
|
||||
fontSize: 17,
|
||||
fontWeight: '600',
|
||||
marginVertical: 4,
|
||||
color: '#111827',
|
||||
},
|
||||
link: {
|
||||
color: '#2563EB',
|
||||
textDecorationLine: 'underline',
|
||||
},
|
||||
hr: {
|
||||
backgroundColor: '#E5E7EB',
|
||||
height: 1,
|
||||
marginVertical: 12,
|
||||
},
|
||||
paragraph: {
|
||||
marginVertical: 2,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,276 @@
|
||||
import { ConnectionBadge } from '@/components/ui/ConnectionBadge';
|
||||
import { useLMStudio } from '@/hooks/useLMStudio';
|
||||
import { useAppStore } from '@/store/useAppStore';
|
||||
import { Check, ChevronDown, RefreshCw } from 'lucide-react-native';
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
FlatList,
|
||||
Modal,
|
||||
Platform,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View
|
||||
} from 'react-native';
|
||||
|
||||
export function ModelSelector() {
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const activeModelId = useAppStore(state => state.activeModelId);
|
||||
const setActiveModelId = useAppStore(state => state.setActiveModelId);
|
||||
const { availableModels, isConnected, fetchModels } = useLMStudio();
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
|
||||
const handleSelect = (modelId: string) => {
|
||||
setActiveModelId(modelId);
|
||||
setModalVisible(false);
|
||||
};
|
||||
|
||||
const handleRefresh = async () => {
|
||||
setIsRefreshing(true);
|
||||
await fetchModels();
|
||||
setIsRefreshing(false);
|
||||
};
|
||||
|
||||
const activeModelName = activeModelId || 'Select Model';
|
||||
|
||||
return (
|
||||
<>
|
||||
<TouchableOpacity
|
||||
style={styles.headerButton}
|
||||
onPress={() => setModalVisible(true)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<ConnectionBadge isConnected={isConnected} compact />
|
||||
<Text style={styles.headerText} numberOfLines={1}>
|
||||
{activeModelName}
|
||||
</Text>
|
||||
<ChevronDown size={16} color="#111827" style={styles.iconOffset} />
|
||||
</TouchableOpacity>
|
||||
|
||||
<Modal
|
||||
animationType="slide"
|
||||
transparent={true}
|
||||
visible={modalVisible}
|
||||
onRequestClose={() => setModalVisible(false)}
|
||||
>
|
||||
<TouchableOpacity
|
||||
style={styles.modalOverlay}
|
||||
activeOpacity={1}
|
||||
onPressOut={() => setModalVisible(false)}
|
||||
>
|
||||
<View style={styles.bottomSheet} onStartShouldSetResponder={() => true}>
|
||||
<View style={styles.dragHandle} />
|
||||
|
||||
<View style={styles.sheetHeader}>
|
||||
<Text style={styles.sheetTitle}>Select Model</Text>
|
||||
<TouchableOpacity
|
||||
onPress={handleRefresh}
|
||||
style={styles.refreshButton}
|
||||
disabled={isRefreshing}
|
||||
>
|
||||
{isRefreshing ? (
|
||||
<ActivityIndicator size="small" color="#6B7280" />
|
||||
) : (
|
||||
<RefreshCw size={18} color="#6B7280" />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{!isConnected && (
|
||||
<View style={styles.warningBox}>
|
||||
<Text style={styles.warningText}>
|
||||
Not connected to LM Studio. Configure server in Settings.
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{availableModels.length === 0 && isConnected && (
|
||||
<View style={styles.emptyBox}>
|
||||
<Text style={styles.emptyText}>
|
||||
No models loaded. Load a model in LM Studio first.
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<FlatList
|
||||
data={availableModels}
|
||||
keyExtractor={item => item.id}
|
||||
renderItem={({ item }) => {
|
||||
const isSelected = item.id === activeModelId;
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[styles.itemRow, isSelected && styles.itemRowSelected]}
|
||||
onPress={() => handleSelect(item.id)}
|
||||
>
|
||||
<View style={styles.itemContent}>
|
||||
<Text style={[styles.itemName, isSelected && styles.itemNameSelected]}>
|
||||
{item.id}
|
||||
</Text>
|
||||
<View style={styles.badgeRow}>
|
||||
<View style={styles.badge}>
|
||||
<Text style={styles.badgeText}>API</Text>
|
||||
</View>
|
||||
{item.owned_by && (
|
||||
<View style={[styles.badge, styles.badgeSecondary]}>
|
||||
<Text style={styles.badgeText}>{item.owned_by}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
{isSelected && <Check size={20} color="#000000" />}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
headerButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 8,
|
||||
paddingHorizontal: 12,
|
||||
borderRadius: 8,
|
||||
backgroundColor: '#F3F4F6',
|
||||
gap: 8,
|
||||
maxWidth: 280,
|
||||
},
|
||||
headerText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: '#111827',
|
||||
flexShrink: 1,
|
||||
},
|
||||
iconOffset: {
|
||||
marginTop: 2,
|
||||
},
|
||||
modalOverlay: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0,0,0,0.4)',
|
||||
justifyContent: 'flex-end',
|
||||
},
|
||||
bottomSheet: {
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderTopLeftRadius: 24,
|
||||
borderTopRightRadius: 24,
|
||||
padding: 24,
|
||||
paddingTop: 12,
|
||||
maxHeight: '80%',
|
||||
...Platform.select({
|
||||
web: {
|
||||
maxWidth: 480,
|
||||
marginHorizontal: 'auto',
|
||||
borderBottomLeftRadius: 24,
|
||||
borderBottomRightRadius: 24,
|
||||
marginBottom: 24,
|
||||
maxHeight: '60%',
|
||||
alignSelf: 'center',
|
||||
width: '100%',
|
||||
shadowColor: '#000',
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 24,
|
||||
}
|
||||
})
|
||||
},
|
||||
dragHandle: {
|
||||
width: 40,
|
||||
height: 4,
|
||||
backgroundColor: '#E5E7EB',
|
||||
borderRadius: 2,
|
||||
alignSelf: 'center',
|
||||
marginBottom: 16,
|
||||
...Platform.select({ web: { display: 'none' } })
|
||||
},
|
||||
sheetHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 16,
|
||||
},
|
||||
sheetTitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: '700',
|
||||
color: '#111827',
|
||||
},
|
||||
refreshButton: {
|
||||
padding: 8,
|
||||
borderRadius: 8,
|
||||
backgroundColor: '#F3F4F6',
|
||||
},
|
||||
warningBox: {
|
||||
backgroundColor: '#FEF3C7',
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
marginBottom: 16,
|
||||
},
|
||||
warningText: {
|
||||
fontSize: 13,
|
||||
color: '#92400E',
|
||||
textAlign: 'center',
|
||||
},
|
||||
emptyBox: {
|
||||
backgroundColor: '#F3F4F6',
|
||||
borderRadius: 8,
|
||||
padding: 16,
|
||||
marginBottom: 16,
|
||||
},
|
||||
emptyText: {
|
||||
fontSize: 14,
|
||||
color: '#6B7280',
|
||||
textAlign: 'center',
|
||||
},
|
||||
itemRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingVertical: 14,
|
||||
paddingHorizontal: 16,
|
||||
borderRadius: 12,
|
||||
marginBottom: 8,
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderWidth: 1,
|
||||
borderColor: '#E5E7EB',
|
||||
},
|
||||
itemRowSelected: {
|
||||
borderColor: '#000000',
|
||||
backgroundColor: '#F9FAFB',
|
||||
},
|
||||
itemContent: {
|
||||
flex: 1,
|
||||
paddingRight: 16,
|
||||
},
|
||||
itemName: {
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
color: '#111827',
|
||||
marginBottom: 6,
|
||||
},
|
||||
itemNameSelected: {
|
||||
color: '#000000',
|
||||
},
|
||||
badgeRow: {
|
||||
flexDirection: 'row',
|
||||
gap: 6,
|
||||
},
|
||||
badge: {
|
||||
alignSelf: 'flex-start',
|
||||
backgroundColor: '#DBEAFE',
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 3,
|
||||
borderRadius: 6,
|
||||
},
|
||||
badgeSecondary: {
|
||||
backgroundColor: '#F3F4F6',
|
||||
},
|
||||
badgeText: {
|
||||
fontSize: 11,
|
||||
fontWeight: '500',
|
||||
color: '#4B5563',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import React from 'react';
|
||||
import { StyleSheet, View } from 'react-native';
|
||||
|
||||
export function TypingIndicator() {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.bubble}>
|
||||
<View style={styles.dotsRow}>
|
||||
<View style={[styles.dot, styles.dot1]} />
|
||||
<View style={[styles.dot, styles.dot2]} />
|
||||
<View style={[styles.dot, styles.dot3]} />
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
width: '100%',
|
||||
paddingHorizontal: 16,
|
||||
marginVertical: 4,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-start',
|
||||
},
|
||||
bubble: {
|
||||
backgroundColor: '#F3F4F6',
|
||||
borderRadius: 20,
|
||||
borderTopLeftRadius: 4,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
},
|
||||
dotsRow: {
|
||||
flexDirection: 'row',
|
||||
gap: 4,
|
||||
alignItems: 'center',
|
||||
},
|
||||
dot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
backgroundColor: '#9CA3AF',
|
||||
opacity: 0.6,
|
||||
},
|
||||
dot1: { opacity: 1.0 },
|
||||
dot2: { opacity: 0.7 },
|
||||
dot3: { opacity: 0.4 },
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Href, Link } from 'expo-router';
|
||||
import { openBrowserAsync, WebBrowserPresentationStyle } from 'expo-web-browser';
|
||||
import { type ComponentProps } from 'react';
|
||||
|
||||
type Props = Omit<ComponentProps<typeof Link>, 'href'> & { href: Href & string };
|
||||
|
||||
export function ExternalLink({ href, ...rest }: Props) {
|
||||
return (
|
||||
<Link
|
||||
target="_blank"
|
||||
{...rest}
|
||||
href={href}
|
||||
onPress={async (event) => {
|
||||
if (process.env.EXPO_OS !== 'web') {
|
||||
// Prevent the default behavior of linking to the default browser on native.
|
||||
event.preventDefault();
|
||||
// Open the link in an in-app browser.
|
||||
await openBrowserAsync(href, {
|
||||
presentationStyle: WebBrowserPresentationStyle.AUTOMATIC,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { BottomTabBarButtonProps } from '@react-navigation/bottom-tabs';
|
||||
import { PlatformPressable } from '@react-navigation/elements';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
|
||||
export function HapticTab(props: BottomTabBarButtonProps) {
|
||||
return (
|
||||
<PlatformPressable
|
||||
{...props}
|
||||
onPressIn={(ev) => {
|
||||
if (process.env.EXPO_OS === 'ios') {
|
||||
// Add a soft haptic feedback when pressing down on the tabs.
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
}
|
||||
props.onPressIn?.(ev);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import Animated from 'react-native-reanimated';
|
||||
|
||||
export function HelloWave() {
|
||||
return (
|
||||
<Animated.Text
|
||||
style={{
|
||||
fontSize: 28,
|
||||
lineHeight: 32,
|
||||
marginTop: -6,
|
||||
animationName: {
|
||||
'50%': { transform: [{ rotate: '25deg' }] },
|
||||
},
|
||||
animationIterationCount: 4,
|
||||
animationDuration: '300ms',
|
||||
}}>
|
||||
👋
|
||||
</Animated.Text>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import type { PropsWithChildren, ReactElement } from 'react';
|
||||
import { StyleSheet } from 'react-native';
|
||||
import Animated, {
|
||||
interpolate,
|
||||
useAnimatedRef,
|
||||
useAnimatedStyle,
|
||||
useScrollOffset,
|
||||
} from 'react-native-reanimated';
|
||||
|
||||
import { ThemedView } from '@/components/themed-view';
|
||||
import { useColorScheme } from '@/hooks/use-color-scheme';
|
||||
import { useThemeColor } from '@/hooks/use-theme-color';
|
||||
|
||||
const HEADER_HEIGHT = 250;
|
||||
|
||||
type Props = PropsWithChildren<{
|
||||
headerImage: ReactElement;
|
||||
headerBackgroundColor: { dark: string; light: string };
|
||||
}>;
|
||||
|
||||
export default function ParallaxScrollView({
|
||||
children,
|
||||
headerImage,
|
||||
headerBackgroundColor,
|
||||
}: Props) {
|
||||
const backgroundColor = useThemeColor({}, 'background');
|
||||
const colorScheme = useColorScheme() ?? 'light';
|
||||
const scrollRef = useAnimatedRef<Animated.ScrollView>();
|
||||
const scrollOffset = useScrollOffset(scrollRef);
|
||||
const headerAnimatedStyle = useAnimatedStyle(() => {
|
||||
return {
|
||||
transform: [
|
||||
{
|
||||
translateY: interpolate(
|
||||
scrollOffset.value,
|
||||
[-HEADER_HEIGHT, 0, HEADER_HEIGHT],
|
||||
[-HEADER_HEIGHT / 2, 0, HEADER_HEIGHT * 0.75]
|
||||
),
|
||||
},
|
||||
{
|
||||
scale: interpolate(scrollOffset.value, [-HEADER_HEIGHT, 0, HEADER_HEIGHT], [2, 1, 1]),
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<Animated.ScrollView
|
||||
ref={scrollRef}
|
||||
style={{ backgroundColor, flex: 1 }}
|
||||
scrollEventThrottle={16}>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.header,
|
||||
{ backgroundColor: headerBackgroundColor[colorScheme] },
|
||||
headerAnimatedStyle,
|
||||
]}>
|
||||
{headerImage}
|
||||
</Animated.View>
|
||||
<ThemedView style={styles.content}>{children}</ThemedView>
|
||||
</Animated.ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
header: {
|
||||
height: HEADER_HEIGHT,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
padding: 32,
|
||||
gap: 16,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { StyleSheet, Text, type TextProps } from 'react-native';
|
||||
|
||||
import { useThemeColor } from '@/hooks/use-theme-color';
|
||||
|
||||
export type ThemedTextProps = TextProps & {
|
||||
lightColor?: string;
|
||||
darkColor?: string;
|
||||
type?: 'default' | 'title' | 'defaultSemiBold' | 'subtitle' | 'link';
|
||||
};
|
||||
|
||||
export function ThemedText({
|
||||
style,
|
||||
lightColor,
|
||||
darkColor,
|
||||
type = 'default',
|
||||
...rest
|
||||
}: ThemedTextProps) {
|
||||
const color = useThemeColor({ light: lightColor, dark: darkColor }, 'text');
|
||||
|
||||
return (
|
||||
<Text
|
||||
style={[
|
||||
{ color },
|
||||
type === 'default' ? styles.default : undefined,
|
||||
type === 'title' ? styles.title : undefined,
|
||||
type === 'defaultSemiBold' ? styles.defaultSemiBold : undefined,
|
||||
type === 'subtitle' ? styles.subtitle : undefined,
|
||||
type === 'link' ? styles.link : undefined,
|
||||
style,
|
||||
]}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
default: {
|
||||
fontSize: 16,
|
||||
lineHeight: 24,
|
||||
},
|
||||
defaultSemiBold: {
|
||||
fontSize: 16,
|
||||
lineHeight: 24,
|
||||
fontWeight: '600',
|
||||
},
|
||||
title: {
|
||||
fontSize: 32,
|
||||
fontWeight: 'bold',
|
||||
lineHeight: 32,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
link: {
|
||||
lineHeight: 30,
|
||||
fontSize: 16,
|
||||
color: '#0a7ea4',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import { View, type ViewProps } from 'react-native';
|
||||
|
||||
import { useThemeColor } from '@/hooks/use-theme-color';
|
||||
|
||||
export type ThemedViewProps = ViewProps & {
|
||||
lightColor?: string;
|
||||
darkColor?: string;
|
||||
};
|
||||
|
||||
export function ThemedView({ style, lightColor, darkColor, ...otherProps }: ThemedViewProps) {
|
||||
const backgroundColor = useThemeColor({ light: lightColor, dark: darkColor }, 'background');
|
||||
|
||||
return <View style={[{ backgroundColor }, style]} {...otherProps} />;
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { Colors } from '@/constants/theme';
|
||||
import { useColorScheme } from '@/hooks/use-color-scheme';
|
||||
import React from 'react';
|
||||
import { ActivityIndicator, Pressable, StyleSheet, Text, TextStyle, ViewStyle } from 'react-native';
|
||||
|
||||
interface ButtonProps {
|
||||
title: string;
|
||||
onPress: () => void;
|
||||
variant?: 'primary' | 'secondary' | 'outline' | 'ghost';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
loading?: boolean;
|
||||
disabled?: boolean;
|
||||
style?: ViewStyle;
|
||||
textStyle?: TextStyle;
|
||||
icon?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function Button({
|
||||
title,
|
||||
onPress,
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
loading = false,
|
||||
disabled = false,
|
||||
style,
|
||||
textStyle,
|
||||
icon
|
||||
}: ButtonProps) {
|
||||
const colorScheme = useColorScheme();
|
||||
const theme = Colors[colorScheme ?? 'dark'];
|
||||
|
||||
const getBackgroundColor = () => {
|
||||
if (disabled) return theme.surfaceHighlight;
|
||||
switch (variant) {
|
||||
case 'primary': return theme.primary;
|
||||
case 'secondary': return theme.surfaceHighlight;
|
||||
case 'outline': return 'transparent';
|
||||
case 'ghost': return 'transparent';
|
||||
default: return theme.primary;
|
||||
}
|
||||
};
|
||||
|
||||
const getTextColor = () => {
|
||||
if (disabled) return theme.textSecondary;
|
||||
switch (variant) {
|
||||
case 'primary': return theme.background; // Black text on white bg
|
||||
case 'secondary': return theme.text;
|
||||
case 'outline': return theme.text;
|
||||
case 'ghost': return theme.textSecondary;
|
||||
default: return theme.background;
|
||||
}
|
||||
};
|
||||
|
||||
const getBorder = () => {
|
||||
if (variant === 'outline') return { borderWidth: 1, borderColor: theme.border };
|
||||
return {};
|
||||
};
|
||||
|
||||
const getPadding = () => {
|
||||
switch (size) {
|
||||
case 'sm': return { paddingVertical: 8, paddingHorizontal: 12 };
|
||||
case 'lg': return { paddingVertical: 16, paddingHorizontal: 24 };
|
||||
default: return { paddingVertical: 12, paddingHorizontal: 20 };
|
||||
}
|
||||
};
|
||||
|
||||
const getFontSize = () => {
|
||||
switch (size) {
|
||||
case 'sm': return 13;
|
||||
case 'lg': return 17;
|
||||
default: return 15;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
disabled={disabled || loading}
|
||||
style={({ pressed }) => [
|
||||
styles.button,
|
||||
{ backgroundColor: getBackgroundColor() },
|
||||
getBorder(),
|
||||
getPadding(),
|
||||
{ opacity: pressed ? 0.8 : 1 },
|
||||
style
|
||||
]}
|
||||
>
|
||||
{loading ? (
|
||||
<ActivityIndicator color={getTextColor()} />
|
||||
) : (
|
||||
<>
|
||||
{icon}
|
||||
<Text style={[
|
||||
styles.text,
|
||||
{ color: getTextColor(), fontSize: getFontSize() },
|
||||
icon ? { marginLeft: 8 } : {},
|
||||
textStyle
|
||||
]}>
|
||||
{title}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
button: {
|
||||
borderRadius: 12,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
text: {
|
||||
fontWeight: '600',
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import React from 'react';
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
|
||||
interface ConnectionBadgeProps {
|
||||
isConnected: boolean;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
export function ConnectionBadge({ isConnected, compact }: ConnectionBadgeProps) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={[styles.dot, isConnected ? styles.dotConnected : styles.dotDisconnected]} />
|
||||
{!compact && (
|
||||
<Text style={[styles.text, isConnected ? styles.textConnected : styles.textDisconnected]}>
|
||||
{isConnected ? 'Connected' : 'Disconnected'}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
},
|
||||
dot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
},
|
||||
dotConnected: {
|
||||
backgroundColor: '#22C55E',
|
||||
},
|
||||
dotDisconnected: {
|
||||
backgroundColor: '#EF4444',
|
||||
},
|
||||
text: {
|
||||
fontSize: 12,
|
||||
fontWeight: '500',
|
||||
},
|
||||
textConnected: {
|
||||
color: '#22C55E',
|
||||
},
|
||||
textDisconnected: {
|
||||
color: '#EF4444',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Colors } from '@/constants/theme';
|
||||
import { useColorScheme } from '@/hooks/use-color-scheme';
|
||||
import { StyleSheet, View, ViewStyle } from 'react-native';
|
||||
|
||||
interface GlassCardProps {
|
||||
children: React.ReactNode;
|
||||
style?: ViewStyle;
|
||||
variant?: 'default' | 'highlight';
|
||||
}
|
||||
|
||||
export function GlassCard({ children, style, variant = 'default' }: GlassCardProps) {
|
||||
const colorScheme = useColorScheme();
|
||||
const theme = Colors[colorScheme ?? 'dark'];
|
||||
|
||||
return (
|
||||
<View style={[
|
||||
styles.card,
|
||||
{
|
||||
backgroundColor: variant === 'default' ? theme.surface : theme.surfaceHighlight,
|
||||
borderColor: theme.border,
|
||||
},
|
||||
style
|
||||
]}>
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
borderRadius: 16,
|
||||
borderWidth: 1,
|
||||
padding: 16,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { Colors } from '@/constants/theme';
|
||||
import { useColorScheme } from '@/hooks/use-color-scheme';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import { ScrollView, StyleSheet, View, ViewStyle } from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
||||
interface ScreenWrapperProps {
|
||||
children: React.ReactNode;
|
||||
style?: ViewStyle;
|
||||
contentContainerStyle?: ViewStyle;
|
||||
scrollable?: boolean;
|
||||
}
|
||||
|
||||
export function ScreenWrapper({
|
||||
children,
|
||||
style,
|
||||
contentContainerStyle,
|
||||
scrollable = false
|
||||
}: ScreenWrapperProps) {
|
||||
const colorScheme = useColorScheme();
|
||||
const theme = Colors[colorScheme ?? 'dark'];
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
const containerStyle = [
|
||||
styles.container,
|
||||
{
|
||||
backgroundColor: theme.background,
|
||||
paddingTop: insets.top,
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
},
|
||||
style
|
||||
];
|
||||
|
||||
if (scrollable) {
|
||||
return (
|
||||
<View style={{ flex: 1, backgroundColor: theme.background }}>
|
||||
<StatusBar style={colorScheme === 'dark' ? 'light' : 'dark'} />
|
||||
<ScrollView
|
||||
style={containerStyle}
|
||||
contentContainerStyle={[
|
||||
{ paddingBottom: insets.bottom + 20 },
|
||||
contentContainerStyle
|
||||
]}
|
||||
>
|
||||
{children}
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={containerStyle}>
|
||||
<StatusBar style={colorScheme === 'dark' ? 'light' : 'dark'} />
|
||||
<View style={[{ flex: 1, paddingBottom: insets.bottom }, contentContainerStyle]}>
|
||||
{children}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { PropsWithChildren, useState } from 'react';
|
||||
import { StyleSheet, TouchableOpacity } from 'react-native';
|
||||
|
||||
import { ThemedText } from '@/components/themed-text';
|
||||
import { ThemedView } from '@/components/themed-view';
|
||||
import { IconSymbol } from '@/components/ui/icon-symbol';
|
||||
import { Colors } from '@/constants/theme';
|
||||
import { useColorScheme } from '@/hooks/use-color-scheme';
|
||||
|
||||
export function Collapsible({ children, title }: PropsWithChildren & { title: string }) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const theme = useColorScheme() ?? 'light';
|
||||
|
||||
return (
|
||||
<ThemedView>
|
||||
<TouchableOpacity
|
||||
style={styles.heading}
|
||||
onPress={() => setIsOpen((value) => !value)}
|
||||
activeOpacity={0.8}>
|
||||
<IconSymbol
|
||||
name="chevron.right"
|
||||
size={18}
|
||||
weight="medium"
|
||||
color={theme === 'light' ? Colors.light.icon : Colors.dark.icon}
|
||||
style={{ transform: [{ rotate: isOpen ? '90deg' : '0deg' }] }}
|
||||
/>
|
||||
|
||||
<ThemedText type="defaultSemiBold">{title}</ThemedText>
|
||||
</TouchableOpacity>
|
||||
{isOpen && <ThemedView style={styles.content}>{children}</ThemedView>}
|
||||
</ThemedView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
heading: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
},
|
||||
content: {
|
||||
marginTop: 6,
|
||||
marginLeft: 24,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { SymbolView, SymbolViewProps, SymbolWeight } from 'expo-symbols';
|
||||
import { StyleProp, ViewStyle } from 'react-native';
|
||||
|
||||
export function IconSymbol({
|
||||
name,
|
||||
size = 24,
|
||||
color,
|
||||
style,
|
||||
weight = 'regular',
|
||||
}: {
|
||||
name: SymbolViewProps['name'];
|
||||
size?: number;
|
||||
color: string;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
weight?: SymbolWeight;
|
||||
}) {
|
||||
return (
|
||||
<SymbolView
|
||||
weight={weight}
|
||||
tintColor={color}
|
||||
resizeMode="scaleAspectFit"
|
||||
name={name}
|
||||
style={[
|
||||
{
|
||||
width: size,
|
||||
height: size,
|
||||
},
|
||||
style,
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
// Fallback for using MaterialIcons on Android and web.
|
||||
|
||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||
import { SymbolWeight, SymbolViewProps } from 'expo-symbols';
|
||||
import { ComponentProps } from 'react';
|
||||
import { OpaqueColorValue, type StyleProp, type TextStyle } from 'react-native';
|
||||
|
||||
type IconMapping = Record<SymbolViewProps['name'], ComponentProps<typeof MaterialIcons>['name']>;
|
||||
type IconSymbolName = keyof typeof MAPPING;
|
||||
|
||||
/**
|
||||
* Add your SF Symbols to Material Icons mappings here.
|
||||
* - see Material Icons in the [Icons Directory](https://icons.expo.fyi).
|
||||
* - see SF Symbols in the [SF Symbols](https://developer.apple.com/sf-symbols/) app.
|
||||
*/
|
||||
const MAPPING = {
|
||||
'house.fill': 'home',
|
||||
'paperplane.fill': 'send',
|
||||
'chevron.left.forwardslash.chevron.right': 'code',
|
||||
'chevron.right': 'chevron-right',
|
||||
} as IconMapping;
|
||||
|
||||
/**
|
||||
* An icon component that uses native SF Symbols on iOS, and Material Icons on Android and web.
|
||||
* This ensures a consistent look across platforms, and optimal resource usage.
|
||||
* Icon `name`s are based on SF Symbols and require manual mapping to Material Icons.
|
||||
*/
|
||||
export function IconSymbol({
|
||||
name,
|
||||
size = 24,
|
||||
color,
|
||||
style,
|
||||
}: {
|
||||
name: IconSymbolName;
|
||||
size?: number;
|
||||
color: string | OpaqueColorValue;
|
||||
style?: StyleProp<TextStyle>;
|
||||
weight?: SymbolWeight;
|
||||
}) {
|
||||
return <MaterialIcons color={color} size={size} name={MAPPING[name]} style={style} />;
|
||||
}
|
||||
Reference in new issue
Block a user