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,36 @@
import { Tabs } from 'expo-router';
import React from 'react';
import { HapticTab } from '@/components/haptic-tab';
import { IconSymbol } from '@/components/ui/icon-symbol';
import { Colors } from '@/constants/theme';
import { useColorScheme } from '@/hooks/use-color-scheme';
export default function TabLayout() {
const colorScheme = useColorScheme();
return (
<Tabs
screenOptions={{
tabBarActiveTintColor: Colors[colorScheme ?? 'light'].tint,
headerShown: false,
tabBarButton: HapticTab,
tabBarStyle: { display: 'none' },
}}>
<Tabs.Screen
name="index"
options={{
title: 'Home',
tabBarIcon: ({ color }) => <IconSymbol size={28} name="house.fill" color={color} />,
}}
/>
<Tabs.Screen
name="explore"
options={{
title: 'Explore',
tabBarIcon: ({ color }) => <IconSymbol size={28} name="paperplane.fill" color={color} />,
}}
/>
</Tabs>
);
}

View File

@ -0,0 +1,112 @@
import { Image } from 'expo-image';
import { Platform, StyleSheet } from 'react-native';
import { Collapsible } from '@/components/ui/collapsible';
import { ExternalLink } from '@/components/external-link';
import ParallaxScrollView from '@/components/parallax-scroll-view';
import { ThemedText } from '@/components/themed-text';
import { ThemedView } from '@/components/themed-view';
import { IconSymbol } from '@/components/ui/icon-symbol';
import { Fonts } from '@/constants/theme';
export default function TabTwoScreen() {
return (
<ParallaxScrollView
headerBackgroundColor={{ light: '#D0D0D0', dark: '#353636' }}
headerImage={
<IconSymbol
size={310}
color="#808080"
name="chevron.left.forwardslash.chevron.right"
style={styles.headerImage}
/>
}>
<ThemedView style={styles.titleContainer}>
<ThemedText
type="title"
style={{
fontFamily: Fonts.rounded,
}}>
Explore
</ThemedText>
</ThemedView>
<ThemedText>This app includes example code to help you get started.</ThemedText>
<Collapsible title="File-based routing">
<ThemedText>
This app has two screens:{' '}
<ThemedText type="defaultSemiBold">app/(tabs)/index.tsx</ThemedText> and{' '}
<ThemedText type="defaultSemiBold">app/(tabs)/explore.tsx</ThemedText>
</ThemedText>
<ThemedText>
The layout file in <ThemedText type="defaultSemiBold">app/(tabs)/_layout.tsx</ThemedText>{' '}
sets up the tab navigator.
</ThemedText>
<ExternalLink href="https://docs.expo.dev/router/introduction">
<ThemedText type="link">Learn more</ThemedText>
</ExternalLink>
</Collapsible>
<Collapsible title="Android, iOS, and web support">
<ThemedText>
You can open this project on Android, iOS, and the web. To open the web version, press{' '}
<ThemedText type="defaultSemiBold">w</ThemedText> in the terminal running this project.
</ThemedText>
</Collapsible>
<Collapsible title="Images">
<ThemedText>
For static images, you can use the <ThemedText type="defaultSemiBold">@2x</ThemedText> and{' '}
<ThemedText type="defaultSemiBold">@3x</ThemedText> suffixes to provide files for
different screen densities
</ThemedText>
<Image
source={require('@/assets/images/react-logo.png')}
style={{ width: 100, height: 100, alignSelf: 'center' }}
/>
<ExternalLink href="https://reactnative.dev/docs/images">
<ThemedText type="link">Learn more</ThemedText>
</ExternalLink>
</Collapsible>
<Collapsible title="Light and dark mode components">
<ThemedText>
This template has light and dark mode support. The{' '}
<ThemedText type="defaultSemiBold">useColorScheme()</ThemedText> hook lets you inspect
what the user&apos;s current color scheme is, and so you can adjust UI colors accordingly.
</ThemedText>
<ExternalLink href="https://docs.expo.dev/develop/user-interface/color-themes/">
<ThemedText type="link">Learn more</ThemedText>
</ExternalLink>
</Collapsible>
<Collapsible title="Animations">
<ThemedText>
This template includes an example of an animated component. The{' '}
<ThemedText type="defaultSemiBold">components/HelloWave.tsx</ThemedText> component uses
the powerful{' '}
<ThemedText type="defaultSemiBold" style={{ fontFamily: Fonts.mono }}>
react-native-reanimated
</ThemedText>{' '}
library to create a waving hand animation.
</ThemedText>
{Platform.select({
ios: (
<ThemedText>
The <ThemedText type="defaultSemiBold">components/ParallaxScrollView.tsx</ThemedText>{' '}
component provides a parallax effect for the header image.
</ThemedText>
),
})}
</Collapsible>
</ParallaxScrollView>
);
}
const styles = StyleSheet.create({
headerImage: {
color: '#808080',
bottom: -90,
left: -35,
position: 'absolute',
},
titleContainer: {
flexDirection: 'row',
gap: 8,
},
});

View File

@ -0,0 +1,179 @@
import { useState } from 'react';
import { View, Text, FlatList, ActivityIndicator, StyleSheet, SafeAreaView, TouchableOpacity, Image } from 'react-native';
import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useOutlets } from '@/hooks/useApi';
import OutletCard from '@/components/OutletCard';
import BottomTabBar from '@/components/BottomTabBar';
import SearchModal from '@/components/SearchModal';
import LanguageModal from '@/components/LanguageModal';
import { useLanguage } from '@/contexts/LanguageContext';
export default function HomeScreen() {
const [activeFilter, setActiveFilter] = useState<'people' | 'topics' | 'companies'>('people');
const [showSearch, setShowSearch] = useState(false);
const [showLanguage, setShowLanguage] = useState(false);
const { language, setLanguage } = useLanguage();
const router = useRouter();
const { data: outlets, isLoading, error } = useOutlets(activeFilter, language);
if (error) {
return (
<SafeAreaView style={styles.container}>
<View style={styles.errorContainer}>
<Text style={styles.errorText}>Error: {error.message}</Text>
<Text style={styles.text}>Check if the server is running on localhost:8050</Text>
</View>
</SafeAreaView>
);
}
return (
<SafeAreaView style={styles.container}>
{/* Header */}
<View style={styles.header}>
<View style={styles.headerLeft}>
<Image
source={require('@/assets/images/sapiens-logo.png')}
style={styles.logo}
resizeMode="contain"
/>
</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)}
/>
) : (
<>
{/* Outlets List */}
{isLoading ? (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color="#007AFF" />
<Text style={styles.text}>Loading outlets...</Text>
</View>
) : (
<FlatList
data={outlets || []}
keyExtractor={(item: any, index) => `${activeFilter}-${item.id}-${index}`}
renderItem={({ item }: any) => (
<OutletCard
id={item.id}
name={item.name}
description={item.description || item.focusSubject || item.category}
category={item.category}
focusSubject={item.focusSubject}
avatar={item.avatar}
articleCount={item.articleCount || 0}
onPress={(id) => router.push(`/outlet/${id}`)}
/>
)}
contentContainerStyle={styles.list}
showsVerticalScrollIndicator={false}
ListEmptyComponent={
<Text style={styles.emptyText}>No outlets found</Text>
}
/>
)}
</>
)}
</View>
{/* Bottom Tab Bar */}
<BottomTabBar
activeTab={activeFilter}
onTabChange={setActiveFilter}
/>
{/* 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',
paddingHorizontal: 16,
paddingVertical: 12,
borderBottomWidth: 1,
borderBottomColor: '#e0e0e0',
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
contentContainer: {
flex: 1,
overflow: 'hidden',
},
headerLeft: {
alignItems: 'flex-start',
},
logo: {
height: 16,
},
headerRight: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
iconButton: {
padding: 8,
},
list: {
padding: 16,
paddingBottom: 16,
},
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
errorContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: 20,
},
text: {
fontSize: 14,
color: '#666',
marginTop: 10,
},
errorText: {
fontSize: 16,
color: 'red',
fontWeight: 'bold',
},
emptyText: {
fontSize: 14,
color: '#666',
textAlign: 'center',
marginTop: 20,
},
});

View File

@ -0,0 +1,34 @@
import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native';
import { Stack } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
import { QueryClientProvider } from '@tanstack/react-query';
import { queryClient } from '@/lib/queryClient';
import 'react-native-reanimated';
import '../global.css';
import { useColorScheme } from '@/hooks/use-color-scheme';
import { LanguageProvider } from '@/contexts/LanguageContext';
export const unstable_settings = {
anchor: '(tabs)',
};
export default function RootLayout() {
const colorScheme = useColorScheme();
return (
<QueryClientProvider client={queryClient}>
<LanguageProvider>
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
<Stack>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen name="outlet/[id]" options={{ headerShown: false }} />
<Stack.Screen name="article/[id]" options={{ headerShown: false }} />
<Stack.Screen name="modal" options={{ presentation: 'modal', title: 'Modal' }} />
</Stack>
<StatusBar style="auto" />
</ThemeProvider>
</LanguageProvider>
</QueryClientProvider>
);
}

View File

@ -0,0 +1,572 @@
import { useState, useMemo } from 'react';
import { View, Text, ScrollView, Image, ActivityIndicator, StyleSheet, SafeAreaView, TouchableOpacity, FlatList } from 'react-native';
import { useLocalSearchParams, useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useQuery } from '@tanstack/react-query';
import { useArticle, useOutlet } from '@/hooks/useApi';
import { useLanguage } from '@/contexts/LanguageContext';
import ArticleCard from '@/components/ArticleCard';
import LanguageModal from '@/components/LanguageModal';
import CommentsDrawer from '@/components/CommentsDrawer';
import { API_BASE_URL } from '@/lib/config';
export default function ArticleScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
const router = useRouter();
const { language, setLanguage } = useLanguage();
const [imageLoaded, setImageLoaded] = useState(false);
const [showLanguage, setShowLanguage] = useState(false);
const [showComments, setShowComments] = useState(false);
const { data: article, isLoading, error } = useArticle(id || '', language, true);
// Get outlet data from article
const articleData = article as any;
const outletId = articleData?.outletId || articleData?.outlet_id;
const { data: outlet } = useOutlet(outletId || '', language);
// Fetch latest articles for the feed
// TODO: Re-enable when news-api has a compatible feed endpoint
const { data: latestArticles } = useQuery({
queryKey: ['/api/feed', { limit: 20, exclude: id }],
queryFn: async () => {
const response = await fetch(`${API_BASE_URL}/api/v1/${language}/articles/latest?limit=20`);
return response.json();
},
enabled: false, // Disabled until API structure matches frontend expectations
});
// Clean and process body content
const cleanedBody = useMemo(() => {
if (!article || !(article as any).body) return '';
const body = (article as any).body;
// Remove "originally published" line
const pattern = /^\s*(?:This article was\s+)?originally published\s+(?:at|on)\s+\S+\.?\s*(?:\r?\n){1,2}/i;
const cleaned = body.replace(pattern, '').trimStart();
// Truncate at 3000 characters but complete the sentence
if (cleaned.length <= 3000) return cleaned;
const truncatePoint = 3000;
const afterTruncate = cleaned.substring(truncatePoint);
const sentenceEndMatch = afterTruncate.match(/[.!?](?:\s|$)/);
if (sentenceEndMatch && sentenceEndMatch.index !== undefined) {
const endIndex = truncatePoint + sentenceEndMatch.index + 1;
return cleaned.substring(0, endIndex);
}
return cleaned.substring(0, 3000);
}, [article]);
const getInitials = (name: string) => {
return name
.split(' ')
.map(word => word[0])
.join('')
.toUpperCase()
.slice(0, 2);
};
// Parse body content for rendering
const parseBodyContent = (text: string) => {
const lines = text.split('\n');
const elements: Array<{ type: 'subtitle' | 'paragraph', content: string, key: string }> = [];
let currentParagraph: string[] = [];
let keyIndex = 0;
const flushParagraph = () => {
if (currentParagraph.length > 0) {
const paragraphText = currentParagraph.join('\n').trim();
if (paragraphText) {
elements.push({
type: 'paragraph',
content: paragraphText,
key: `p-${keyIndex++}`
});
}
currentParagraph = [];
}
};
lines.forEach((line) => {
const trimmedLine = line.trim();
// Check for subtitle patterns
const fullLineSubtitle = trimmedLine.match(/^\*\*(.*?)\*\*$/);
const prefixedSubtitle = trimmedLine.match(/^\s*\*\*(.+?)\*\*\s*(.*)$/);
if (fullLineSubtitle && trimmedLine === `**${fullLineSubtitle[1]}**`) {
flushParagraph();
elements.push({
type: 'subtitle',
content: fullLineSubtitle[1],
key: `subtitle-${keyIndex++}`
});
} else if (prefixedSubtitle && prefixedSubtitle[2].trim() !== '') {
flushParagraph();
elements.push({
type: 'subtitle',
content: prefixedSubtitle[1],
key: `subtitle-${keyIndex++}`
});
if (prefixedSubtitle[2].trim()) {
currentParagraph.push(prefixedSubtitle[2].trim());
}
} else if (trimmedLine === '') {
flushParagraph();
} else {
currentParagraph.push(line);
}
});
flushParagraph();
return elements;
};
if (isLoading) {
return (
<SafeAreaView style={styles.container}>
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color="#007AFF" />
<Text style={styles.text}>Loading...</Text>
</View>
</SafeAreaView>
);
}
if (error || !article) {
return (
<SafeAreaView style={styles.container}>
<View style={styles.errorContainer}>
<Text style={styles.errorText}>Article not found</Text>
<TouchableOpacity style={styles.backButton} onPress={() => router.back()}>
<Text style={styles.backButtonText}>Go Back</Text>
</TouchableOpacity>
</View>
</SafeAreaView>
);
}
const bodyElements = articleData.subtopics && articleData.subtopics.length > 0
? null
: parseBodyContent(cleanedBody);
const outletData = outlet as any;
const outletName = outletData?.name || articleData?.outletName || 'Unknown Outlet';
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(outletName)}</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}>{outletName}</Text>
</View>
</View>
<View style={styles.headerRight}>
<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>
<ScrollView style={styles.scrollView} showsVerticalScrollIndicator={false}>
{/* Hero Image */}
{articleData.thumbnail && (
<View style={styles.heroContainer}>
<Image
source={{ uri: articleData.thumbnail }}
style={[styles.heroImage, !imageLoaded && styles.heroImageHidden]}
resizeMode="cover"
onLoad={() => setImageLoaded(true)}
/>
{!imageLoaded && (
<View style={styles.imagePlaceholder}>
<ActivityIndicator size="large" color="#007AFF" />
</View>
)}
{articleData.category && (
<View style={styles.categoryBadge}>
<Text style={styles.categoryText}>{articleData.category}</Text>
</View>
)}
{articleData.timeAgo && (
<View style={styles.timeContainer}>
<Text style={styles.timeText}>{articleData.timeAgo}</Text>
</View>
)}
</View>
)}
{/* Article Content Card */}
<View style={styles.contentCard}>
{/* Title */}
<Text style={styles.title}>{articleData.title}</Text>
{/* Summary */}
{articleData.summary && (
<Text style={styles.summary}>{articleData.summary}</Text>
)}
{/* Body - Subtopics or Parsed Content */}
<View style={styles.body}>
{articleData.subtopics && articleData.subtopics.length > 0 ? (
articleData.subtopics.map((topic: any, index: number) => (
<View key={index} style={styles.subtopicSection}>
<Text style={styles.subtitle}>{topic.title}</Text>
{Array.isArray(topic.content) ? (
topic.content.map((paragraph: string, pIndex: number) => (
<Text key={pIndex} style={styles.paragraph}>
{paragraph}
</Text>
))
) : (
<Text style={styles.paragraph}>{topic.content}</Text>
)}
</View>
))
) : (
bodyElements?.map((element) => (
element.type === 'subtitle' ? (
<Text key={element.key} style={styles.subtitle}>
{element.content}
</Text>
) : (
<Text key={element.key} style={styles.paragraph}>
{element.content}
</Text>
)
))
)}
</View>
</View>
{/* Latest Articles Feed */}
{latestArticles?.items && latestArticles.items.length > 0 && (
<View style={styles.feedContainer}>
<Text style={styles.feedTitle}>Latest Articles</Text>
{latestArticles.items
.filter((item: any) => item.newsId && item.newsId !== id)
.slice(0, 10)
.map((item: any) => (
<View key={item.newsId} style={styles.feedItemWrapper}>
<ArticleCard
id={item.newsId}
title={item.title}
summary={item.summary}
thumbnail={item.thumbnail}
publishedAt={new Date(item.publishedAt)}
timeAgo={item.timeAgo}
outletName={item.outletName}
category={item.category}
onClick={(newsId) => {
router.push(`/article/${newsId}`);
}}
isAd={item.isAd || false}
tags={item.tags || []}
/>
</View>
))}
</View>
)}
</ScrollView>
{/* Footer Action Bar */}
<View style={styles.footer}>
<TouchableOpacity style={styles.footerButton} onPress={() => router.back()}>
<Ionicons name="chevron-back" size={24} color="#333" />
</TouchableOpacity>
<View style={styles.footerActions}>
<TouchableOpacity style={styles.actionButton} onPress={() => setShowComments(true)}>
<Ionicons name="chatbubble-outline" size={22} color="#333" />
</TouchableOpacity>
<TouchableOpacity style={styles.actionButton}>
<Ionicons name="share-outline" size={22} color="#333" />
</TouchableOpacity>
<TouchableOpacity style={styles.actionButton}>
<Ionicons name="bookmark-outline" size={22} color="#333" />
</TouchableOpacity>
</View>
<TouchableOpacity style={styles.footerButton}>
<Ionicons name="chevron-forward" size={24} color="#999" />
</TouchableOpacity>
</View>
{/* Language Modal */}
<LanguageModal
visible={showLanguage}
onClose={() => setShowLanguage(false)}
currentLanguage={language}
onSelectLanguage={setLanguage}
/>
{/* Comments Drawer */}
<CommentsDrawer
articleId={id || ''}
isOpen={showComments}
onClose={() => setShowComments(false)}
/>
</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',
},
headerLeft: {
flexDirection: 'row',
alignItems: 'center',
flex: 1,
gap: 12,
},
outletAvatar: {
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',
},
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,
},
headerRight: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
iconButton: {
padding: 8,
},
scrollView: {
flex: 1,
},
heroContainer: {
position: 'relative',
width: '100%',
aspectRatio: 16 / 9,
backgroundColor: '#e0e0e0',
},
heroImage: {
width: '100%',
height: '100%',
},
heroImageHidden: {
opacity: 0,
},
imagePlaceholder: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: '#e0e0e0',
},
categoryBadge: {
position: 'absolute',
top: 16,
left: 16,
backgroundColor: 'rgba(255, 255, 255, 0.95)',
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 16,
},
categoryText: {
fontSize: 12,
fontWeight: '600',
color: '#333',
},
timeContainer: {
position: 'absolute',
bottom: 12,
left: 12,
},
timeText: {
fontSize: 12,
color: '#fff',
fontWeight: '600',
textShadowColor: 'rgba(0, 0, 0, 0.75)',
textShadowOffset: { width: 0, height: 1 },
textShadowRadius: 3,
},
contentCard: {
backgroundColor: '#fff',
marginTop: 16,
marginHorizontal: 16,
marginBottom: 24,
padding: 20,
borderRadius: 12,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 3,
},
title: {
fontSize: 24,
fontWeight: 'bold',
color: '#000',
marginBottom: 12,
lineHeight: 32,
},
summary: {
fontSize: 16,
color: '#666',
marginBottom: 20,
lineHeight: 24,
},
body: {
marginBottom: 16,
},
subtopicSection: {
marginBottom: 20,
},
subtitle: {
fontSize: 18,
fontWeight: '600',
color: '#000',
marginTop: 20,
marginBottom: 12,
},
paragraph: {
fontSize: 16,
color: '#333',
lineHeight: 26,
marginBottom: 16,
},
feedContainer: {
marginTop: 32,
marginHorizontal: 16,
marginBottom: 32,
},
feedTitle: {
fontSize: 20,
fontWeight: 'bold',
color: '#000',
marginBottom: 16,
},
feedItemWrapper: {
marginBottom: 12,
},
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
errorContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: 20,
},
text: {
fontSize: 14,
color: '#666',
marginTop: 10,
},
errorText: {
fontSize: 16,
color: 'red',
fontWeight: 'bold',
marginBottom: 16,
},
backButton: {
backgroundColor: '#007AFF',
paddingHorizontal: 20,
paddingVertical: 10,
borderRadius: 8,
},
backButtonText: {
color: '#fff',
fontSize: 16,
fontWeight: '600',
},
footer: {
backgroundColor: '#fff',
borderTopWidth: 1,
borderTopColor: '#e0e0e0',
paddingVertical: 8,
paddingHorizontal: 8,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
footerButton: {
padding: 8,
width: 40,
alignItems: 'center',
},
footerActions: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
},
actionButton: {
padding: 8,
width: 36,
height: 36,
alignItems: 'center',
justifyContent: 'center',
},
});

View File

@ -0,0 +1,29 @@
import { Link } from 'expo-router';
import { StyleSheet } from 'react-native';
import { ThemedText } from '@/components/themed-text';
import { ThemedView } from '@/components/themed-view';
export default function ModalScreen() {
return (
<ThemedView style={styles.container}>
<ThemedText type="title">This is a modal</ThemedText>
<Link href="/" dismissTo style={styles.link}>
<ThemedText type="link">Go to home screen</ThemedText>
</Link>
</ThemedView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
padding: 20,
},
link: {
marginTop: 15,
paddingVertical: 15,
},
});

View File

@ -0,0 +1,444 @@
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,
},
});