feat: SAPIENS Mobile App - Initial commit

React Native mobile application for SAPIENS news platform.
Consolidated all previous history into single commit.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
jungwoo choi
2025-10-23 14:30:25 +09:00
commit 919afe56f2
1516 changed files with 64072 additions and 0 deletions

View File

@ -0,0 +1,138 @@
import { View, Text, TouchableOpacity, Image, StyleSheet } from 'react-native';
interface ArticleCardProps {
id: string;
title: string;
summary: string;
thumbnail: string;
publishedAt: Date;
timeAgo?: string;
outletName: string;
category: string;
onClick: (id: string) => void;
className?: string;
isAd?: boolean;
tags?: string[];
index?: number;
}
export default function ArticleCard({
id,
title,
summary,
thumbnail,
timeAgo,
onClick,
isAd = false,
index = 0,
}: ArticleCardProps) {
// Generate stable random data based on index and id
const hash = id.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0);
const baseMinutes = index * 5;
const randomOffset = (hash % 5) + 1;
const minutesAgo = baseMinutes + randomOffset;
return (
<TouchableOpacity
style={styles.card}
onPress={() => onClick(id)}
activeOpacity={0.7}
>
{/* Thumbnail */}
<View style={styles.thumbnailContainer}>
<Image
source={{ uri: thumbnail }}
style={styles.thumbnail}
defaultSource={require('../assets/images/partial-react-logo.png')}
/>
{isAd && (
<View style={styles.adBadge}>
<Text style={styles.adText}>Ad</Text>
</View>
)}
{!isAd && (
<View style={styles.timeBadge}>
<Text style={styles.timeText}>
{timeAgo || `${minutesAgo} min ago`}
</Text>
</View>
)}
</View>
{/* Content */}
<View style={styles.content}>
<Text style={styles.title} numberOfLines={2}>
{title}
</Text>
<Text style={styles.summary} numberOfLines={2}>
{summary}
</Text>
</View>
</TouchableOpacity>
);
}
const styles = StyleSheet.create({
card: {
backgroundColor: '#fff',
borderRadius: 12,
marginBottom: 12,
overflow: 'hidden',
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 3,
},
thumbnailContainer: {
width: '100%',
aspectRatio: 21 / 9,
position: 'relative',
},
thumbnail: {
width: '100%',
height: '100%',
},
adBadge: {
position: 'absolute',
top: 8,
left: 8,
backgroundColor: '#f59e0b',
paddingHorizontal: 8,
paddingVertical: 4,
borderRadius: 4,
},
adText: {
color: '#fff',
fontSize: 10,
fontWeight: '600',
},
timeBadge: {
position: 'absolute',
bottom: 8,
left: 8,
backgroundColor: 'rgba(0, 0, 0, 0.6)',
paddingHorizontal: 8,
paddingVertical: 4,
borderRadius: 4,
},
timeText: {
color: '#fff',
fontSize: 10,
},
content: {
padding: 12,
},
title: {
fontSize: 16,
fontWeight: '600',
color: '#000',
marginBottom: 6,
lineHeight: 22,
},
summary: {
fontSize: 14,
color: '#666',
lineHeight: 20,
},
});

View File

@ -0,0 +1,107 @@
import { View, Text, TouchableOpacity, StyleSheet } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
interface BottomTabBarProps {
activeTab: 'people' | 'topics' | 'companies';
onTabChange: (tab: 'people' | 'topics' | 'companies') => void;
showBackButton?: boolean;
onBack?: () => void;
}
export default function BottomTabBar({ activeTab, onTabChange, showBackButton, onBack }: BottomTabBarProps) {
const tabs = [
{ id: 'people' as const, label: 'People', icon: 'person-outline' as const },
{ id: 'topics' as const, label: 'Topics', icon: 'newspaper-outline' as const },
{ id: 'companies' as const, label: 'Companies', icon: 'business-outline' as const },
];
return (
<View style={styles.container}>
{showBackButton && onBack ? (
<TouchableOpacity
style={styles.backTab}
onPress={onBack}
activeOpacity={0.7}
>
<Ionicons
name="arrow-back"
size={24}
color="#000"
style={styles.icon}
/>
<Text style={styles.backLabel}>Back</Text>
</TouchableOpacity>
) : null}
{tabs.map((tab) => (
<TouchableOpacity
key={tab.id}
style={[
styles.tab,
activeTab === tab.id && styles.activeTab,
]}
onPress={() => onTabChange(tab.id)}
activeOpacity={0.7}
>
<Ionicons
name={tab.icon}
size={24}
color={activeTab === tab.id ? '#000' : '#666'}
style={styles.icon}
/>
<Text
style={[
styles.label,
activeTab === tab.id && styles.activeLabel,
]}
>
{tab.label}
</Text>
</TouchableOpacity>
))}
</View>
);
}
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
backgroundColor: '#fff',
borderTopWidth: 1,
borderTopColor: '#e0e0e0',
paddingBottom: 8,
paddingTop: 8,
},
tab: {
flex: 1,
alignItems: 'center',
paddingVertical: 8,
},
activeTab: {
backgroundColor: '#f0f0f0',
borderRadius: 8,
},
icon: {
marginBottom: 4,
},
label: {
fontSize: 12,
color: '#666',
},
activeLabel: {
color: '#000',
fontWeight: '600',
},
backTab: {
flex: 1,
alignItems: 'center',
paddingVertical: 8,
backgroundColor: '#f0f0f0',
borderRadius: 8,
marginHorizontal: 4,
},
backLabel: {
fontSize: 12,
color: '#000',
fontWeight: '600',
},
});

View File

@ -0,0 +1,452 @@
import { useState, useEffect, useRef } from 'react';
import {
View,
Text,
Modal,
TouchableOpacity,
TextInput,
FlatList,
StyleSheet,
Animated,
Dimensions,
KeyboardAvoidingView,
Platform,
ActivityIndicator,
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { useQuery, useMutation } from '@tanstack/react-query';
import { queryClient } from '@/lib/queryClient';
import { API_BASE_URL } from '@/lib/config';
interface Comment {
id: string;
articleId: string;
nickname: string;
content: string;
createdAt: string;
}
interface CommentsDrawerProps {
articleId: string;
isOpen: boolean;
onClose: () => void;
}
const SCREEN_HEIGHT = Dimensions.get('window').height;
const DRAWER_HEIGHT = SCREEN_HEIGHT * 0.6;
export default function CommentsDrawer({
articleId,
isOpen,
onClose,
}: CommentsDrawerProps) {
const [nickname, setNickname] = useState('');
const [commentContent, setCommentContent] = useState('');
const slideAnim = useRef(new Animated.Value(DRAWER_HEIGHT)).current;
const opacityAnim = useRef(new Animated.Value(0)).current;
// Load saved nickname from AsyncStorage
useEffect(() => {
loadNickname();
}, []);
const loadNickname = async () => {
try {
const saved = await AsyncStorage.getItem('commentNickname');
if (saved) {
setNickname(saved);
}
} catch (error) {
console.error('Failed to load nickname:', error);
}
};
// Animate drawer on open/close
useEffect(() => {
if (isOpen) {
Animated.parallel([
Animated.timing(slideAnim, {
toValue: 0,
duration: 300,
useNativeDriver: true,
}),
Animated.timing(opacityAnim, {
toValue: 1,
duration: 200,
useNativeDriver: true,
}),
]).start();
} else {
Animated.parallel([
Animated.timing(slideAnim, {
toValue: DRAWER_HEIGHT,
duration: 300,
useNativeDriver: true,
}),
Animated.timing(opacityAnim, {
toValue: 0,
duration: 200,
useNativeDriver: true,
}),
]).start();
}
}, [isOpen]);
// Fetch comments
const { data: commentsData, isLoading } = useQuery<{
comments: Comment[];
total: number;
}>({
queryKey: ['/api/v1/comments', articleId],
queryFn: async () => {
const response = await fetch(`${API_BASE_URL}/comments?articleId=${articleId}`);
return response.json();
},
enabled: isOpen && !!articleId,
});
// Create comment mutation
const createComment = useMutation({
mutationFn: async (data: { content: string; nickname: string }) => {
const response = await fetch(`${API_BASE_URL}/comments`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
articleId,
content: data.content,
nickname: data.nickname,
}),
});
return response.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['/api/v1/comments', articleId] });
queryClient.invalidateQueries({
queryKey: ['/api/v1/articles', articleId, 'comment-count'],
});
setCommentContent('');
},
});
const handleSubmit = async () => {
if (!commentContent.trim() || !nickname.trim()) return;
try {
await AsyncStorage.setItem('commentNickname', nickname);
createComment.mutate({
content: commentContent,
nickname: nickname,
});
} catch (error) {
console.error('Failed to save nickname:', error);
}
};
const formatTimeAgo = (dateString: string) => {
const date = new Date(dateString);
const now = new Date();
const seconds = Math.floor((now.getTime() - date.getTime()) / 1000);
if (seconds < 60) return 'just now';
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
if (days < 7) return `${days}d ago`;
const weeks = Math.floor(days / 7);
if (weeks < 4) return `${weeks}w ago`;
const months = Math.floor(days / 30);
if (months < 12) return `${months}mo ago`;
const years = Math.floor(days / 365);
return `${years}y ago`;
};
const comments = commentsData?.comments || [];
const total = commentsData?.total || 0;
if (!isOpen) return null;
return (
<Modal
visible={isOpen}
transparent
animationType="none"
onRequestClose={onClose}
>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={styles.container}
>
{/* Backdrop */}
<Animated.View
style={[styles.backdrop, { opacity: opacityAnim }]}
>
<TouchableOpacity
style={styles.backdropTouchable}
activeOpacity={1}
onPress={onClose}
/>
</Animated.View>
{/* Drawer */}
<Animated.View
style={[
styles.drawer,
{
transform: [{ translateY: slideAnim }],
},
]}
>
{/* Header */}
<View style={styles.header}>
<Text style={styles.title}>Comments ({total})</Text>
<TouchableOpacity onPress={onClose} style={styles.closeButton}>
<Ionicons name="close" size={24} color="#333" />
</TouchableOpacity>
</View>
{/* Comments List */}
<View style={styles.commentsContainer}>
{isLoading ? (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color="#007AFF" />
</View>
) : comments.length === 0 ? (
<View style={styles.emptyContainer}>
<Text style={styles.emptyText}>
No comments yet. Be the first to comment!
</Text>
</View>
) : (
<FlatList
data={comments}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<View style={styles.commentCard}>
<View style={styles.commentHeader}>
<View style={styles.avatar}>
<Text style={styles.avatarText}>
{item.nickname.charAt(0).toUpperCase()}
</Text>
</View>
<View style={styles.commentMeta}>
<Text style={styles.nickname}>{item.nickname}</Text>
<Text style={styles.timeAgo}>
{formatTimeAgo(item.createdAt)}
</Text>
</View>
</View>
<Text style={styles.commentContent}>{item.content}</Text>
</View>
)}
contentContainerStyle={styles.listContent}
showsVerticalScrollIndicator={false}
/>
)}
</View>
{/* Input Form */}
<View style={styles.inputContainer}>
{!nickname && (
<TextInput
style={styles.nicknameInput}
placeholder="Your nickname"
value={nickname}
onChangeText={setNickname}
maxLength={50}
placeholderTextColor="#999"
/>
)}
<View style={styles.commentInputRow}>
<TextInput
style={styles.commentInput}
placeholder="Write a comment..."
value={commentContent}
onChangeText={setCommentContent}
maxLength={500}
multiline
numberOfLines={3}
placeholderTextColor="#999"
/>
<TouchableOpacity
style={[
styles.sendButton,
(!commentContent.trim() || !nickname.trim() || createComment.isPending) &&
styles.sendButtonDisabled,
]}
onPress={handleSubmit}
disabled={
!commentContent.trim() ||
!nickname.trim() ||
createComment.isPending
}
>
{createComment.isPending ? (
<ActivityIndicator size="small" color="#fff" />
) : (
<Ionicons name="send" size={18} color="#fff" />
)}
</TouchableOpacity>
</View>
</View>
</Animated.View>
</KeyboardAvoidingView>
</Modal>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
backdrop: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0, 0, 0, 0.3)',
},
backdropTouchable: {
flex: 1,
},
drawer: {
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
height: DRAWER_HEIGHT,
backgroundColor: '#fff',
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
shadowColor: '#000',
shadowOffset: { width: 0, height: -2 },
shadowOpacity: 0.1,
shadowRadius: 8,
elevation: 5,
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: 16,
paddingVertical: 16,
borderBottomWidth: 1,
borderBottomColor: '#e0e0e0',
},
title: {
fontSize: 18,
fontWeight: '600',
color: '#000',
},
closeButton: {
padding: 4,
},
commentsContainer: {
flex: 1,
},
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
emptyContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: 32,
},
emptyText: {
fontSize: 14,
color: '#666',
textAlign: 'center',
},
listContent: {
padding: 16,
},
commentCard: {
backgroundColor: '#f5f5f5',
borderRadius: 12,
padding: 12,
marginBottom: 12,
},
commentHeader: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 8,
},
avatar: {
width: 32,
height: 32,
borderRadius: 16,
backgroundColor: '#007AFF20',
justifyContent: 'center',
alignItems: 'center',
marginRight: 8,
},
avatarText: {
fontSize: 14,
fontWeight: '600',
color: '#007AFF',
},
commentMeta: {
flex: 1,
},
nickname: {
fontSize: 14,
fontWeight: '600',
color: '#000',
marginBottom: 2,
},
timeAgo: {
fontSize: 12,
color: '#666',
},
commentContent: {
fontSize: 14,
color: '#333',
lineHeight: 20,
},
inputContainer: {
borderTopWidth: 1,
borderTopColor: '#e0e0e0',
padding: 16,
backgroundColor: '#fff',
},
nicknameInput: {
borderWidth: 1,
borderColor: '#e0e0e0',
borderRadius: 8,
paddingHorizontal: 12,
paddingVertical: 10,
fontSize: 14,
marginBottom: 12,
color: '#000',
},
commentInputRow: {
flexDirection: 'row',
gap: 8,
},
commentInput: {
flex: 1,
borderWidth: 1,
borderColor: '#e0e0e0',
borderRadius: 8,
paddingHorizontal: 12,
paddingVertical: 10,
fontSize: 14,
maxHeight: 80,
color: '#000',
},
sendButton: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: '#007AFF',
justifyContent: 'center',
alignItems: 'center',
},
sendButtonDisabled: {
backgroundColor: '#ccc',
},
});

View File

@ -0,0 +1,135 @@
import { View, Text, Modal, TouchableOpacity, StyleSheet } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
interface LanguageModalProps {
visible: boolean;
onClose: () => void;
currentLanguage: string;
onSelectLanguage: (language: string) => void;
}
const languages = [
{ code: 'ko', name: '한국어', flag: '🇰🇷' },
{ code: 'en', name: 'English', flag: '🇺🇸' },
{ code: 'ja', name: '日本語', flag: '🇯🇵' },
{ code: 'zh-cn', name: '简体中文', flag: '🇨🇳' },
{ code: 'zh-tw', name: '繁體中文', flag: '🇹🇼' },
{ code: 'fr', name: 'Français', flag: '🇫🇷' },
{ code: 'de', name: 'Deutsch', flag: '🇩🇪' },
{ code: 'es', name: 'Español', flag: '🇪🇸' },
{ code: 'it', name: 'Italiano', flag: '🇮🇹' },
];
export default function LanguageModal({ visible, onClose, currentLanguage, onSelectLanguage }: LanguageModalProps) {
return (
<Modal
visible={visible}
transparent
animationType="fade"
onRequestClose={onClose}
>
<TouchableOpacity
style={styles.overlay}
activeOpacity={1}
onPress={onClose}
>
<View style={styles.modalContainer}>
<View style={styles.header}>
<Text style={styles.title}>Select Language</Text>
<TouchableOpacity onPress={onClose}>
<Ionicons name="close" size={24} color="#333" />
</TouchableOpacity>
</View>
<View style={styles.languageList}>
{languages.map((lang) => (
<TouchableOpacity
key={lang.code}
style={[
styles.languageItem,
currentLanguage === lang.code && styles.languageItemActive,
]}
onPress={() => {
onSelectLanguage(lang.code);
onClose();
}}
>
<View style={styles.languageInfo}>
<Text style={styles.flag}>{lang.flag}</Text>
<Text style={[
styles.languageName,
currentLanguage === lang.code && styles.languageNameActive,
]}>
{lang.name}
</Text>
</View>
{currentLanguage === lang.code && (
<Ionicons name="checkmark" size={20} color="#007AFF" />
)}
</TouchableOpacity>
))}
</View>
</View>
</TouchableOpacity>
</Modal>
);
}
const styles = StyleSheet.create({
overlay: {
flex: 1,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
justifyContent: 'center',
alignItems: 'center',
},
modalContainer: {
backgroundColor: '#fff',
borderRadius: 12,
width: '85%',
maxWidth: 400,
maxHeight: '70%',
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
padding: 16,
borderBottomWidth: 1,
borderBottomColor: '#e0e0e0',
},
title: {
fontSize: 18,
fontWeight: '600',
color: '#000',
},
languageList: {
padding: 8,
},
languageItem: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
padding: 12,
borderRadius: 8,
marginBottom: 4,
},
languageItemActive: {
backgroundColor: '#f0f0f0',
},
languageInfo: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
},
flag: {
fontSize: 24,
},
languageName: {
fontSize: 16,
color: '#333',
},
languageNameActive: {
fontWeight: '600',
color: '#007AFF',
},
});

View File

@ -0,0 +1,112 @@
import { View, Text, TouchableOpacity, Image, StyleSheet } from 'react-native';
interface OutletCardProps {
id: string;
name: string;
description: string;
category: string;
focusSubject: string;
avatar?: string;
articleCount: number;
onPress: (id: string) => void;
}
export default function OutletCard({
id,
name,
description,
avatar,
articleCount,
onPress,
}: OutletCardProps) {
const getInitials = (name: string) => {
return name
.split(' ')
.map(word => word[0])
.join('')
.toUpperCase()
.slice(0, 2);
};
return (
<TouchableOpacity
style={styles.card}
onPress={() => onPress(id)}
activeOpacity={0.7}
>
<View style={styles.content}>
{avatar ? (
<Image
source={{ uri: avatar }}
style={styles.avatar}
defaultSource={require('../assets/images/partial-react-logo.png')}
/>
) : (
<View style={styles.avatarFallback}>
<Text style={styles.avatarText}>{getInitials(name)}</Text>
</View>
)}
<View style={styles.textContainer}>
<Text style={styles.name} numberOfLines={1}>
{name}
</Text>
<Text style={styles.description} numberOfLines={1}>
{description}
</Text>
</View>
</View>
</TouchableOpacity>
);
}
const styles = StyleSheet.create({
card: {
backgroundColor: '#fff',
borderRadius: 12,
padding: 12,
marginBottom: 8,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.1,
shadowRadius: 2,
elevation: 2,
},
content: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
},
avatar: {
width: 48,
height: 48,
borderRadius: 24,
},
avatarFallback: {
width: 48,
height: 48,
borderRadius: 24,
backgroundColor: '#e0e0e0',
justifyContent: 'center',
alignItems: 'center',
},
avatarText: {
fontSize: 14,
fontWeight: '600',
color: '#333',
},
textContainer: {
flex: 1,
minWidth: 0,
},
name: {
fontSize: 16,
fontWeight: '600',
color: '#000',
marginBottom: 4,
},
description: {
fontSize: 14,
color: '#666',
},
});

View File

@ -0,0 +1,126 @@
import { useState, useEffect } from 'react';
import { View, Text, TextInput, TouchableOpacity, StyleSheet, Animated, Dimensions } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
const { width } = Dimensions.get('window');
interface SearchModalProps {
visible: boolean;
onClose: () => void;
}
export default function SearchModal({ visible, onClose }: SearchModalProps) {
const [searchText, setSearchText] = useState('');
const [slideAnim] = useState(new Animated.Value(width));
useEffect(() => {
if (visible) {
Animated.spring(slideAnim, {
toValue: 0,
useNativeDriver: true,
tension: 65,
friction: 11,
}).start();
} else {
Animated.timing(slideAnim, {
toValue: width,
duration: 250,
useNativeDriver: true,
}).start();
}
}, [visible]);
if (!visible) return null;
const handleClose = () => {
Animated.timing(slideAnim, {
toValue: width,
duration: 250,
useNativeDriver: true,
}).start(() => {
onClose();
});
};
return (
<Animated.View
style={[
styles.container,
{
transform: [{ translateX: slideAnim }],
},
]}
>
<View style={styles.searchBar}>
<Ionicons name="search-outline" size={20} color="#666" style={styles.searchIcon} />
<TextInput
style={styles.searchInput}
placeholder="Search outlets, articles..."
value={searchText}
onChangeText={setSearchText}
autoFocus
returnKeyType="search"
/>
<TouchableOpacity onPress={handleClose} style={styles.closeButton}>
<Ionicons name="close" size={24} color="#666" />
</TouchableOpacity>
</View>
<View style={styles.content}>
{searchText === '' ? (
<View style={styles.emptyState}>
<Ionicons name="search-outline" size={64} color="#ccc" />
<Text style={styles.emptyText}>Search for outlets or articles</Text>
</View>
) : (
<View style={styles.emptyState}>
<Text style={styles.emptyText}>No results found for "{searchText}"</Text>
</View>
)}
</View>
</Animated.View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#fff',
},
searchBar: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 16,
paddingVertical: 12,
borderBottomWidth: 1,
borderBottomColor: '#e0e0e0',
backgroundColor: '#fff',
},
searchIcon: {
marginRight: 8,
},
searchInput: {
flex: 1,
fontSize: 16,
color: '#000',
padding: 8,
},
closeButton: {
padding: 4,
},
content: {
flex: 1,
padding: 16,
},
emptyState: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
emptyText: {
fontSize: 16,
color: '#999',
marginTop: 16,
textAlign: 'center',
},
});

View File

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

View File

@ -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);
}}
/>
);
}

View File

@ -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>
);
}

View File

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

View File

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

View File

@ -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} />;
}

View File

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

View File

@ -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,
]}
/>
);
}

View File

@ -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} />;
}