This commit is contained in:
2026-03-28 23:51:50 -04:00
parent 0a477300f4
commit e56e3ba531
47 changed files with 13489 additions and 201 deletions

186
src/app/add-feed.tsx Normal file
View File

@@ -0,0 +1,186 @@
// Add Feed Screen
import React, { useState } from 'react';
import {
View,
TextInput,
Button,
Text,
StyleSheet,
Alert,
ActivityIndicator,
} from 'react-native';
import { useRouter } from 'expo-router';
import { useFeedStore } from '@/stores/feed-store';
import { parseFeed } from '@/services/feed-service';
import { generateId } from '@/utils/helpers';
import { t } from '@/i18n';
export default function AddFeedScreen() {
const router = useRouter();
const [url, setUrl] = useState('');
const [title, setTitle] = useState('');
const [loading, setLoading] = useState(false);
const [preview, setPreview] = useState<any>(null);
const addSubscription = useFeedStore((state) => state.addSubscription);
const handlePreview = async () => {
if (!url) {
Alert.alert('Error', 'Please enter a feed URL');
return;
}
setLoading(true);
setPreview(null);
try {
const response = await fetch(url);
const text = await response.text();
const result = await parseFeed(url, text);
if (result.success && result.feed) {
setPreview(result.feed);
} else {
Alert.alert('Error', result.error || 'Failed to parse feed');
}
} catch (error) {
Alert.alert('Error', 'Failed to fetch feed');
} finally {
setLoading(false);
}
};
const handleAdd = async () => {
if (!url) {
Alert.alert('Error', 'Please enter a feed URL');
return;
}
if (!preview) {
Alert.alert('Error', 'Please preview the feed first');
return;
}
setLoading(true);
try {
const subscription = {
id: generateId(),
url,
title: title || preview.title,
enabled: true,
fetchInterval: 60, // Default 1 hour
createdAt: new Date(),
updatedAt: new Date(),
};
await addSubscription(subscription);
router.back();
} catch (error) {
Alert.alert('Error', 'Failed to add subscription');
} finally {
setLoading(false);
}
};
return (
<View style={styles.container}>
<Text style={styles.title}>{t('feed.add')}</Text>
<View style={styles.inputContainer}>
<Text style={styles.label}>URL</Text>
<TextInput
style={styles.input}
placeholder="https://example.com/feed.xml"
value={url}
onChangeText={setUrl}
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
/>
</View>
<View style={styles.inputContainer}>
<Text style={styles.label}>Title (optional)</Text>
<TextInput
style={styles.input}
placeholder="My Feed"
value={title}
onChangeText={setTitle}
/>
</View>
<Button
title={loading && !preview ? 'Loading...' : 'Preview'}
onPress={handlePreview}
disabled={loading || !url}
/>
{preview && (
<View style={styles.previewContainer}>
<Text style={styles.previewTitle}>{preview.title}</Text>
<Text style={styles.previewDescription}>
{preview.description || 'No description'}
</Text>
<Text style={styles.previewItems}>
{preview.items.length} items
</Text>
</View>
)}
<Button
title={loading ? 'Adding...' : 'Add Feed'}
onPress={handleAdd}
disabled={loading || !preview}
/>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
padding: 20,
paddingTop: 60,
},
title: {
fontSize: 24,
fontWeight: 'bold',
marginBottom: 30,
},
inputContainer: {
marginBottom: 20,
},
label: {
fontSize: 14,
marginBottom: 8,
},
input: {
borderWidth: 1,
borderColor: '#ccc',
borderRadius: 8,
padding: 12,
fontSize: 16,
},
previewContainer: {
marginTop: 20,
padding: 16,
borderWidth: 1,
borderColor: '#ddd',
borderRadius: 8,
},
previewTitle: {
fontSize: 18,
fontWeight: 'bold',
marginBottom: 8,
},
previewDescription: {
fontSize: 14,
color: '#666',
marginBottom: 8,
},
previewItems: {
fontSize: 12,
color: '#999',
},
});

270
src/app/article/[id].tsx Normal file
View File

@@ -0,0 +1,270 @@
// Article Detail View
import React, { useEffect, useState } from 'react';
import {
View,
ScrollView,
StyleSheet,
Share,
Linking,
Image,
Pressable,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { SymbolView } from 'expo-symbols';
import { useLocalSearchParams } from 'expo-router';
import { ThemedText } from '@/components/themed-text';
import { ThemedView } from '@/components/themed-view';
import { useTheme } from '@/hooks/use-theme';
import { useBookmarkStore } from '@/stores/bookmark-store';
import { FeedItem } from '@/types/feed';
import { getFeedItems, getAllFeedItems } from '@/services/database';
import { Colors, Spacing, MaxContentWidth, BottomTabInset } from '@/constants/theme';
export default function ArticleDetailScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
const insets = useSafeAreaInsets();
const theme = useTheme();
const { isBookmarked, toggleBookmark } = useBookmarkStore();
const [article, setArticle] = useState<FeedItem | null>(null);
const [loading, setLoading] = useState(true);
const bookmarked = id ? isBookmarked(id) : false;
useEffect(() => {
async function loadArticle() {
if (!id) return;
try {
setLoading(true);
const items = await getFeedItems(undefined, 500);
const found = items.find(item => item.id === id);
setArticle(found || null);
} catch (error) {
console.error('Failed to load article:', error);
} finally {
setLoading(false);
}
}
loadArticle();
}, [id]);
const handleShare = async () => {
if (!article?.link) return;
try {
await Share.share({
message: `${article.title}\n\n${article.link}`,
title: article.title,
url: article.link,
});
} catch (error) {
console.error('Share error:', error);
}
};
const handleOpenInBrowser = () => {
if (article?.link) {
Linking.openURL(article.link);
}
};
const formatDate = (date?: Date) => {
if (!date) return '';
return new Date(date).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
};
const renderContent = (content?: string) => {
if (!content) return null;
return (
<ThemedText style={styles.content}>
{content}
</ThemedText>
);
};
if (loading) {
return (
<ThemedView style={styles.container}>
<ThemedView style={styles.loadingContainer}>
<ThemedText>Loading...</ThemedText>
</ThemedView>
</ThemedView>
);
}
if (!article) {
return (
<ThemedView style={styles.container}>
<ThemedView style={styles.errorContainer}>
<ThemedText>Article not found</ThemedText>
</ThemedView>
</ThemedView>
);
}
return (
<ThemedView style={styles.container}>
<ScrollView
style={styles.scrollView}
contentContainerStyle={[
styles.contentContainer,
{ paddingBottom: insets.bottom + BottomTabInset + Spacing.four },
]}>
<ThemedView style={styles.header}>
<ThemedText type="title" style={styles.title}>
{article.title}
</ThemedText>
{article.author && (
<ThemedText type="small" themeColor="textSecondary" style={styles.author}>
By {article.author}
</ThemedText>
)}
{article.published && (
<ThemedText type="small" themeColor="textSecondary">
{formatDate(article.published)}
</ThemedText>
)}
</ThemedView>
<ThemedView style={styles.actions}>
<Pressable
style={[styles.actionButton, { backgroundColor: theme.backgroundElement }]}
onPress={() => id && toggleBookmark(id)}>
<SymbolView
tintColor={bookmarked ? '#007AFF' : theme.text}
name={bookmarked ? 'bookmark_fill' as any : 'bookmark' as any}
size={20}
/>
</Pressable>
<Pressable
style={[styles.actionButton, { backgroundColor: theme.backgroundElement }]}
onPress={handleShare}>
<SymbolView
tintColor={theme.text}
name={{ ios: 'square.and.arrow.up', android: 'share', web: 'share' }}
size={20}
/>
</Pressable>
<Pressable
style={[styles.actionButton, { backgroundColor: theme.backgroundElement }]}
onPress={handleOpenInBrowser}>
<SymbolView
tintColor={theme.text}
name={{ ios: 'safari', android: 'public', web: 'globe' }}
size={20}
/>
</Pressable>
</ThemedView>
{article.description && (
<ThemedView style={styles.section}>
<ThemedText type="default" style={styles.description}>
{article.description}
</ThemedText>
</ThemedView>
)}
{article.content && (
<ThemedView style={styles.section}>
{renderContent(article.content)}
</ThemedView>
)}
{article.link && (
<Pressable
style={[styles.readMoreButton, { backgroundColor: theme.text }]}
onPress={handleOpenInBrowser}>
<ThemedText style={[styles.readMoreText, { color: theme.background }]}>
Read Full Article
</ThemedText>
</Pressable>
)}
</ScrollView>
</ThemedView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
scrollView: {
flex: 1,
},
contentContainer: {
maxWidth: MaxContentWidth,
marginHorizontal: 'auto',
flexGrow: 1,
},
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
errorContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
header: {
padding: Spacing.four,
gap: Spacing.two,
},
title: {
fontSize: 24,
fontWeight: '700',
lineHeight: 32,
},
author: {
marginTop: Spacing.two,
},
actions: {
flexDirection: 'row',
paddingHorizontal: Spacing.four,
paddingVertical: Spacing.two,
gap: Spacing.two,
},
actionButton: {
width: 44,
height: 44,
borderRadius: 22,
justifyContent: 'center',
alignItems: 'center',
},
section: {
padding: Spacing.four,
paddingTop: 0,
},
description: {
fontSize: 16,
lineHeight: 24,
color: '#333',
},
content: {
fontSize: 16,
lineHeight: 26,
},
readMoreButton: {
margin: Spacing.four,
padding: Spacing.three,
borderRadius: Spacing.three,
alignItems: 'center',
},
readMoreText: {
fontSize: 16,
fontWeight: '600',
},
});

View File

@@ -1,24 +1,117 @@
import { Image } from 'expo-image';
import { SymbolView } from 'expo-symbols';
import React from 'react';
import { Platform, Pressable, ScrollView, StyleSheet } from 'react-native';
import React, { useState } from 'react';
import {
Platform,
Pressable,
ScrollView,
StyleSheet,
TextInput,
View,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { SymbolView } from 'expo-symbols';
import { ExternalLink } from '@/components/external-link';
import { ThemedText } from '@/components/themed-text';
import { ThemedView } from '@/components/themed-view';
import { Collapsible } from '@/components/ui/collapsible';
import { WebBadge } from '@/components/web-badge';
import { BottomTabInset, MaxContentWidth, Spacing } from '@/constants/theme';
import { useTheme } from '@/hooks/use-theme';
import { t } from '@/i18n';
export default function TabTwoScreen() {
type CategoryIcon = {
ios: string;
android: string;
web: string;
};
const CATEGORY_ICONS: Record<string, CategoryIcon> = {
tech: { ios: 'laptopcomputer', android: 'laptop', web: 'laptop' },
news: { ios: 'doc.text', android: 'article', web: 'article' },
sports: { ios: 'figure.run', android: 'run', web: 'run' },
business: { ios: 'briefcase', android: 'briefcase', web: 'briefcase' },
entertainment: { ios: 'star', android: 'star', web: 'star' },
health: { ios: 'heart.fill', android: 'heart', web: 'heart' },
science: { ios: 'beaker', android: 'flask', web: 'flask' },
food: { ios: 'bowl.with.spoon', android: 'restaurant', web: 'restaurant' },
};
const CATEGORIES = [
{ id: 'tech', name: 'Technology' },
{ id: 'news', name: 'News' },
{ id: 'sports', name: 'Sports' },
{ id: 'business', name: 'Business' },
{ id: 'entertainment', name: 'Entertainment' },
{ id: 'health', name: 'Health' },
{ id: 'science', name: 'Science' },
{ id: 'food', name: 'Food' },
];
const TRENDING_FEEDS = [
{
id: '1',
title: 'TechCrunch',
description: 'Latest technology news and startups',
category: 'tech',
subscribers: 125000,
},
{
id: '2',
title: 'The Verge',
description: 'Technology, science, art, and culture',
category: 'tech',
subscribers: 98000,
},
{
id: '3',
title: 'Hacker News',
description: 'News about hacking and startups',
category: 'tech',
subscribers: 87000,
},
{
id: '4',
title: 'BBC News',
description: 'Breaking news and features',
category: 'news',
subscribers: 156000,
},
{
id: '5',
title: 'ESPN',
description: 'Sports news and highlights',
category: 'sports',
subscribers: 112000,
},
];
const RECOMMENDED_PODCASTS = [
{
id: '1',
title: 'The Daily',
description: 'News explained',
publisher: 'The New York Times',
},
{
id: '2',
title: 'Reply All',
description: 'About the internet',
publisher: 'Gimlet Media',
},
{
id: '3',
title: '99% Invisible',
description: 'Design and architecture',
publisher: 'Roman Mars',
},
];
export default function ExploreScreen() {
const safeAreaInsets = useSafeAreaInsets();
const insets = {
...safeAreaInsets,
bottom: safeAreaInsets.bottom + BottomTabInset + Spacing.three,
};
const theme = useTheme();
const [searchQuery, setSearchQuery] = useState('');
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
const contentPlatformStyle = Platform.select({
android: {
@@ -33,94 +126,182 @@ export default function TabTwoScreen() {
},
});
const filteredTrending = TRENDING_FEEDS.filter((feed) => {
const matchesSearch =
feed.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
feed.description.toLowerCase().includes(searchQuery.toLowerCase());
const matchesCategory = selectedCategory ? feed.category === selectedCategory : true;
return matchesSearch && matchesCategory;
});
return (
<ScrollView
style={[styles.scrollView, { backgroundColor: theme.background }]}
contentInset={insets}
contentContainerStyle={[styles.contentContainer, contentPlatformStyle]}>
<ThemedView style={styles.container}>
<ThemedView style={styles.titleContainer}>
<ThemedText type="subtitle">Explore</ThemedText>
<ThemedText style={styles.centerText} themeColor="textSecondary">
This starter app includes example{'\n'}code to help you get started.
</ThemedText>
<ExternalLink href="https://docs.expo.dev" asChild>
<Pressable style={({ pressed }) => pressed && styles.pressed}>
<ThemedView type="backgroundElement" style={styles.linkButton}>
<ThemedText type="link">Expo documentation</ThemedText>
<SymbolView
tintColor={theme.text}
name={{ ios: 'arrow.up.right.square', android: 'link', web: 'link' }}
size={12}
/>
</ThemedView>
</Pressable>
</ExternalLink>
<ThemedView style={styles.header}>
<ThemedText type="title">{t('tab.explore')}</ThemedText>
<ThemedText themeColor="textSecondary">{t('explore.discover')}</ThemedText>
</ThemedView>
<ThemedView style={styles.sectionsWrapper}>
<Collapsible title="File-based routing">
<ThemedText type="small">
This app has two screens: <ThemedText type="code">src/app/index.tsx</ThemedText> and{' '}
<ThemedText type="code">src/app/explore.tsx</ThemedText>
</ThemedText>
<ThemedText type="small">
The layout file in <ThemedText type="code">src/app/_layout.tsx</ThemedText> sets up
the tab navigator.
</ThemedText>
<ExternalLink href="https://docs.expo.dev/router/introduction">
<ThemedText type="linkPrimary">Learn more</ThemedText>
</ExternalLink>
</Collapsible>
<ThemedView style={styles.searchContainer}>
<SymbolView
tintColor={theme.textSecondary}
name={{ ios: 'magnifyingglass', android: 'search', web: 'search' }}
size={18}
style={styles.searchIcon}
/>
<TextInput
style={[styles.searchInput, { color: theme.text }]}
placeholder={t('explore.searchPlaceholder')}
placeholderTextColor={theme.textSecondary}
value={searchQuery}
onChangeText={setSearchQuery}
clearButtonMode="while-editing"
/>
</ThemedView>
<Collapsible title="Android, iOS, and web support">
<ThemedView type="backgroundElement" style={styles.collapsibleContent}>
<ThemedText type="small">
You can open this project on Android, iOS, and the web. To open the web version,
press <ThemedText type="smallBold">w</ThemedText> in the terminal running this
project.
<ThemedView style={styles.categoriesContainer}>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.categoriesContent}>
<Pressable
style={[
styles.categoryChip,
!selectedCategory && { backgroundColor: theme.text },
]}
onPress={() => setSelectedCategory(null)}>
<ThemedText
style={[
styles.categoryChipText,
!selectedCategory && { color: theme.background },
]}>
{t('explore.all')}
</ThemedText>
<Image
source={require('@/assets/images/tutorial-web.png')}
style={styles.imageTutorial}
/>
</ThemedView>
</Collapsible>
<Collapsible title="Images">
<ThemedText type="small">
For static images, you can use the <ThemedText type="code">@2x</ThemedText> and{' '}
<ThemedText type="code">@3x</ThemedText> suffixes to provide files for different
screen densities.
</ThemedText>
<Image source={require('@/assets/images/react-logo.png')} style={styles.imageReact} />
<ExternalLink href="https://reactnative.dev/docs/images">
<ThemedText type="linkPrimary">Learn more</ThemedText>
</ExternalLink>
</Collapsible>
<Collapsible title="Light and dark mode components">
<ThemedText type="small">
This template has light and dark mode support. The{' '}
<ThemedText type="code">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="linkPrimary">Learn more</ThemedText>
</ExternalLink>
</Collapsible>
<Collapsible title="Animations">
<ThemedText type="small">
This template includes an example of an animated component. The{' '}
<ThemedText type="code">src/components/ui/collapsible.tsx</ThemedText> component uses
the powerful <ThemedText type="code">react-native-reanimated</ThemedText> library to
animate opening this hint.
</ThemedText>
</Collapsible>
</Pressable>
{CATEGORIES.map((category) => (
<Pressable
key={category.id}
style={[
styles.categoryChip,
selectedCategory === category.id && { backgroundColor: theme.text },
]}
onPress={() =>
setSelectedCategory(
selectedCategory === category.id ? null : category.id
)
}>
<SymbolView
tintColor={
selectedCategory === category.id ? theme.background : theme.text
}
name={(CATEGORY_ICONS[category.id] as any)}
size={14}
style={styles.categoryIcon}
/>
<ThemedText
style={[
styles.categoryChipText,
selectedCategory === category.id && { color: theme.background },
]}>
{category.name}
</ThemedText>
</Pressable>
))}
</ScrollView>
</ThemedView>
<ThemedView style={styles.section}>
<ThemedView style={styles.sectionHeader}>
<ThemedText type="section">{t('explore.trending')}</ThemedText>
</ThemedView>
<ThemedView style={styles.feedsList}>
{filteredTrending.map((feed) => (
<Pressable
key={feed.id}
style={[
styles.feedCard,
{ backgroundColor: theme.backgroundElement },
]}>
<ThemedView style={styles.feedCardContent}>
<ThemedText type="defaultSemiBold" numberOfLines={1}>
{feed.title}
</ThemedText>
<ThemedText
themeColor="textSecondary"
numberOfLines={2}
style={styles.feedDescription}>
{feed.description}
</ThemedText>
<ThemedView style={styles.feedMeta}>
<ThemedText themeColor="textSecondary" type="small">
{(feed.subscribers / 1000).toFixed(1)}K subscribers
</ThemedText>
</ThemedView>
</ThemedView>
<Pressable style={[styles.addButton, { backgroundColor: theme.text }]}>
<SymbolView
tintColor={theme.background}
name={{ ios: 'plus', android: 'add', web: 'add' }}
size={16}
/>
</Pressable>
</Pressable>
))}
{filteredTrending.length === 0 && (
<ThemedView style={styles.emptyState}>
<ThemedText themeColor="textSecondary">
{t('explore.noFeeds')}
</ThemedText>
</ThemedView>
)}
</ThemedView>
</ThemedView>
<ThemedView style={styles.section}>
<ThemedView style={styles.sectionHeader}>
<ThemedText type="section">{t('explore.recommended')}</ThemedText>
</ThemedView>
<ThemedView style={styles.feedsList}>
{RECOMMENDED_PODCASTS.map((podcast) => (
<Pressable
key={podcast.id}
style={[
styles.feedCard,
{ backgroundColor: theme.backgroundElement },
]}>
<ThemedView style={styles.feedCardContent}>
<ThemedView style={styles.podcastHeader}>
<SymbolView
tintColor={theme.textSecondary}
name={{ ios: 'waveform', android: 'equalizer', web: 'equalizer' }}
size={14}
style={styles.podcastIcon}
/>
<ThemedText type="defaultSemiBold" numberOfLines={1}>
{podcast.title}
</ThemedText>
</ThemedView>
<ThemedText
themeColor="textSecondary"
numberOfLines={2}
style={styles.feedDescription}>
{podcast.description} {podcast.publisher}
</ThemedText>
</ThemedView>
<Pressable style={[styles.addButton, { backgroundColor: theme.text }]}>
<SymbolView
tintColor={theme.background}
name={{ ios: 'plus', android: 'add', web: 'add' }}
size={16}
/>
</Pressable>
</Pressable>
))}
</ThemedView>
</ThemedView>
{Platform.OS === 'web' && <WebBadge />}
</ThemedView>
</ScrollView>
);
@@ -138,44 +319,97 @@ const styles = StyleSheet.create({
maxWidth: MaxContentWidth,
flexGrow: 1,
},
titleContainer: {
gap: Spacing.three,
alignItems: 'center',
header: {
paddingHorizontal: Spacing.four,
paddingVertical: Spacing.six,
paddingVertical: Spacing.four,
gap: Spacing.two,
},
centerText: {
textAlign: 'center',
},
pressed: {
opacity: 0.7,
},
linkButton: {
searchContainer: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: Spacing.four,
paddingVertical: Spacing.two,
borderRadius: Spacing.five,
justifyContent: 'center',
gap: Spacing.one,
alignItems: 'center',
},
sectionsWrapper: {
gap: Spacing.five,
paddingHorizontal: Spacing.four,
paddingTop: Spacing.three,
},
collapsibleContent: {
alignItems: 'center',
},
imageTutorial: {
width: '100%',
aspectRatio: 296 / 171,
marginHorizontal: Spacing.four,
marginBottom: Spacing.two,
borderRadius: Spacing.three,
marginTop: Spacing.two,
},
imageReact: {
width: 100,
height: 100,
alignSelf: 'center',
searchIcon: {
marginRight: Spacing.two,
},
searchInput: {
flex: 1,
fontSize: 16,
paddingVertical: Spacing.one,
},
categoriesContainer: {
marginBottom: Spacing.four,
},
categoriesContent: {
paddingHorizontal: Spacing.four,
gap: Spacing.two,
},
categoryChip: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: Spacing.three,
paddingVertical: Spacing.two,
borderRadius: Spacing.six,
backgroundColor: '#e5e5ea',
minWidth: 80,
},
categoryChipText: {
fontSize: 14,
fontWeight: '500',
},
categoryIcon: {
marginRight: Spacing.one,
},
section: {
marginBottom: Spacing.four,
},
sectionHeader: {
paddingHorizontal: Spacing.four,
paddingVertical: Spacing.two,
},
feedsList: {
gap: Spacing.two,
paddingHorizontal: Spacing.four,
},
feedCard: {
flexDirection: 'row',
alignItems: 'center',
padding: Spacing.three,
borderRadius: Spacing.three,
},
feedCardContent: {
flex: 1,
gap: Spacing.one,
},
feedDescription: {
fontSize: 14,
marginTop: Spacing.one,
},
feedMeta: {
marginTop: Spacing.one,
},
addButton: {
width: 32,
height: 32,
borderRadius: 16,
justifyContent: 'center',
alignItems: 'center',
marginLeft: Spacing.two,
},
podcastHeader: {
flexDirection: 'row',
alignItems: 'center',
gap: Spacing.two,
},
podcastIcon: {
flexShrink: 0,
},
emptyState: {
paddingVertical: Spacing.six,
alignItems: 'center',
},
});

View File

@@ -1,61 +1,129 @@
import * as Device from 'expo-device';
import { Platform, StyleSheet } from 'react-native';
import React from 'react';
import { FlatList, StyleSheet, View, Platform } from 'react-native';
import { RefreshControl } from 'react-native';
import { Animated, Easing } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { AnimatedIcon } from '@/components/animated-icon';
import { HintRow } from '@/components/hint-row';
import { useFeedList } from '@/hooks/use-feed-list';
import { FeedItemCard } from '@/components/feed-item-card';
import { ThemedText } from '@/components/themed-text';
import { ThemedView } from '@/components/themed-view';
import { WebBadge } from '@/components/web-badge';
import { BottomTabInset, MaxContentWidth, Spacing } from '@/constants/theme';
import { Colors, MaxContentWidth, Spacing } from '@/constants/theme';
import { useColorScheme } from 'react-native';
function getDevMenuHint() {
if (Platform.OS === 'web') {
return <ThemedText type="small">use browser devtools</ThemedText>;
}
if (Device.isDevice) {
return (
<ThemedText type="small">
shake device or press <ThemedText type="code">m</ThemedText> in terminal
</ThemedText>
);
}
const shortcut = Platform.OS === 'android' ? 'cmd+m (or ctrl+m)' : 'cmd+d';
return (
<ThemedText type="small">
press <ThemedText type="code">{shortcut}</ThemedText>
</ThemedText>
);
}
const EXTRACT_HTML_TEXT = (html: string): string => html.replace(/<[^>]*>/g, '');
const EXCERPT_LENGTH = 200;
const ANIMATION_DELAY_MULTIPLIER = 50;
const MAX_ANIMATION_DELAY = 500;
export default function HomeScreen() {
const { feedItems, loading, hasMore, loadMore, refreshFeed, isRefreshing, error } = useFeedList();
const scheme = useColorScheme();
const colors = Colors[scheme === 'unspecified' ? 'light' : scheme];
const fadeInAnim = React.useRef(new Animated.Value(0)).current;
React.useEffect(() => {
const anim = Animated.timing(fadeInAnim, {
toValue: 1,
duration: 300,
easing: Easing.ease,
useNativeDriver: true,
});
anim.start();
return () => anim.stop();
}, []);
const renderFeedItem = React.useCallback(({ item, index }: { item: any; index: number }) => {
const itemFadeIn = React.useMemo(() => new Animated.Value(0), []);
React.useEffect(() => {
const anim = Animated.timing(itemFadeIn, {
toValue: 1,
duration: 200,
delay: Math.min(index * ANIMATION_DELAY_MULTIPLIER, MAX_ANIMATION_DELAY),
easing: Easing.ease,
useNativeDriver: true,
});
const timeout = setTimeout(() => anim.start(), 100);
return () => {
clearTimeout(timeout);
anim.stop();
};
}, [itemFadeIn, index]);
return (
<Animated.View style={[styles.itemContainer, { opacity: itemFadeIn, transform: [{ translateY: itemFadeIn.interpolate({ inputRange: [0, 1], outputRange: [10, 0] }) }] }]}>
<FeedItemCard item={item} />
</Animated.View>
);
}, []);
const renderHeader = () => (
<ThemedView style={styles.header}>
<ThemedText type="title" style={styles.headerTitle}>Today</ThemedText>
</ThemedView>
);
const renderFooter = () => {
if (!loading) return null;
return (
<ThemedView style={styles.footer}>
<ThemedText type="small">Loading more...</ThemedText>
</ThemedView>
);
};
const renderEmpty = () => (
<ThemedView style={styles.emptyContainer}>
{error ? (
<>
<ThemedText type="title">Error Loading Feed</ThemedText>
<ThemedText type="default" style={styles.emptySubtitle}>
{error}
</ThemedText>
<ThemedText type="small" style={styles.retryHint}>
Pull down to retry
</ThemedText>
</>
) : (
<>
<ThemedText type="title">No articles yet</ThemedText>
<ThemedText type="default" style={styles.emptySubtitle}>
Add a feed to start reading
</ThemedText>
</>
)}
</ThemedView>
);
return (
<ThemedView style={styles.container}>
<SafeAreaView style={styles.safeArea}>
<ThemedView style={styles.heroSection}>
<AnimatedIcon />
<ThemedText type="title" style={styles.title}>
Welcome to&nbsp;Expo
</ThemedText>
</ThemedView>
<ThemedText type="code" style={styles.code}>
get started
</ThemedText>
<ThemedView type="backgroundElement" style={styles.stepContainer}>
<HintRow
title="Try editing"
hint={<ThemedText type="code">src/app/index.tsx</ThemedText>}
/>
<HintRow title="Dev tools" hint={getDevMenuHint()} />
<HintRow
title="Fresh start"
hint={<ThemedText type="code">npm run reset-project</ThemedText>}
/>
</ThemedView>
{Platform.OS === 'web' && <WebBadge />}
<SafeAreaView style={styles.safeArea} edges={['top']}>
{renderHeader()}
<Animated.View style={{ opacity: fadeInAnim }}>
{feedItems.length === 0 ? (
renderEmpty()
) : (
<FlatList
data={feedItems}
keyExtractor={(item) => item.id}
renderItem={renderFeedItem}
onEndReached={loadMore}
onEndReachedThreshold={0.5}
ListFooterComponent={renderFooter}
refreshControl={
<RefreshControl
refreshing={isRefreshing}
onRefresh={refreshFeed}
colors={[colors.text]}
/>
}
contentContainerStyle={styles.listContent}
showsVerticalScrollIndicator={false}
/>
)}
</Animated.View>
</SafeAreaView>
</ThemedView>
);
@@ -64,35 +132,45 @@ export default function HomeScreen() {
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'center',
flexDirection: 'row',
backgroundColor: Colors.light.background,
},
safeArea: {
flex: 1,
},
header: {
paddingHorizontal: Spacing.four,
alignItems: 'center',
gap: Spacing.three,
paddingBottom: BottomTabInset + Spacing.three,
paddingVertical: Spacing.three,
maxWidth: MaxContentWidth,
},
heroSection: {
alignItems: 'center',
justifyContent: 'center',
flex: 1,
headerTitle: {
fontSize: 32,
fontWeight: '700',
},
listContent: {
paddingHorizontal: Spacing.four,
gap: Spacing.four,
paddingBottom: Spacing.six,
},
title: {
textAlign: 'center',
itemContainer: {
maxWidth: MaxContentWidth,
},
code: {
textTransform: 'uppercase',
},
stepContainer: {
gap: Spacing.three,
alignSelf: 'stretch',
paddingHorizontal: Spacing.three,
footer: {
paddingVertical: Spacing.four,
borderRadius: Spacing.four,
alignItems: 'center',
},
emptyContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
paddingHorizontal: Spacing.four,
gap: Spacing.two,
},
emptySubtitle: {
textAlign: 'center',
color: '#8e8e93',
},
retryHint: {
marginTop: Spacing.three,
color: '#007AFF',
fontSize: 12,
},
});

200
src/app/search.tsx Normal file
View File

@@ -0,0 +1,200 @@
import React, { useEffect, useState } from 'react';
import { StyleSheet, ScrollView, KeyboardAvoidingView, Platform, TouchableOpacity } from 'react-native';
import { SearchInput } from '@/components/search-input';
import { SearchSort } from '@/components/search-sort';
import { SearchResults } from '@/components/search-results';
import { SearchFilters } from '@/components/search-filters';
import { FilterOptions } from '@/components/filter-options';
import { combinedSearch } from '@/services/search-service';
import { useSearchStore } from '@/stores/search-store';
import { getAllSubscriptions } from '@/services/database';
import { SearchResult, SearchSortOption, SearchFilters as SearchFiltersType } from '@/types/feed';
import { ThemedText } from '@/components/themed-text';
import { ThemedView } from '@/components/themed-view';
import { Colors, Spacing } from '@/constants/theme';
import { useColorScheme } from 'react-native';
export default function SearchScreen() {
const scheme = useColorScheme();
const colors = Colors[scheme === 'unspecified' ? 'light' : scheme];
const { query, setSort, setPage, setLoading, setError, sort, page, pageSize, loadHistory, loading, filters, setFilters } = useSearchStore();
const [articles, setArticles] = useState<SearchResult[]>([]);
const [feeds, setFeeds] = useState<SearchResult[]>([]);
const [hasMore, setHasMore] = useState(false);
const [isInitialLoad, setIsInitialLoad] = useState(true);
const [filterModalVisible, setFilterModalVisible] = useState(false);
const [availableFeeds, setAvailableFeeds] = useState<Array<{ id: string; title: string }>>([]);
// Load search history and available feeds on mount
useEffect(() => {
loadHistory();
const loadAvailableFeeds = async () => {
try {
const subscriptions = await getAllSubscriptions();
setAvailableFeeds(subscriptions.map(s => ({ id: s.id, title: s.title })));
} catch (error) {
console.error('Failed to load available feeds:', error);
}
};
loadAvailableFeeds();
}, []);
// Perform search when query, sort, filters, or page changes
useEffect(() => {
const performSearch = async () => {
if (!query || query.trim().length === 0) {
setArticles([]);
setFeeds([]);
setHasMore(false);
return;
}
if (isInitialLoad) {
setLoading(true);
}
try {
const results = await combinedSearch(query, {
filters,
sort,
page,
pageSize,
});
if (page === 1) {
setArticles(results.articles);
setFeeds(results.feeds);
} else {
setArticles(prev => [...prev, ...results.articles]);
}
setHasMore(results.articles.length >= pageSize);
setError(null);
} catch (error) {
setError(error instanceof Error ? error.message : 'Search failed');
console.error('Search error:', error);
} finally {
setLoading(false);
setIsInitialLoad(false);
}
};
const timer = setTimeout(performSearch, 300);
return () => clearTimeout(timer);
}, [query, sort, filters, page, pageSize]);
const handleSortChange = (newSort: SearchSortOption) => {
setSort(newSort);
setPage(1);
};
const handleFilterChange = (newFilters: Partial<SearchFiltersType>) => {
setFilters(newFilters);
setPage(1);
};
const handleClearAllFilters = () => {
setFilters({});
setPage(1);
};
const handleApplyFilters = (newFilters: SearchFiltersType) => {
setFilters(newFilters);
setPage(1);
};
const handleLoadMore = () => {
if (!loading && hasMore) {
setPage(page + 1);
}
};
const handleResultPress = (result: SearchResult) => {
console.log('Result pressed:', result);
};
return (
<KeyboardAvoidingView
style={styles.container}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
keyboardVerticalOffset={0}
>
<ScrollView
style={styles.scrollView}
keyboardShouldPersistTaps="handled"
scrollEnabled={false}
>
<SearchInput autoFocus />
<ThemedView style={styles.filtersRow}>
<SearchSort sort={sort} onSortChange={handleSortChange} />
<TouchableOpacity
style={styles.filterButton}
onPress={() => setFilterModalVisible(true)}
>
<ThemedText style={styles.filterButtonText}>🔍 Filters</ThemedText>
</TouchableOpacity>
</ThemedView>
<SearchFilters
filters={filters}
onFilterChange={handleFilterChange}
onClearAll={handleClearAllFilters}
availableFeeds={availableFeeds}
/>
</ScrollView>
<ScrollView
style={styles.resultsContainer}
keyboardShouldPersistTaps="handled"
>
<SearchResults
articles={articles}
feeds={feeds}
loading={loading}
onLoadMore={handleLoadMore}
onResultPress={handleResultPress}
hasMore={hasMore}
/>
</ScrollView>
<FilterOptions
visible={filterModalVisible}
onClose={() => setFilterModalVisible(false)}
currentFilters={filters}
onApplyFilters={handleApplyFilters}
availableFeeds={availableFeeds}
/>
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
scrollView: {
zIndex: 10,
},
filtersRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
filterButton: {
paddingHorizontal: Spacing.three,
paddingVertical: Spacing.two,
borderRadius: Spacing.two,
backgroundColor: '#e5e5ea',
marginLeft: Spacing.two,
},
filterButtonText: {
fontSize: 14,
color: '#8e8e93',
},
resultsContainer: {
flex: 1,
},
});

6
src/app/settings.tsx Normal file
View File

@@ -0,0 +1,6 @@
import React from 'react';
import SettingsScreen from '@/components/settings';
export default function SettingsPage() {
return <SettingsScreen />;
}