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:
36
sapiense-ai-app/app/(tabs)/_layout.tsx
Normal file
36
sapiense-ai-app/app/(tabs)/_layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
112
sapiense-ai-app/app/(tabs)/explore.tsx
Normal file
112
sapiense-ai-app/app/(tabs)/explore.tsx
Normal 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'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,
|
||||
},
|
||||
});
|
||||
179
sapiense-ai-app/app/(tabs)/index.tsx
Normal file
179
sapiense-ai-app/app/(tabs)/index.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
34
sapiense-ai-app/app/_layout.tsx
Normal file
34
sapiense-ai-app/app/_layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
572
sapiense-ai-app/app/article/[id].tsx
Normal file
572
sapiense-ai-app/app/article/[id].tsx
Normal 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',
|
||||
},
|
||||
});
|
||||
29
sapiense-ai-app/app/modal.tsx
Normal file
29
sapiense-ai-app/app/modal.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
444
sapiense-ai-app/app/outlet/[id].tsx
Normal file
444
sapiense-ai-app/app/outlet/[id].tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user