Files
jungwoo choi 919afe56f2 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>
2025-10-23 14:30:25 +09:00

445 lines
12 KiB
TypeScript

import { useState } from 'react';
import { View, Text, FlatList, ActivityIndicator, StyleSheet, SafeAreaView, Image, RefreshControl, TouchableOpacity, Modal } from 'react-native';
import { useLocalSearchParams, useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useOutlet, useArticlesByOutlet } from '@/hooks/useApi';
import ArticleCard from '@/components/ArticleCard';
import BottomTabBar from '@/components/BottomTabBar';
import SearchModal from '@/components/SearchModal';
import LanguageModal from '@/components/LanguageModal';
import { useLanguage } from '@/contexts/LanguageContext';
export default function OutletScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
const router = useRouter();
const [refreshing, setRefreshing] = useState(false);
const [activeFilter, setActiveFilter] = useState<'people' | 'topics' | 'companies'>('people');
const [showProfileModal, setShowProfileModal] = useState(false);
const [showSearch, setShowSearch] = useState(false);
const [showLanguage, setShowLanguage] = useState(false);
const [viewMode, setViewMode] = useState<'card' | 'list'>('card');
const { language, setLanguage } = useLanguage();
const { data: outlet, isLoading: outletLoading, error: outletError, refetch: refetchOutlet } = useOutlet(id || '', language);
const { data: articlesResponse, isLoading: articlesLoading, refetch: refetchArticles } = useArticlesByOutlet(id || '', language);
const onRefresh = async () => {
setRefreshing(true);
await Promise.all([refetchOutlet(), refetchArticles()]);
setRefreshing(false);
};
const getInitials = (name: string) => {
return name
.split(' ')
.map(word => word[0])
.join('')
.toUpperCase()
.slice(0, 2);
};
if (outletLoading) {
return (
<SafeAreaView style={styles.container}>
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color="#007AFF" />
<Text style={styles.text}>Loading...</Text>
</View>
</SafeAreaView>
);
}
if (outletError || !outlet) {
return (
<SafeAreaView style={styles.container}>
<View style={styles.errorContainer}>
<Text style={styles.errorText}>Outlet not found</Text>
<TouchableOpacity
style={styles.backButton}
onPress={() => router.back()}
>
<Text style={styles.backButtonText}>Go Back</Text>
</TouchableOpacity>
</View>
</SafeAreaView>
);
}
const outletData = outlet as any;
// Extract articles array from paginated response
const articlesData = (articlesResponse as any)?.articles || [];
return (
<SafeAreaView style={styles.container}>
{/* Header */}
<View style={styles.header}>
<View style={styles.headerLeft}>
{outletData.avatar ? (
<Image
source={{ uri: outletData.avatar }}
style={styles.outletAvatar}
/>
) : (
<View style={[styles.avatarFallback, styles.outletAvatar]}>
<Text style={styles.avatarText}>{getInitials(outletData.name)}</Text>
</View>
)}
<View style={styles.outletInfo}>
<Image
source={require('@/assets/images/sapiens-logo.png')}
style={styles.miniLogo}
resizeMode="contain"
/>
<Text style={styles.outletName} numberOfLines={1}>{outletData.name}</Text>
</View>
</View>
<View style={styles.headerRight}>
<TouchableOpacity style={styles.iconButton} onPress={() => setShowSearch(true)}>
<Ionicons name="search-outline" size={18} color="#333" />
</TouchableOpacity>
<TouchableOpacity style={styles.iconButton} onPress={() => setShowLanguage(true)}>
<Ionicons name="settings-outline" size={18} color="#333" />
</TouchableOpacity>
<TouchableOpacity style={styles.iconButton}>
<Ionicons name="person-circle-outline" size={20} color="#333" />
</TouchableOpacity>
</View>
</View>
{/* Content Area */}
<View style={styles.contentContainer}>
{showSearch ? (
<SearchModal
visible={showSearch}
onClose={() => setShowSearch(false)}
/>
) : (
<>
{/* Articles List */}
<FlatList
data={articlesData.filter((item: any) => item.newsId)}
keyExtractor={(item: any) => item.newsId}
renderItem={({ item, index }: any) => (
<View style={styles.articleWrapper}>
<ArticleCard
key={item.newsId}
id={item.newsId}
title={item.title}
summary={item.summary}
thumbnail={item.thumbnail}
outletName={outletData?.name || 'Unknown Outlet'}
category={outletData?.category || 'uncategorized'}
publishedAt={new Date(item.publishedAt)}
timeAgo={item.timeAgo}
onClick={(newsId) => {
router.push(`/article/${newsId}`);
}}
isAd={item.isAd || false}
tags={item.tags || []}
index={index}
/>
</View>
)}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
}
contentContainerStyle={styles.listContent}
showsVerticalScrollIndicator={false}
ListEmptyComponent={
articlesLoading ? (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color="#007AFF" />
<Text style={styles.text}>Loading articles...</Text>
</View>
) : (
<View style={styles.emptyContainer}>
<Text style={styles.emptyText}>No articles available yet.</Text>
</View>
)
}
/>
</>
)}
</View>
{/* Footer Navigation */}
<View style={styles.footer}>
<TouchableOpacity style={styles.navButton} onPress={() => router.back()}>
<Ionicons name="arrow-back" size={24} color="#333" />
</TouchableOpacity>
<TouchableOpacity onPress={() => router.push('/')}>
<Image
source={require('@/assets/images/sapiens-logo.png')}
style={styles.footerLogo}
resizeMode="contain"
/>
</TouchableOpacity>
<TouchableOpacity style={styles.navButton}>
<Ionicons name="arrow-forward" size={24} color="#333" />
</TouchableOpacity>
</View>
{/* Profile Modal */}
<Modal
visible={showProfileModal}
transparent
animationType="fade"
onRequestClose={() => setShowProfileModal(false)}
>
<TouchableOpacity
style={styles.modalOverlay}
activeOpacity={1}
onPress={() => setShowProfileModal(false)}
>
<View style={styles.modalContent}>
<View style={styles.modalHeader}>
<View style={styles.modalProfileContainer}>
{outletData.avatar ? (
<Image
source={{ uri: outletData.avatar }}
style={styles.modalAvatar}
/>
) : (
<View style={[styles.avatarFallback, styles.modalAvatar]}>
<Text style={styles.avatarText}>{getInitials(outletData.name)}</Text>
</View>
)}
<Text style={styles.modalName}>{outletData.name}</Text>
</View>
<TouchableOpacity onPress={() => setShowProfileModal(false)}>
<Text style={styles.closeButton}></Text>
</TouchableOpacity>
</View>
<Text style={styles.modalDescription}>
{outletData.description || outletData.focusSubject}
</Text>
</View>
</TouchableOpacity>
</Modal>
{/* Language Modal */}
<LanguageModal
visible={showLanguage}
onClose={() => setShowLanguage(false)}
currentLanguage={language}
onSelectLanguage={setLanguage}
/>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f5f5f5',
},
header: {
backgroundColor: '#fff',
borderBottomWidth: 1,
borderBottomColor: '#e0e0e0',
paddingVertical: 12,
paddingHorizontal: 16,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
contentContainer: {
flex: 1,
overflow: 'hidden',
},
headerLeft: {
flexDirection: 'row',
alignItems: 'center',
flex: 1,
gap: 12,
},
outletAvatar: {
width: 40,
height: 40,
borderRadius: 20,
},
outletInfo: {
flexDirection: 'column',
alignItems: 'flex-start',
gap: 3,
flex: 1,
},
miniLogo: {
height: 10,
width: 90,
resizeMode: 'contain',
marginLeft: 0,
},
outletName: {
fontSize: 16,
fontWeight: '600',
color: '#000',
maxWidth: 150,
},
categoryBadge: {
backgroundColor: '#f0f0f0',
paddingHorizontal: 8,
paddingVertical: 4,
borderRadius: 12,
},
categoryText: {
fontSize: 11,
color: '#666',
fontWeight: '500',
},
headerRight: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
profileContainer: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
flex: 1,
},
avatar: {
width: 40,
height: 40,
borderRadius: 20,
},
avatarFallback: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: '#e0e0e0',
justifyContent: 'center',
alignItems: 'center',
},
avatarText: {
fontSize: 14,
fontWeight: '600',
color: '#333',
},
profileInfo: {
flex: 1,
},
sapiensLogo: {
height: 10,
marginBottom: 2,
},
nameRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
},
name: {
fontSize: 16,
fontWeight: 'bold',
color: '#000',
},
infoIcon: {
fontSize: 16,
color: '#007AFF',
},
iconButton: {
padding: 8,
},
articleWrapper: {
paddingHorizontal: 16,
paddingTop: 16,
},
listContent: {
paddingBottom: 16,
},
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: 20,
},
errorContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: 20,
},
emptyContainer: {
padding: 32,
alignItems: 'center',
},
text: {
fontSize: 14,
color: '#666',
marginTop: 10,
},
errorText: {
fontSize: 16,
color: 'red',
fontWeight: 'bold',
marginBottom: 16,
},
emptyText: {
fontSize: 14,
color: '#666',
textAlign: 'center',
},
modalOverlay: {
flex: 1,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
justifyContent: 'center',
alignItems: 'center',
},
modalContent: {
backgroundColor: '#fff',
borderRadius: 12,
padding: 20,
width: '85%',
maxWidth: 400,
},
modalHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-start',
marginBottom: 16,
},
modalProfileContainer: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
flex: 1,
},
modalAvatar: {
width: 48,
height: 48,
borderRadius: 24,
},
modalName: {
fontSize: 18,
fontWeight: 'bold',
color: '#000',
flex: 1,
},
closeButton: {
fontSize: 24,
color: '#666',
fontWeight: '300',
},
modalDescription: {
fontSize: 14,
color: '#666',
lineHeight: 20,
},
footer: {
backgroundColor: '#fff',
borderTopWidth: 1,
borderTopColor: '#e0e0e0',
paddingVertical: 12,
paddingHorizontal: 16,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
navButton: {
padding: 8,
},
footerLogo: {
height: 16,
},
});