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