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

View File

@@ -1 +0,0 @@
{ "recommendations": ["expo.vscode-expo-tools"] }

View File

@@ -1,7 +0,0 @@
{
"editor.codeActionsOnSave": {
"source.fixAll": "explicit",
"source.organizeImports": "explicit",
"source.sortMembers": "explicit"
}
}

View File

@@ -39,6 +39,13 @@
"experiments": { "experiments": {
"typedRoutes": true, "typedRoutes": true,
"reactCompiler": true "reactCompiler": true
} },
"extra": {
"router": {},
"eas": {
"projectId": "006dd772-380c-4e20-9754-42a15ad83fc9"
}
},
"owner": "mikefrenodev"
} }
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 244 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 383 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 513 B

7902
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -14,6 +14,8 @@
"@react-navigation/bottom-tabs": "^7.15.5", "@react-navigation/bottom-tabs": "^7.15.5",
"@react-navigation/elements": "^2.9.10", "@react-navigation/elements": "^2.9.10",
"@react-navigation/native": "^7.1.33", "@react-navigation/native": "^7.1.33",
"@tanstack/react-query": "^5.95.2",
"axios": "^1.14.0",
"expo": "55.0.10-canary-20260328-2049187", "expo": "55.0.10-canary-20260328-2049187",
"expo-constants": "55.0.10-canary-20260328-2049187", "expo-constants": "55.0.10-canary-20260328-2049187",
"expo-device": "55.0.11-canary-20260328-2049187", "expo-device": "55.0.11-canary-20260328-2049187",
@@ -21,24 +23,31 @@
"expo-glass-effect": "55.0.9-canary-20260328-2049187", "expo-glass-effect": "55.0.9-canary-20260328-2049187",
"expo-image": "55.0.7-canary-20260328-2049187", "expo-image": "55.0.7-canary-20260328-2049187",
"expo-linking": "55.0.10-canary-20260328-2049187", "expo-linking": "55.0.10-canary-20260328-2049187",
"expo-localization": "^55.0.10-canary-20260328-bdc6273",
"expo-notifications": "^55.0.15-canary-20260328-bdc6273",
"expo-router": "55.0.9-canary-20260328-2049187", "expo-router": "55.0.9-canary-20260328-2049187",
"expo-splash-screen": "55.0.14-canary-20260328-2049187", "expo-splash-screen": "55.0.14-canary-20260328-2049187",
"expo-sqlite": "^55.0.12-canary-20260328-bdc6273",
"expo-status-bar": "55.0.5-canary-20260328-2049187", "expo-status-bar": "55.0.5-canary-20260328-2049187",
"expo-symbols": "55.0.6-canary-20260328-2049187", "expo-symbols": "55.0.6-canary-20260328-2049187",
"expo-system-ui": "55.0.12-canary-20260328-2049187", "expo-system-ui": "55.0.12-canary-20260328-2049187",
"expo-task-manager": "^55.0.11-canary-20260328-bdc6273",
"expo-web-browser": "55.0.11-canary-20260328-2049187", "expo-web-browser": "55.0.11-canary-20260328-2049187",
"react": "19.2.0", "react": "19.2.0",
"react-dom": "19.2.0", "react-dom": "19.2.0",
"react-native": "0.83.4", "react-native": "0.83.4",
"react-native-gesture-handler": "~2.30.0", "react-native-gesture-handler": "~2.30.0",
"react-native-worklets": "0.7.2",
"react-native-reanimated": "4.2.1", "react-native-reanimated": "4.2.1",
"react-native-safe-area-context": "~5.6.2", "react-native-safe-area-context": "~5.6.2",
"react-native-screens": "~4.23.0", "react-native-screens": "~4.23.0",
"react-native-web": "~0.21.0" "react-native-web": "~0.21.0",
"react-native-worklets": "0.7.2",
"xml2js": "^0.6.2",
"zustand": "^5.0.12"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "~19.2.2", "@types/react": "~19.2.2",
"@types/xml2js": "^0.4.14",
"typescript": "~5.9.2" "typescript": "~5.9.2"
}, },
"private": true "private": true

92
src/ISSUE_PLAN.md Normal file
View File

@@ -0,0 +1,92 @@
# Plan
## FRE-517: Build settings and preferences screen
### Overview
Create a settings screen with the following sections:
- Sync intervals
- Notifications
- Theme (light/dark/system)
- Reading preferences
- Account management
### Implementation Plan
#### 1. Types and Interfaces
- [ ] Add `SyncInterval` type to `src/types/feed.ts`
- [ ] Add `NotificationPreference` type to `src/types/feed.ts`
- [ ] Add `ReadingPreference` type to `src/types/feed.ts`
- [ ] Add `AccountSettings` type to `src/types/global.d.ts`
#### 2. Settings State Management
- [ ] Create `settings-store` in `src/stores/settings-store.ts`
- [ ] Initialize default settings
- [ ] Add actions for updating all preference types
#### 3. Settings Screen Component
- [ ] Create `src/components/settings.tsx` with:
- Sync interval selector
- Notification toggle switches
- Theme selector
- Reading preference toggles
- Account section placeholder
#### 4. Settings Navigation
- [ ] Add route `src/app/settings.tsx`
- [ ] Update `app-tabs.tsx` to include settings tab
- [ ] Add navigation to `AppLayout.tsx`
#### 5. Integration
- [ ] Connect settings to feed subscription intervals
- [ ] Connect theme settings to `useTheme` hook
- [ ] Connect notifications to expo-notifications
---
## FRE-520: Add push notifications
### Overview
Implement push notifications for new articles, episode releases, and custom alerts using expo-notifications.
### Implementation Plan
#### 1. Notifications Types
- [ ] Add `NotificationType` enum to `src/types/global.d.ts`
- [ ] Add `NotificationConfig` interface to `src/types/global.d.ts`
- [ ] Add `PushNotificationConfig` interface to `src/types/global.d.ts`
#### 2. Notifications Service
- [ ] Create `src/services/notification-service.ts`
- [ ] Implement `requestPermission()`
- [ ] Implement `scheduleNotification(type: NotificationType)`
- [ ] Implement `onNotificationReceived()`
- [ ] Implement `onNotificationOpened()`
- [ ] Implement `clearAllNotifications()`
#### 3. Notification Handlers
- [ ] Create `src/hooks/use-notifications.ts`
- [ ] Add notification state and actions
- [ ] Add notification display component
#### 4. Notification Settings
- [ ] Add notification type preferences in settings
- [ ] Add push notification toggle in settings
- [ ] Add custom alert configuration
#### 5. Integration
- [ ] Connect notifications to feed updates
- [ ] Schedule notifications on feed fetch
- [ ] Handle notification deep linking
---
## Dependencies and Tools
- expo-notifications (already installed)
- expo-task-manager (already installed)
- expo-sqlite (already installed)
- zustand (already installed)
## Notes
- Settings will persist via zustand persist middleware
- Notifications will use Expo's token-based system
- Feed subscriptions will respect sync interval settings

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 React, { useState } from 'react';
import { SymbolView } from 'expo-symbols'; import {
import React from 'react'; Platform,
import { Platform, Pressable, ScrollView, StyleSheet } from 'react-native'; Pressable,
ScrollView,
StyleSheet,
TextInput,
View,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context'; 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 { ThemedText } from '@/components/themed-text';
import { ThemedView } from '@/components/themed-view'; 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 { BottomTabInset, MaxContentWidth, Spacing } from '@/constants/theme';
import { useTheme } from '@/hooks/use-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 safeAreaInsets = useSafeAreaInsets();
const insets = { const insets = {
...safeAreaInsets, ...safeAreaInsets,
bottom: safeAreaInsets.bottom + BottomTabInset + Spacing.three, bottom: safeAreaInsets.bottom + BottomTabInset + Spacing.three,
}; };
const theme = useTheme(); const theme = useTheme();
const [searchQuery, setSearchQuery] = useState('');
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
const contentPlatformStyle = Platform.select({ const contentPlatformStyle = Platform.select({
android: { 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 ( return (
<ScrollView <ScrollView
style={[styles.scrollView, { backgroundColor: theme.background }]} style={[styles.scrollView, { backgroundColor: theme.background }]}
contentInset={insets} contentInset={insets}
contentContainerStyle={[styles.contentContainer, contentPlatformStyle]}> contentContainerStyle={[styles.contentContainer, contentPlatformStyle]}>
<ThemedView style={styles.container}> <ThemedView style={styles.container}>
<ThemedView style={styles.titleContainer}> <ThemedView style={styles.header}>
<ThemedText type="subtitle">Explore</ThemedText> <ThemedText type="title">{t('tab.explore')}</ThemedText>
<ThemedText style={styles.centerText} themeColor="textSecondary"> <ThemedText themeColor="textSecondary">{t('explore.discover')}</ThemedText>
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> </ThemedView>
<ThemedView style={styles.sectionsWrapper}> <ThemedView style={styles.searchContainer}>
<Collapsible title="File-based routing"> <SymbolView
<ThemedText type="small"> tintColor={theme.textSecondary}
This app has two screens: <ThemedText type="code">src/app/index.tsx</ThemedText> and{' '} name={{ ios: 'magnifyingglass', android: 'search', web: 'search' }}
<ThemedText type="code">src/app/explore.tsx</ThemedText> size={18}
</ThemedText> style={styles.searchIcon}
<ThemedText type="small"> />
The layout file in <ThemedText type="code">src/app/_layout.tsx</ThemedText> sets up <TextInput
the tab navigator. style={[styles.searchInput, { color: theme.text }]}
</ThemedText> placeholder={t('explore.searchPlaceholder')}
<ExternalLink href="https://docs.expo.dev/router/introduction"> placeholderTextColor={theme.textSecondary}
<ThemedText type="linkPrimary">Learn more</ThemedText> value={searchQuery}
</ExternalLink> onChangeText={setSearchQuery}
</Collapsible> clearButtonMode="while-editing"
/>
</ThemedView>
<Collapsible title="Android, iOS, and web support"> <ThemedView style={styles.categoriesContainer}>
<ThemedView type="backgroundElement" style={styles.collapsibleContent}> <ScrollView
<ThemedText type="small"> horizontal
You can open this project on Android, iOS, and the web. To open the web version, showsHorizontalScrollIndicator={false}
press <ThemedText type="smallBold">w</ThemedText> in the terminal running this contentContainerStyle={styles.categoriesContent}>
project. <Pressable
style={[
styles.categoryChip,
!selectedCategory && { backgroundColor: theme.text },
]}
onPress={() => setSelectedCategory(null)}>
<ThemedText
style={[
styles.categoryChipText,
!selectedCategory && { color: theme.background },
]}>
{t('explore.all')}
</ThemedText> </ThemedText>
<Image </Pressable>
source={require('@/assets/images/tutorial-web.png')} {CATEGORIES.map((category) => (
style={styles.imageTutorial} <Pressable
/> key={category.id}
</ThemedView> style={[
</Collapsible> styles.categoryChip,
selectedCategory === category.id && { backgroundColor: theme.text },
<Collapsible title="Images"> ]}
<ThemedText type="small"> onPress={() =>
For static images, you can use the <ThemedText type="code">@2x</ThemedText> and{' '} setSelectedCategory(
<ThemedText type="code">@3x</ThemedText> suffixes to provide files for different selectedCategory === category.id ? null : category.id
screen densities. )
</ThemedText> }>
<Image source={require('@/assets/images/react-logo.png')} style={styles.imageReact} /> <SymbolView
<ExternalLink href="https://reactnative.dev/docs/images"> tintColor={
<ThemedText type="linkPrimary">Learn more</ThemedText> selectedCategory === category.id ? theme.background : theme.text
</ExternalLink> }
</Collapsible> name={(CATEGORY_ICONS[category.id] as any)}
size={14}
<Collapsible title="Light and dark mode components"> style={styles.categoryIcon}
<ThemedText type="small"> />
This template has light and dark mode support. The{' '} <ThemedText
<ThemedText type="code">useColorScheme()</ThemedText> hook lets you inspect what the style={[
user&apos;s current color scheme is, and so you can adjust UI colors accordingly. styles.categoryChipText,
</ThemedText> selectedCategory === category.id && { color: theme.background },
<ExternalLink href="https://docs.expo.dev/develop/user-interface/color-themes/"> ]}>
<ThemedText type="linkPrimary">Learn more</ThemedText> {category.name}
</ExternalLink> </ThemedText>
</Collapsible> </Pressable>
))}
<Collapsible title="Animations"> </ScrollView>
<ThemedText type="small"> </ThemedView>
This template includes an example of an animated component. The{' '}
<ThemedText type="code">src/components/ui/collapsible.tsx</ThemedText> component uses <ThemedView style={styles.section}>
the powerful <ThemedText type="code">react-native-reanimated</ThemedText> library to <ThemedView style={styles.sectionHeader}>
animate opening this hint. <ThemedText type="section">{t('explore.trending')}</ThemedText>
</ThemedText> </ThemedView>
</Collapsible> <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> </ThemedView>
{Platform.OS === 'web' && <WebBadge />}
</ThemedView> </ThemedView>
</ScrollView> </ScrollView>
); );
@@ -138,44 +319,97 @@ const styles = StyleSheet.create({
maxWidth: MaxContentWidth, maxWidth: MaxContentWidth,
flexGrow: 1, flexGrow: 1,
}, },
titleContainer: { header: {
gap: Spacing.three,
alignItems: 'center',
paddingHorizontal: Spacing.four, paddingHorizontal: Spacing.four,
paddingVertical: Spacing.six, paddingVertical: Spacing.four,
gap: Spacing.two,
}, },
centerText: { searchContainer: {
textAlign: 'center',
},
pressed: {
opacity: 0.7,
},
linkButton: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: Spacing.four, paddingHorizontal: Spacing.four,
paddingVertical: Spacing.two, paddingVertical: Spacing.two,
borderRadius: Spacing.five, marginHorizontal: Spacing.four,
justifyContent: 'center', marginBottom: Spacing.two,
gap: Spacing.one,
alignItems: 'center',
},
sectionsWrapper: {
gap: Spacing.five,
paddingHorizontal: Spacing.four,
paddingTop: Spacing.three,
},
collapsibleContent: {
alignItems: 'center',
},
imageTutorial: {
width: '100%',
aspectRatio: 296 / 171,
borderRadius: Spacing.three, borderRadius: Spacing.three,
marginTop: Spacing.two,
}, },
imageReact: { searchIcon: {
width: 100, marginRight: Spacing.two,
height: 100, },
alignSelf: 'center', 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 React from 'react';
import { Platform, StyleSheet } from 'react-native'; 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 { SafeAreaView } from 'react-native-safe-area-context';
import { useFeedList } from '@/hooks/use-feed-list';
import { AnimatedIcon } from '@/components/animated-icon'; import { FeedItemCard } from '@/components/feed-item-card';
import { HintRow } from '@/components/hint-row';
import { ThemedText } from '@/components/themed-text'; import { ThemedText } from '@/components/themed-text';
import { ThemedView } from '@/components/themed-view'; import { ThemedView } from '@/components/themed-view';
import { WebBadge } from '@/components/web-badge'; import { Colors, MaxContentWidth, Spacing } from '@/constants/theme';
import { BottomTabInset, MaxContentWidth, Spacing } from '@/constants/theme'; import { useColorScheme } from 'react-native';
function getDevMenuHint() { const EXTRACT_HTML_TEXT = (html: string): string => html.replace(/<[^>]*>/g, '');
if (Platform.OS === 'web') { const EXCERPT_LENGTH = 200;
return <ThemedText type="small">use browser devtools</ThemedText>; const ANIMATION_DELAY_MULTIPLIER = 50;
} const MAX_ANIMATION_DELAY = 500;
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>
);
}
export default function HomeScreen() { 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 ( return (
<ThemedView style={styles.container}> <ThemedView style={styles.container}>
<SafeAreaView style={styles.safeArea}> <SafeAreaView style={styles.safeArea} edges={['top']}>
<ThemedView style={styles.heroSection}> {renderHeader()}
<AnimatedIcon />
<ThemedText type="title" style={styles.title}> <Animated.View style={{ opacity: fadeInAnim }}>
Welcome to&nbsp;Expo {feedItems.length === 0 ? (
</ThemedText> renderEmpty()
</ThemedView> ) : (
<FlatList
<ThemedText type="code" style={styles.code}> data={feedItems}
get started keyExtractor={(item) => item.id}
</ThemedText> renderItem={renderFeedItem}
onEndReached={loadMore}
<ThemedView type="backgroundElement" style={styles.stepContainer}> onEndReachedThreshold={0.5}
<HintRow ListFooterComponent={renderFooter}
title="Try editing" refreshControl={
hint={<ThemedText type="code">src/app/index.tsx</ThemedText>} <RefreshControl
/> refreshing={isRefreshing}
<HintRow title="Dev tools" hint={getDevMenuHint()} /> onRefresh={refreshFeed}
<HintRow colors={[colors.text]}
title="Fresh start" />
hint={<ThemedText type="code">npm run reset-project</ThemedText>} }
/> contentContainerStyle={styles.listContent}
</ThemedView> showsVerticalScrollIndicator={false}
/>
{Platform.OS === 'web' && <WebBadge />} )}
</Animated.View>
</SafeAreaView> </SafeAreaView>
</ThemedView> </ThemedView>
); );
@@ -64,35 +132,45 @@ export default function HomeScreen() {
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {
flex: 1, flex: 1,
justifyContent: 'center', backgroundColor: Colors.light.background,
flexDirection: 'row',
}, },
safeArea: { safeArea: {
flex: 1, flex: 1,
},
header: {
paddingHorizontal: Spacing.four, paddingHorizontal: Spacing.four,
alignItems: 'center', paddingVertical: Spacing.three,
gap: Spacing.three,
paddingBottom: BottomTabInset + Spacing.three,
maxWidth: MaxContentWidth, maxWidth: MaxContentWidth,
}, },
heroSection: { headerTitle: {
alignItems: 'center', fontSize: 32,
justifyContent: 'center', fontWeight: '700',
flex: 1, },
listContent: {
paddingHorizontal: Spacing.four, paddingHorizontal: Spacing.four,
gap: Spacing.four, paddingBottom: Spacing.six,
}, },
title: { itemContainer: {
textAlign: 'center', maxWidth: MaxContentWidth,
}, },
code: { footer: {
textTransform: 'uppercase',
},
stepContainer: {
gap: Spacing.three,
alignSelf: 'stretch',
paddingHorizontal: Spacing.three,
paddingVertical: Spacing.four, 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 />;
}

View File

@@ -3,6 +3,7 @@ import React from 'react';
import { useColorScheme } from 'react-native'; import { useColorScheme } from 'react-native';
import { Colors } from '@/constants/theme'; import { Colors } from '@/constants/theme';
import { t } from '@/i18n';
export default function AppTabs() { export default function AppTabs() {
const scheme = useColorScheme(); const scheme = useColorScheme();
@@ -14,7 +15,7 @@ export default function AppTabs() {
indicatorColor={colors.backgroundElement} indicatorColor={colors.backgroundElement}
labelStyle={{ selected: { color: colors.text } }}> labelStyle={{ selected: { color: colors.text } }}>
<NativeTabs.Trigger name="index"> <NativeTabs.Trigger name="index">
<NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label> <NativeTabs.Trigger.Label>{t('tab.home')}</NativeTabs.Trigger.Label>
<NativeTabs.Trigger.Icon <NativeTabs.Trigger.Icon
src={require('@/assets/images/tabIcons/home.png')} src={require('@/assets/images/tabIcons/home.png')}
renderingMode="template" renderingMode="template"
@@ -22,12 +23,28 @@ export default function AppTabs() {
</NativeTabs.Trigger> </NativeTabs.Trigger>
<NativeTabs.Trigger name="explore"> <NativeTabs.Trigger name="explore">
<NativeTabs.Trigger.Label>Explore</NativeTabs.Trigger.Label> <NativeTabs.Trigger.Label>{t('tab.explore')}</NativeTabs.Trigger.Label>
<NativeTabs.Trigger.Icon <NativeTabs.Trigger.Icon
src={require('@/assets/images/tabIcons/explore.png')} src={require('@/assets/images/tabIcons/explore.png')}
renderingMode="template" renderingMode="template"
/> />
</NativeTabs.Trigger> </NativeTabs.Trigger>
<NativeTabs.Trigger name="search">
<NativeTabs.Trigger.Label>Search</NativeTabs.Trigger.Label>
<NativeTabs.Trigger.Icon
src={require('@/assets/images/tabIcons/search.png')}
renderingMode="template"
/>
</NativeTabs.Trigger>
<NativeTabs.Trigger name="settings">
<NativeTabs.Trigger.Label>{t('tab.settings')}</NativeTabs.Trigger.Label>
<NativeTabs.Trigger.Icon
src={require('@/assets/images/tabIcons/settings.png')}
renderingMode="template"
/>
</NativeTabs.Trigger>
</NativeTabs> </NativeTabs>
); );
} }

View File

@@ -0,0 +1,134 @@
import React from 'react';
import { StyleSheet, TouchableOpacity, Linking } from 'react-native';
import { SearchResult } from '@/types/feed';
import { ThemedText } from './themed-text';
import { ThemedView } from './themed-view';
import { Colors, Spacing } from '@/constants/theme';
import { useColorScheme } from 'react-native';
interface ArticleCardProps {
result: SearchResult;
onPress?: (result: SearchResult) => void;
}
export function ArticleCard({ result, onPress }: ArticleCardProps) {
const scheme = useColorScheme();
const colors = Colors[scheme === 'unspecified' ? 'light' : scheme];
const handlePress = () => {
if (onPress) {
onPress(result);
} else if (result.link) {
Linking.openURL(result.link);
}
};
const formatDate = (date?: Date) => {
if (!date) return '';
const now = new Date();
const diff = now.getTime() - date.getTime();
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
if (days === 0) {
const hours = Math.floor(diff / (1000 * 60 * 60));
return hours === 0 ? 'Just now' : `${hours}h ago`;
} else if (days === 1) {
return 'Yesterday';
} else if (days < 7) {
return `${days}d ago`;
} else {
return date.toLocaleDateString();
}
};
return (
<TouchableOpacity
style={[styles.container, { backgroundColor: colors.background }]}
onPress={handlePress}
activeOpacity={0.7}
>
<ThemedView style={styles.content}>
<ThemedText type="smallBold" style={styles.title} numberOfLines={2}>
{result.title}
</ThemedText>
{result.snippet && (
<ThemedText type="small" style={styles.snippet} numberOfLines={2}>
{result.snippet}
</ThemedText>
)}
<ThemedView style={styles.metaRow}>
{result.feedTitle && (
<ThemedText type="small" style={styles.feedTitle} numberOfLines={1}>
{result.feedTitle}
</ThemedText>
)}
{result.published && (
<ThemedText type="small" style={styles.date}>
{formatDate(result.published)}
</ThemedText>
)}
</ThemedView>
{result.type === 'feed' && (
<ThemedView style={styles.feedBadge}>
<ThemedText type="small" style={styles.feedBadgeText}>
Feed
</ThemedText>
</ThemedView>
)}
</ThemedView>
</TouchableOpacity>
);
}
const styles = StyleSheet.create({
container: {
borderRadius: Spacing.three,
padding: Spacing.three,
marginBottom: Spacing.two,
borderWidth: 1,
borderColor: '#e5e5ea',
},
content: {
flex: 1,
},
title: {
fontSize: 16,
marginBottom: Spacing.one,
},
snippet: {
fontSize: 14,
color: '#8e8e93',
marginBottom: Spacing.two,
lineHeight: 20,
},
metaRow: {
flexDirection: 'row',
alignItems: 'center',
gap: Spacing.two,
},
feedTitle: {
color: '#007AFF',
flex: 1,
},
date: {
color: '#8e8e93',
fontSize: 12,
},
feedBadge: {
alignSelf: 'flex-start',
paddingHorizontal: Spacing.two,
paddingVertical: Spacing.one,
backgroundColor: '#007AFF20',
borderRadius: Spacing.one,
marginTop: Spacing.two,
},
feedBadgeText: {
color: '#007AFF',
fontSize: 11,
fontWeight: '600',
textTransform: 'uppercase',
},
});

View File

@@ -0,0 +1,169 @@
import React from 'react';
import { StyleSheet, TouchableOpacity, Linking } from 'react-native';
import { FeedItem } from '@/types/feed';
import { ThemedText } from './themed-text';
import { ThemedView } from './themed-view';
import { Colors, Spacing } from '@/constants/theme';
import { useColorScheme } from 'react-native';
const EXTRACT_HTML_TEXT = (html: string): string => html.replace(/<[^>]*>/g, '');
const EXCERPT_LENGTH = 200;
interface FeedItemCardProps {
item: FeedItem;
onPress?: (item: FeedItem) => void;
onLongPress?: (item: FeedItem) => void;
}
export function FeedItemCard({ item, onPress, onLongPress }: FeedItemCardProps) {
const scheme = useColorScheme();
const colors = Colors[scheme === 'unspecified' ? 'light' : scheme];
const handlePress = () => {
if (onPress) {
onPress(item);
} else if (item.link) {
Linking.openURL(item.link);
}
};
const handleLongPress = () => {
if (onLongPress) {
onLongPress(item);
}
};
const formatDate = (date?: Date) => {
if (!date) return '';
const now = new Date();
const diff = now.getTime() - date.getTime();
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
if (days === 0) {
const hours = Math.floor(diff / (1000 * 60 * 60));
return hours === 0 ? 'Just now' : `${hours}h ago`;
} else if (days === 1) {
return 'Yesterday';
} else if (days < 7) {
return `${days}d ago`;
} else {
return date.toLocaleDateString();
}
};
const excerpt = React.useMemo(() => {
if (item.content) {
const htmlContent = EXTRACT_HTML_TEXT(item.content);
return htmlContent.length > EXCERPT_LENGTH ? htmlContent.substring(0, EXCERPT_LENGTH) + '...' : htmlContent;
}
if (item.description) {
const htmlDesc = EXTRACT_HTML_TEXT(item.description);
return htmlDesc.length > EXCERPT_LENGTH ? htmlDesc.substring(0, EXCERPT_LENGTH) + '...' : htmlDesc;
}
return '';
}, [item.content, item.description]);
return (
<TouchableOpacity
style={[styles.container, { backgroundColor: colors.background }]}
onPress={handlePress}
onLongPress={handleLongPress}
activeOpacity={0.7}
>
<ThemedView style={styles.content}>
{item.subscriptionTitle && (
<ThemedText type="small" style={styles.feedTitle} numberOfLines={1}>
{item.subscriptionTitle}
</ThemedText>
)}
<ThemedText type="smallBold" style={styles.title} numberOfLines={2}>
{item.title}
</ThemedText>
{excerpt && (
<ThemedText type="small" style={styles.excerpt} numberOfLines={3}>
{excerpt}
</ThemedText>
)}
<ThemedView style={styles.metaRow}>
{item.author && (
<ThemedText type="small" style={styles.author} numberOfLines={1}>
{item.author}
</ThemedText>
)}
{item.published && (
<ThemedText type="small" style={styles.date}>
{formatDate(item.published)}
</ThemedText>
)}
</ThemedView>
{item.enclosure && item.enclosure.type.includes('audio') && (
<ThemedView style={styles.audioBadge}>
<ThemedText type="small" style={styles.audioBadgeText}>
🎧 Audio
</ThemedText>
</ThemedView>
)}
</ThemedView>
</TouchableOpacity>
);
}
const styles = StyleSheet.create({
container: {
borderRadius: Spacing.three,
padding: Spacing.three,
marginBottom: Spacing.two,
borderWidth: 1,
borderColor: '#e5e5ea',
},
content: {
flex: 1,
},
feedTitle: {
fontSize: 12,
color: '#007AFF',
marginBottom: Spacing.half,
fontWeight: '500',
},
title: {
fontSize: 16,
marginBottom: Spacing.one,
},
excerpt: {
fontSize: 14,
color: '#8e8e93',
marginBottom: Spacing.two,
lineHeight: 20,
},
metaRow: {
flexDirection: 'row',
alignItems: 'center',
gap: Spacing.two,
},
author: {
color: '#8e8e93',
fontSize: 12,
flex: 1,
},
date: {
color: '#8e8e93',
fontSize: 12,
},
audioBadge: {
alignSelf: 'flex-start',
paddingHorizontal: Spacing.two,
paddingVertical: Spacing.one,
backgroundColor: '#007AFF20',
borderRadius: Spacing.one,
marginTop: Spacing.two,
},
audioBadgeText: {
color: '#007AFF',
fontSize: 11,
fontWeight: '600',
},
});

View File

@@ -0,0 +1,337 @@
import React, { useState } from 'react';
import { StyleSheet, ScrollView, TouchableOpacity, Modal, View } from 'react-native';
import { SearchFilters } from '@/types/feed';
import { ThemedText } from './themed-text';
import { ThemedView } from './themed-view';
import { Colors, Spacing } from '@/constants/theme';
import { useColorScheme } from 'react-native';
interface FilterOptionsProps {
visible: boolean;
onClose: () => void;
currentFilters: SearchFilters;
onApplyFilters: (filters: SearchFilters) => void;
availableFeeds: Array<{ id: string; title: string }>;
}
export function FilterOptions({ visible, onClose, currentFilters, onApplyFilters, availableFeeds }: FilterOptionsProps) {
const scheme = useColorScheme();
const colors = Colors[scheme === 'unspecified' ? 'light' : scheme];
const [localFilters, setLocalFilters] = useState<SearchFilters>(currentFilters);
const [selectedContentType, setSelectedContentType] = useState<'all' | 'article' | 'audio' | 'video'>(
localFilters.contentType || 'all'
);
const [selectedFeeds, setSelectedFeeds] = useState<string[]>(localFilters.feedIds || []);
const [dateFrom, setDateFrom] = useState<string>(localFilters.dateFrom?.toISOString().split('T')[0] || '');
const [dateTo, setDateTo] = useState<string>(localFilters.dateTo?.toISOString().split('T')[0] || '');
const handleApply = () => {
const filters: SearchFilters = {};
if (selectedContentType !== 'all') {
filters.contentType = selectedContentType;
}
if (selectedFeeds.length > 0) {
filters.feedIds = selectedFeeds;
}
if (dateFrom) {
filters.dateFrom = new Date(dateFrom);
}
if (dateTo) {
filters.dateTo = new Date(dateTo);
}
onApplyFilters(filters);
onClose();
};
const handleReset = () => {
setLocalFilters({});
setSelectedContentType('all');
setSelectedFeeds([]);
setDateFrom('');
setDateTo('');
};
const toggleFeed = (feedId: string) => {
setSelectedFeeds(prev =>
prev.includes(feedId)
? prev.filter(id => id !== feedId)
: [...prev, feedId]
);
};
const contentTypeOptions = [
{ value: 'all', label: 'All Types', icon: '📋' },
{ value: 'article', label: 'Articles', icon: '📄' },
{ value: 'audio', label: 'Audio', icon: '🎵' },
{ value: 'video', label: 'Video', icon: '🎬' },
];
return (
<Modal
visible={visible}
animationType="slide"
onRequestClose={onClose}
>
<ThemedView style={styles.container}>
{/* Header */}
<ThemedView style={styles.header}>
<ThemedText style={styles.headerTitle}>Filters</ThemedText>
<TouchableOpacity onPress={onClose} style={styles.closeButton}>
<ThemedText style={styles.closeButtonText}>Done</ThemedText>
</TouchableOpacity>
</ThemedView>
{/* Content */}
<ScrollView style={styles.content}>
{/* Content Type Filter */}
<ThemedView style={styles.section}>
<ThemedText style={styles.sectionTitle}>Content Type</ThemedText>
<ThemedView style={styles.optionRow}>
{contentTypeOptions.map(option => (
<TouchableOpacity
key={option.value}
style={[
styles.optionButton,
selectedContentType === option.value && styles.optionButtonSelected
]}
onPress={() => setSelectedContentType(option.value as any)}
>
<ThemedText style={styles.optionIcon}>{option.icon}</ThemedText>
<ThemedText style={[
styles.optionLabel,
selectedContentType === option.value && styles.optionLabelSelected
]}>
{option.label}
</ThemedText>
</TouchableOpacity>
))}
</ThemedView>
</ThemedView>
{/* Date Range Filter */}
<ThemedView style={styles.section}>
<ThemedText style={styles.sectionTitle}>Date Range</ThemedText>
<ThemedView style={styles.dateRow}>
<ThemedView style={styles.dateInputContainer}>
<ThemedText style={styles.dateLabel}>From</ThemedText>
<ThemedText style={styles.dateValue}>
{dateFrom || 'Any date'}
</ThemedText>
</ThemedView>
<ThemedText style={styles.dateSeparator}>to</ThemedText>
<ThemedView style={styles.dateInputContainer}>
<ThemedText style={styles.dateLabel}>To</ThemedText>
<ThemedText style={styles.dateValue}>
{dateTo || 'Now'}
</ThemedText>
</ThemedView>
</ThemedView>
<ThemedText style={styles.dateHint}>Tap to select dates (coming soon)</ThemedText>
</ThemedView>
{/* Feed Filter */}
<ThemedView style={styles.section}>
<ThemedText style={styles.sectionTitle}>Feeds ({selectedFeeds.length} selected)</ThemedText>
<ScrollView
style={styles.feedsList}
showsVerticalScrollIndicator={false}
>
{availableFeeds.map(feed => (
<TouchableOpacity
key={feed.id}
style={[
styles.feedOption,
selectedFeeds.includes(feed.id) && styles.feedOptionSelected
]}
onPress={() => toggleFeed(feed.id)}
>
<ThemedText style={styles.feedCheckbox}>
{selectedFeeds.includes(feed.id) ? '☑' : '☐'}
</ThemedText>
<ThemedText style={styles.feedTitle} numberOfLines={1}>
{feed.title}
</ThemedText>
</TouchableOpacity>
))}
</ScrollView>
</ThemedView>
</ScrollView>
{/* Footer Actions */}
<ThemedView style={styles.footer}>
<TouchableOpacity
style={[styles.footerButton, styles.resetButton]}
onPress={handleReset}
>
<ThemedText style={styles.resetButtonText}>Reset</ThemedText>
</TouchableOpacity>
<TouchableOpacity
style={[styles.footerButton, styles.applyButton]}
onPress={handleApply}
>
<ThemedText style={styles.applyButtonText}>Apply</ThemedText>
</TouchableOpacity>
</ThemedView>
</ThemedView>
</Modal>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: Spacing.three,
paddingVertical: Spacing.three,
borderBottomWidth: 1,
borderBottomColor: '#e5e5ea',
},
headerTitle: {
fontSize: 20,
fontWeight: 'bold',
},
closeButton: {
paddingHorizontal: Spacing.two,
paddingVertical: Spacing.one,
},
closeButtonText: {
fontSize: 16,
color: '#007AFF',
},
content: {
flex: 1,
},
section: {
paddingVertical: Spacing.three,
paddingHorizontal: Spacing.three,
borderBottomWidth: 1,
borderBottomColor: '#e5e5ea',
},
sectionTitle: {
fontSize: 14,
fontWeight: '600',
marginBottom: Spacing.three,
color: '#8e8e93',
textTransform: 'uppercase',
letterSpacing: 0.5,
},
optionRow: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: Spacing.two,
},
optionButton: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: Spacing.three,
paddingVertical: Spacing.two,
borderRadius: Spacing.two,
backgroundColor: '#e5e5ea',
gap: Spacing.one,
},
optionButtonSelected: {
backgroundColor: '#007AFF',
},
optionIcon: {
fontSize: 14,
},
optionLabel: {
fontSize: 14,
color: '#8e8e93',
},
optionLabelSelected: {
color: '#ffffff',
},
dateRow: {
flexDirection: 'row',
alignItems: 'center',
gap: Spacing.two,
},
dateInputContainer: {
flex: 1,
},
dateLabel: {
fontSize: 12,
color: '#8e8e93',
marginBottom: Spacing.one,
},
dateValue: {
fontSize: 16,
paddingVertical: Spacing.two,
paddingHorizontal: Spacing.two,
borderRadius: Spacing.two,
backgroundColor: '#e5e5ea',
},
dateSeparator: {
fontSize: 16,
color: '#8e8e93',
},
dateHint: {
fontSize: 12,
color: '#8e8e93',
marginTop: Spacing.two,
fontStyle: 'italic',
},
feedsList: {
maxHeight: 200,
},
feedOption: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: Spacing.two,
paddingHorizontal: Spacing.two,
gap: Spacing.two,
},
feedOptionSelected: {
backgroundColor: '#e5f2ff',
borderRadius: Spacing.two,
marginLeft: -Spacing.two,
marginRight: -Spacing.two,
},
feedCheckbox: {
fontSize: 16,
},
feedTitle: {
flex: 1,
fontSize: 14,
},
footer: {
flexDirection: 'row',
padding: Spacing.three,
gap: Spacing.three,
borderTopWidth: 1,
borderTopColor: '#e5e5ea',
},
footerButton: {
flex: 1,
paddingVertical: Spacing.three,
borderRadius: Spacing.two,
alignItems: 'center',
},
resetButton: {
backgroundColor: '#e5e5ea',
},
resetButtonText: {
fontSize: 16,
fontWeight: '600',
color: '#8e8e93',
},
applyButton: {
backgroundColor: '#007AFF',
},
applyButtonText: {
fontSize: 16,
fontWeight: '600',
color: '#ffffff',
},
});

View File

@@ -0,0 +1,216 @@
import React from 'react';
import { StyleSheet, ScrollView, TouchableOpacity, View } from 'react-native';
import { SearchFilters } from '@/types/feed';
import { ThemedText } from './themed-text';
import { ThemedView } from './themed-view';
import { Colors, Spacing } from '@/constants/theme';
import { useColorScheme } from 'react-native';
interface FilterChipProps {
label: string;
value?: string;
onRemove: () => void;
icon?: string;
}
function FilterChip({ label, value, onRemove, icon }: FilterChipProps) {
const scheme = useColorScheme();
const colors = Colors[scheme === 'unspecified' ? 'light' : scheme];
return (
<TouchableOpacity
style={styles.chip}
onPress={onRemove}
activeOpacity={0.7}
>
{icon && <ThemedText style={styles.chipIcon}>{icon}</ThemedText>}
<ThemedText style={styles.chipText}>
{label}:
</ThemedText>
<ThemedText style={styles.chipValue}>
{value}
</ThemedText>
<ThemedText style={styles.chipClose}></ThemedText>
</TouchableOpacity>
);
}
interface SearchFiltersProps {
filters: SearchFilters;
onFilterChange: (filters: Partial<SearchFilters>) => void;
onClearAll: () => void;
availableFeeds?: Array<{ id: string; title: string }>;
}
export function SearchFilters({ filters, onFilterChange, onClearAll, availableFeeds }: SearchFiltersProps) {
const scheme = useColorScheme();
const colors = Colors[scheme === 'unspecified' ? 'light' : scheme];
const getFeedTitle = (feedId: string) => {
const feed = availableFeeds?.find(f => f.id === feedId);
return feed?.title || feedId;
};
const formatDate = (date: Date) => {
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
};
const renderFilterChips = () => {
const chips: any[] = [];
// Date range filter
if (filters.dateFrom || filters.dateTo) {
const fromLabel = filters.dateFrom ? formatDate(filters.dateFrom) : 'Any date';
const toLabel = filters.dateTo ? formatDate(filters.dateTo) : 'Now';
chips.push(
<FilterChip
key="date"
label="Date"
value={`${fromLabel} ${toLabel}`}
icon="📅"
onRemove={() => onFilterChange({ dateFrom: undefined, dateTo: undefined })}
/>
);
}
// Feed filters
if (filters.feedIds && filters.feedIds.length > 0) {
const feedCount = filters.feedIds.length;
const feedTitle = feedCount === 1
? getFeedTitle(filters.feedIds[0])
: `${feedCount} feeds`;
chips.push(
<FilterChip
key="feeds"
label="Feed"
value={feedTitle}
icon="📰"
onRemove={() => onFilterChange({ feedIds: undefined })}
/>
);
}
// Author filters
if (filters.authors && filters.authors.length > 0) {
const authorCount = filters.authors.length;
const authorLabel = authorCount === 1
? filters.authors[0]
: `${authorCount} authors`;
chips.push(
<FilterChip
key="authors"
label="Author"
value={authorLabel}
icon="✍️"
onRemove={() => onFilterChange({ authors: undefined })}
/>
);
}
// Content type filter
if (filters.contentType) {
const contentTypeLabels: Record<string, string> = {
article: 'Articles',
audio: 'Audio',
video: 'Video',
};
const contentTypeIcons: Record<string, string> = {
article: '📄',
audio: '🎵',
video: '🎬',
};
chips.push(
<FilterChip
key="contentType"
label="Type"
value={contentTypeLabels[filters.contentType]}
icon={contentTypeIcons[filters.contentType]}
onRemove={() => onFilterChange({ contentType: undefined })}
/>
);
}
return chips;
};
const hasActiveFilters =
filters.dateFrom ||
filters.dateTo ||
(filters.feedIds?.length ?? 0) > 0 ||
(filters.authors?.length ?? 0) > 0 ||
filters.contentType !== undefined;
if (!hasActiveFilters) {
return null;
}
return (
<ThemedView style={styles.container}>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.chipsContainer}
>
{renderFilterChips()}
</ScrollView>
{hasActiveFilters && (
<TouchableOpacity
style={styles.clearAllButton}
onPress={onClearAll}
activeOpacity={0.7}
>
<ThemedText style={styles.clearAllText}>Clear all</ThemedText>
</TouchableOpacity>
)}
</ThemedView>
);
}
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: Spacing.three,
paddingVertical: Spacing.two,
gap: Spacing.two,
},
chipsContainer: {
flex: 1,
gap: Spacing.two,
},
chip: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: Spacing.three,
paddingVertical: Spacing.one,
borderRadius: Spacing.two,
backgroundColor: '#e5e5ea',
gap: Spacing.one,
},
chipIcon: {
fontSize: 12,
},
chipText: {
fontSize: 12,
color: '#8e8e93',
fontWeight: '600',
},
chipValue: {
fontSize: 12,
color: '#000000',
},
chipClose: {
fontSize: 14,
color: '#8e8e93',
marginLeft: Spacing.one,
},
clearAllButton: {
paddingHorizontal: Spacing.two,
paddingVertical: Spacing.one,
},
clearAllText: {
fontSize: 12,
color: '#007AFF',
fontWeight: '500',
},
});

View File

@@ -0,0 +1,180 @@
import React, { useRef, useEffect } from 'react';
import { StyleSheet, TouchableOpacity, TextInput, ViewStyle, TextStyle } from 'react-native';
import { useSearchStore } from '@/stores/search-store';
import { ThemedText } from './themed-text';
import { ThemedView } from './themed-view';
import { Colors, Spacing } from '@/constants/theme';
import { useColorScheme } from 'react-native';
interface SearchInputProps {
onSearch?: (query: string) => void;
style?: ViewStyle;
inputStyle?: TextStyle;
placeholder?: string;
autoFocus?: boolean;
}
export function SearchInput({
onSearch,
style,
inputStyle,
placeholder = 'Search articles and feeds...',
autoFocus = false,
}: SearchInputProps) {
const scheme = useColorScheme();
const colors = Colors[scheme === 'unspecified' ? 'light' : scheme];
const { query, setQuery, clearSearch, searchHistory } = useSearchStore();
const inputRef = useRef<TextInput>(null);
// Debounced search handler
useEffect(() => {
if (!onSearch) return;
const timer = setTimeout(() => {
onSearch(query);
}, 300);
return () => clearTimeout(timer);
}, [query, onSearch]);
const handleClear = () => {
setQuery('');
inputRef.current?.focus();
};
const handleClearSearch = () => {
clearSearch();
inputRef.current?.focus();
};
return (
<ThemedView style={[styles.container, style]}>
<ThemedView style={styles.inputContainer}>
<ThemedText style={styles.searchIcon}>🔍</ThemedText>
<TextInput
ref={inputRef}
value={query}
onChangeText={setQuery}
placeholder={placeholder}
placeholderTextColor={colors.textSecondary}
style={[styles.input, inputStyle]}
autoFocus={autoFocus}
returnKeyType="search"
keyboardType="default"
autoCapitalize="none"
autoCorrect={false}
clearButtonMode="while-editing"
selectionColor={colors.text}
/>
{query ? (
<TouchableOpacity
onPress={handleClear}
style={[styles.clearButton, { backgroundColor: colors.backgroundElement }]}
>
<ThemedText style={styles.clearIcon}></ThemedText>
</TouchableOpacity>
) : null}
</ThemedView>
{query && (
<TouchableOpacity
onPress={handleClearSearch}
style={styles.clearSearchButton}
>
<ThemedText type="small" style={styles.clearSearchText}>
Clear search
</ThemedText>
</TouchableOpacity>
)}
{searchHistory.length > 0 && !query && (
<ThemedView style={styles.historyContainer}>
<ThemedText type="small" style={styles.historyLabel}>
Recent searches:
</ThemedText>
<ThemedView style={styles.historyList}>
{searchHistory.map((item) => (
<TouchableOpacity
key={item.id}
style={styles.historyItem}
onPress={() => {
setQuery(item.query);
onSearch?.(item.query);
}}
>
<ThemedText type="small" style={styles.historyText}>
{item.query}
</ThemedText>
</TouchableOpacity>
))}
</ThemedView>
</ThemedView>
)}
</ThemedView>
);
}
const styles = StyleSheet.create({
container: {
paddingHorizontal: Spacing.three,
paddingBottom: Spacing.two,
},
inputContainer: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#f6f6f6',
borderRadius: Spacing.three,
paddingHorizontal: Spacing.three,
height: 44,
},
searchIcon: {
fontSize: 18,
marginRight: Spacing.two,
},
input: {
flex: 1,
fontSize: 16,
height: '100%',
},
clearButton: {
width: 24,
height: 24,
borderRadius: 12,
justifyContent: 'center',
alignItems: 'center',
},
clearIcon: {
fontSize: 14,
fontWeight: 'bold',
},
clearSearchButton: {
alignItems: 'center',
paddingVertical: Spacing.two,
},
clearSearchText: {
color: '#007AFF',
},
historyContainer: {
marginTop: Spacing.two,
},
historyLabel: {
fontSize: 12,
color: '#8e8e93',
marginBottom: Spacing.two,
},
historyList: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: Spacing.two,
},
historyItem: {
paddingHorizontal: Spacing.three,
paddingVertical: Spacing.two,
backgroundColor: '#e5e5ea',
borderRadius: Spacing.two,
},
historyText: {
fontSize: 14,
},
});

View File

@@ -0,0 +1,172 @@
import React, { useState } from 'react';
import { StyleSheet, FlatList, ViewToken, ActivityIndicator } from 'react-native';
import { SearchResult } from '@/types/feed';
import { ThemedText } from './themed-text';
import { ThemedView } from './themed-view';
import { ArticleCard } from './article-card';
import { Colors, Spacing } from '@/constants/theme';
import { useColorScheme } from 'react-native';
interface SearchResultsProps {
articles: SearchResult[];
feeds: SearchResult[];
loading: boolean;
onLoadMore?: () => void;
onResultPress?: (result: SearchResult) => void;
hasMore?: boolean;
}
export function SearchResults({
articles,
feeds,
loading,
onLoadMore,
onResultPress,
hasMore = false,
}: SearchResultsProps) {
const scheme = useColorScheme();
const colors = Colors[scheme === 'unspecified' ? 'light' : scheme];
const [visibleArticles, setVisibleArticles] = useState<Set<string>>(new Set());
const [visibleFeeds, setVisibleFeeds] = useState<Set<string>>(new Set());
const keyExtractor = (item: SearchResult) => item.id;
const onViewableItemsChangedArticles = ({ viewableItems }: { viewableItems: ViewToken[] }) => {
const newVisible = new Set(visibleArticles);
viewableItems.forEach(item => {
if (item.isViewable) {
newVisible.add(item.key);
}
});
setVisibleArticles(newVisible);
};
const onViewableItemsChangedFeeds = ({ viewableItems }: { viewableItems: ViewToken[] }) => {
const newVisible = new Set(visibleFeeds);
viewableItems.forEach(item => {
if (item.isViewable) {
newVisible.add(item.key);
}
});
setVisibleFeeds(newVisible);
};
const viewabilityConfig = {
itemVisiblePercentThreshold: 50,
};
const renderArticle = ({ item }: { item: SearchResult }) => (
<ArticleCard result={item} onPress={onResultPress} />
);
const renderFeed = ({ item }: { item: SearchResult }) => (
<ArticleCard result={item} onPress={onResultPress} />
);
const renderFooter = () => {
if (!loading || !hasMore) return null;
return (
<ThemedView style={styles.loadingContainer}>
<ActivityIndicator size="small" color={colors.text} />
</ThemedView>
);
};
const renderEmptyState = () => {
if (!loading && articles.length === 0 && feeds.length === 0) {
return (
<ThemedView style={styles.emptyContainer}>
<ThemedText type="section" style={styles.emptyTitle}>
No results found
</ThemedText>
<ThemedText type="small" style={styles.emptySubtitle}>
Try different keywords or check your filters
</ThemedText>
</ThemedView>
);
}
return null;
};
return (
<ThemedView style={styles.container}>
{feeds.length > 0 && (
<ThemedView style={styles.section}>
<ThemedText type="smallBold" style={styles.sectionHeader}>
Feeds ({feeds.length})
</ThemedText>
<FlatList
data={feeds}
renderItem={renderFeed}
keyExtractor={keyExtractor}
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.feedsList}
onViewableItemsChanged={onViewableItemsChangedFeeds}
viewabilityConfig={viewabilityConfig}
/>
</ThemedView>
)}
<ThemedView style={styles.section}>
{(articles.length > 0 || loading) && (
<ThemedText type="smallBold" style={styles.sectionHeader}>
Articles ({articles.length})
</ThemedText>
)}
{renderEmptyState()}
<FlatList
data={articles}
renderItem={renderArticle}
keyExtractor={keyExtractor}
onEndReached={onLoadMore}
onEndReachedThreshold={0.5}
ListFooterComponent={renderFooter}
onViewableItemsChanged={onViewableItemsChangedArticles}
viewabilityConfig={viewabilityConfig}
contentContainerStyle={styles.articlesList}
/>
</ThemedView>
</ThemedView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
section: {
paddingHorizontal: Spacing.three,
paddingBottom: Spacing.three,
},
sectionHeader: {
fontSize: 14,
marginBottom: Spacing.three,
marginTop: Spacing.two,
},
feedsList: {
paddingRight: Spacing.three,
},
articlesList: {
paddingBottom: Spacing.four,
},
loadingContainer: {
paddingVertical: Spacing.four,
alignItems: 'center',
},
emptyContainer: {
paddingVertical: Spacing.six * 2,
alignItems: 'center',
},
emptyTitle: {
textAlign: 'center',
marginBottom: Spacing.two,
},
emptySubtitle: {
textAlign: 'center',
color: '#8e8e93',
},
});

View File

@@ -0,0 +1,82 @@
import React from 'react';
import { StyleSheet, ScrollView, TouchableOpacity } from 'react-native';
import { SearchSortOption } from '@/types/feed';
import { ThemedText } from './themed-text';
import { ThemedView } from './themed-view';
import { Colors, Spacing } from '@/constants/theme';
import { useColorScheme } from 'react-native';
const SORT_OPTIONS: { value: SearchSortOption; label: string; icon: string }[] = [
{ value: 'relevance', label: 'Relevance', icon: '⭐' },
{ value: 'date_desc', label: 'Newest', icon: '📅' },
{ value: 'date_asc', label: 'Oldest', icon: '🕐' },
{ value: 'title_asc', label: 'A-Z', icon: '🔤' },
{ value: 'title_desc', label: 'Z-A', icon: '🔡' },
{ value: 'feed_asc', label: 'Feed A-Z', icon: '📰' },
{ value: 'feed_desc', label: 'Feed Z-A', icon: '📰' },
];
interface SearchSortProps {
sort: SearchSortOption;
onSortChange: (sort: SearchSortOption) => void;
}
export function SearchSort({ sort, onSortChange }: SearchSortProps) {
const scheme = useColorScheme();
const colors = Colors[scheme === 'unspecified' ? 'light' : scheme];
return (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.container}
>
{SORT_OPTIONS.map((option) => {
const isActive = sort === option.value;
return (
<TouchableOpacity
key={option.value}
style={[
styles.button,
isActive && { backgroundColor: '#007AFF' }
]}
onPress={() => onSortChange(option.value)}
activeOpacity={0.7}
>
<ThemedText
style={[
styles.buttonText,
isActive && styles.activeButtonText
]}
>
{option.icon} {option.label}
</ThemedText>
</TouchableOpacity>
);
})}
</ScrollView>
);
}
const styles = StyleSheet.create({
container: {
paddingHorizontal: Spacing.three,
paddingVertical: Spacing.two,
gap: Spacing.two,
},
button: {
paddingHorizontal: Spacing.three,
paddingVertical: Spacing.two,
borderRadius: Spacing.two,
backgroundColor: '#e5e5ea',
minWidth: 80,
alignItems: 'center',
},
buttonText: {
fontSize: 14,
color: '#8e8e93',
},
activeButtonText: {
color: '#ffffff',
},
});

548
src/components/settings.tsx Normal file
View File

@@ -0,0 +1,548 @@
import React from 'react';
import { ScrollView, StyleSheet, Switch, View, TextInput, Pressable } from 'react-native';
import { useColorScheme } from 'react-native';
import { ThemedText } from '@/components/themed-text';
import { ThemedView } from '@/components/themed-view';
import { Collapsible } from '@/components/ui/collapsible';
import { SYNC_INTERVALS, SyncInterval, NotificationPreferences, ReadingPreferences } from '@/types/feed';
import { AccountSettings } from '@/types/global';
import { useTheme } from '@/hooks/use-theme';
import { useSettingsStore } from '@/stores/settings-store';
export default function SettingsScreen() {
const colorScheme = useColorScheme();
const theme = useTheme();
const {
syncInterval,
setSyncInterval,
theme: themeSetting,
setTheme,
notificationPreferences,
setNotificationPreferences,
readingPreferences,
setReadingPreferences,
accountSettings,
setAccountSettings,
} = useSettingsStore();
// Sync interval handler
const handleSyncIntervalChange = (value: number) => {
const interval = SYNC_INTERVALS.find((i) => i.value === value);
if (interval) {
setSyncInterval(interval);
}
};
// Notification handlers
const handleNotificationToggle = (key: keyof NotificationPreferences, value: boolean) => {
setNotificationPreferences({ [key]: value });
};
// Reading preference handlers
const handleReadingToggle = (key: keyof ReadingPreferences, value: boolean) => {
setReadingPreferences({ [key]: value });
};
const handleFontSizeChange = (size: ReadingPreferences['fontSize']) => {
setReadingPreferences({ fontSize: size });
};
const handleLineHeightChange = (height: ReadingPreferences['lineHeight']) => {
setReadingPreferences({ lineHeight: height });
};
// Theme handler
const handleThemeChange = (theme: 'light' | 'dark' | 'system') => {
setTheme(theme);
};
// Account settings handlers
const handleAccountSettingChange = (key: keyof AccountSettings, value: string | boolean) => {
setAccountSettings({ [key]: value });
};
return (
<ThemedView style={styles.container}>
<ScrollView style={styles.scrollView} contentContainerStyle={styles.scrollContent}>
{/* Sync Settings */}
<ThemedView style={styles.section}>
<ThemedText type="section" style={styles.sectionTitle}>Sync Settings</ThemedText>
<ThemedText type="defaultSemiBold" style={styles.sectionDescription}>
Choose how often your feeds should be updated.
</ThemedText>
<View style={styles.intervalContainer}>
{SYNC_INTERVALS.map((interval) => (
<Pressable
key={interval.value}
onPress={() => handleSyncIntervalChange(interval.value)}
style={({ pressed }) => [
styles.intervalItem,
{
backgroundColor:
syncInterval.value === interval.value
? theme.backgroundSelected
: theme.backgroundElement,
opacity: pressed ? 0.7 : 1,
},
]}
>
<ThemedText style={styles.intervalLabel}>
{interval.label}
</ThemedText>
{syncInterval.value === interval.value && (
<View style={styles.checkmark}>
<View style={styles.checkmarkCircle} />
</View>
)}
</Pressable>
))}
</View>
</ThemedView>
{/* Theme */}
<ThemedView style={styles.section}>
<ThemedText type="section" style={styles.sectionTitle}>Theme</ThemedText>
<ThemedText type="defaultSemiBold" style={styles.sectionDescription}>
Choose your preferred appearance.
</ThemedText>
<View style={styles.themeContainer}>
<Pressable
onPress={() => handleThemeChange('light')}
style={({ pressed }) => [
styles.themeButton,
{ opacity: pressed ? 0.7 : 1 },
]}
>
<View style={[
styles.themeCircle,
{ backgroundColor: themeSetting === 'light' ? '#ffffff' : theme.backgroundElement }
]} />
<ThemedText style={styles.themeLabel}>Light</ThemedText>
</Pressable>
<Pressable
onPress={() => handleThemeChange('dark')}
style={({ pressed }) => [
styles.themeButton,
{ opacity: pressed ? 0.7 : 1 },
]}
>
<View style={[
styles.themeCircle,
{ backgroundColor: themeSetting === 'dark' ? '#000000' : theme.backgroundElement }
]} />
<ThemedText style={styles.themeLabel}>Dark</ThemedText>
</Pressable>
<Pressable
onPress={() => handleThemeChange('system')}
style={({ pressed }) => [
styles.themeButton,
{ opacity: pressed ? 0.7 : 1 },
]}
>
<View style={[
styles.themeCircle,
{ backgroundColor: themeSetting === 'system' ? theme.backgroundSelected : theme.backgroundElement }
]} />
<ThemedText style={styles.themeLabel}>System</ThemedText>
</Pressable>
</View>
<ThemedView type="backgroundElement" style={styles.warningBox}>
<ThemedText type="smallBold" style={styles.warningTitle}> Note</ThemedText>
<ThemedText type="small" style={styles.warningText}>
Changing theme will apply on app restart.
</ThemedText>
</ThemedView>
</ThemedView>
{/* Notifications */}
<ThemedView style={styles.section}>
<ThemedText type="section" style={styles.sectionTitle}>Notifications</ThemedText>
<ThemedText type="defaultSemiBold" style={styles.sectionDescription}>
Manage how you receive notifications.
</ThemedText>
<ThemedView style={styles.prefGroup}>
<View style={styles.prefHeader}>
<ThemedText style={styles.prefTitle}>New Articles</ThemedText>
<Switch
value={notificationPreferences.newArticles}
onValueChange={(value) => handleNotificationToggle('newArticles', value)}
trackColor={{ false: theme.textSecondary, true: theme.text }}
thumbColor="white"
/>
</View>
</ThemedView>
<ThemedView style={styles.prefGroup}>
<View style={styles.prefHeader}>
<ThemedText style={styles.prefTitle}>Episode Releases</ThemedText>
<Switch
value={notificationPreferences.episodeReleases}
onValueChange={(value) => handleNotificationToggle('episodeReleases', value)}
trackColor={{ false: theme.textSecondary, true: theme.text }}
thumbColor="white"
/>
</View>
</ThemedView>
<ThemedView style={styles.prefGroup}>
<View style={styles.prefHeader}>
<ThemedText style={styles.prefTitle}>Custom Alerts</ThemedText>
<Switch
value={notificationPreferences.customAlerts}
onValueChange={(value) => handleNotificationToggle('customAlerts', value)}
trackColor={{ false: theme.textSecondary, true: theme.text }}
thumbColor="white"
/>
</View>
</ThemedView>
<Collapsible title="Sound & Vibration">
<ThemedView style={styles.prefGroup}>
<View style={styles.prefHeader}>
<ThemedText style={styles.prefTitle}>Sound</ThemedText>
<Switch
value={notificationPreferences.sound}
onValueChange={(value) => handleNotificationToggle('sound', value)}
trackColor={{ false: theme.textSecondary, true: theme.text }}
thumbColor="white"
/>
</View>
</ThemedView>
<ThemedView style={styles.prefGroup}>
<View style={styles.prefHeader}>
<ThemedText style={styles.prefTitle}>Vibration</ThemedText>
<Switch
value={notificationPreferences.vibration}
onValueChange={(value) => handleNotificationToggle('vibration', value)}
trackColor={{ false: theme.textSecondary, true: theme.text }}
thumbColor="white"
/>
</View>
</ThemedView>
</Collapsible>
</ThemedView>
{/* Reading Preferences */}
<ThemedView style={styles.section}>
<ThemedText type="section" style={styles.sectionTitle}>Reading Preferences</ThemedText>
<ThemedText type="defaultSemiBold" style={styles.sectionDescription}>
Customize your reading experience.
</ThemedText>
<ThemedView style={styles.prefGroup}>
<ThemedText style={styles.prefTitle}>Font Size</ThemedText>
<View style={styles.fontSizeSelector}>
{(['small', 'medium', 'large', 'xlarge'] as ReadingPreferences['fontSize'][]).map((size) => (
<Pressable
key={size}
onPress={() => handleFontSizeChange(size)}
style={({ pressed }) => [
styles.fontSizeButton,
{
backgroundColor:
readingPreferences.fontSize === size
? theme.backgroundSelected
: theme.backgroundElement,
opacity: pressed ? 0.7 : 1,
},
]}
>
<ThemedText style={[
styles.fontSizeLabel,
size === 'small' && { fontSize: 12 },
size === 'medium' && { fontSize: 16 },
size === 'large' && { fontSize: 20 },
size === 'xlarge' && { fontSize: 24 }
]}>
{size === 'small' ? 'A' : size === 'medium' ? 'B' : size === 'large' ? 'C' : 'D'}
</ThemedText>
</Pressable>
))}
</View>
</ThemedView>
<ThemedView style={styles.prefGroup}>
<ThemedText style={styles.prefTitle}>Line Height</ThemedText>
<View style={styles.lineHeightSelector}>
{(['normal', 'relaxed', 'loose'] as ReadingPreferences['lineHeight'][]).map((height) => (
<Pressable
key={height}
onPress={() => handleLineHeightChange(height)}
style={({ pressed }) => [
styles.lineHeightButton,
{
backgroundColor:
readingPreferences.lineHeight === height
? theme.backgroundSelected
: theme.backgroundElement,
opacity: pressed ? 0.7 : 1,
},
]}
>
<ThemedText style={styles.lineHeightLabel}>{height.charAt(0).toUpperCase() + height.slice(1)}</ThemedText>
</Pressable>
))}
</View>
</ThemedView>
<ThemedView style={styles.prefGroup}>
<View style={styles.prefHeader}>
<ThemedText style={styles.prefTitle}>Show Reading Time</ThemedText>
<Switch
value={readingPreferences.showReadingTime}
onValueChange={(value) => handleReadingToggle('showReadingTime', value)}
trackColor={{ false: theme.textSecondary, true: theme.text }}
thumbColor="white"
/>
</View>
</ThemedView>
<ThemedView style={styles.prefGroup}>
<View style={styles.prefHeader}>
<ThemedText style={styles.prefTitle}>Show Author</ThemedText>
<Switch
value={readingPreferences.showAuthor}
onValueChange={(value) => handleReadingToggle('showAuthor', value)}
trackColor={{ false: theme.textSecondary, true: theme.text }}
thumbColor="white"
/>
</View>
</ThemedView>
<ThemedView style={styles.prefGroup}>
<View style={styles.prefHeader}>
<ThemedText style={styles.prefTitle}>Show Date</ThemedText>
<Switch
value={readingPreferences.showDate}
onValueChange={(value) => handleReadingToggle('showDate', value)}
trackColor={{ false: theme.textSecondary, true: theme.text }}
thumbColor="white"
/>
</View>
</ThemedView>
</ThemedView>
{/* Account Settings */}
<ThemedView style={styles.section}>
<ThemedText type="section" style={styles.sectionTitle}>Account Settings</ThemedText>
<ThemedText type="defaultSemiBold" style={styles.sectionDescription}>
Manage your account preferences.
</ThemedText>
<ThemedView style={styles.prefGroup}>
<ThemedText style={styles.prefTitle}>Email</ThemedText>
<TextInput
style={[styles.input, { backgroundColor: theme.backgroundElement }]}
placeholder="Enter your email"
value={accountSettings.email}
onChangeText={(text) => handleAccountSettingChange('email', text)}
keyboardType="email-address"
autoCapitalize="none"
/>
</ThemedView>
<ThemedView style={styles.prefGroup}>
<ThemedText style={styles.prefTitle}>Privacy Level</ThemedText>
<View style={styles.privacySelector}>
{(['public', 'private', 'anonymous'] as AccountSettings['privacy'][]).map((privacy) => (
<Pressable
key={privacy}
onPress={() => handleAccountSettingChange('privacy', privacy)}
style={({ pressed }) => [
styles.privacyButton,
{
backgroundColor:
accountSettings.privacy === privacy
? theme.backgroundSelected
: theme.backgroundElement,
opacity: pressed ? 0.7 : 1,
},
]}
>
<ThemedText style={styles.privacyLabel}>
{privacy.charAt(0).toUpperCase() + privacy.slice(1)}
</ThemedText>
</Pressable>
))}
</View>
</ThemedView>
<ThemedView style={styles.prefGroup}>
<ThemedText style={styles.prefTitle}>Language</ThemedText>
<TextInput
style={[styles.input, { backgroundColor: theme.backgroundElement }]}
placeholder="e.g., en-US"
value={accountSettings.language}
onChangeText={(text) => handleAccountSettingChange('language', text)}
/>
</ThemedView>
<ThemedView style={styles.prefGroup}>
<ThemedText style={styles.prefTitle}>Timezone</ThemedText>
<TextInput
style={styles.input}
placeholder="e.g., UTC"
value={accountSettings.timezone}
onChangeText={(text) => handleAccountSettingChange('timezone', text)}
/>
</ThemedView>
<ThemedView style={styles.prefGroup}>
<View style={styles.prefHeader}>
<ThemedText style={styles.prefTitle}>Notifications Enabled</ThemedText>
<Switch
value={accountSettings.notificationsEnabled}
onValueChange={(value) => handleAccountSettingChange('notificationsEnabled', value)}
trackColor={{ false: theme.textSecondary, true: theme.text }}
thumbColor="white"
/>
</View>
</ThemedView>
</ThemedView>
</ScrollView>
</ThemedView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
scrollView: {
flex: 1,
},
scrollContent: {
paddingBottom: 24,
},
section: {
marginBottom: 24,
paddingHorizontal: 16,
},
sectionTitle: {
marginBottom: 4,
},
sectionDescription: {
marginBottom: 16,
},
intervalContainer: {
gap: 8,
},
intervalItem: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
padding: 16,
borderRadius: 12,
gap: 12,
},
intervalLabel: {
fontSize: 14,
},
checkmark: {
width: 20,
height: 20,
borderRadius: 10,
justifyContent: 'center',
alignItems: 'center',
},
checkmarkCircle: {
width: 12,
height: 12,
borderRadius: 6,
backgroundColor: '#4CAF50',
},
themeContainer: {
flexDirection: 'row',
justifyContent: 'space-around',
alignItems: 'center',
marginBottom: 16,
},
themeButton: {
alignItems: 'center',
gap: 4,
},
themeCircle: {
width: 40,
height: 40,
borderRadius: 20,
},
themeLabel: {
fontSize: 12,
},
warningBox: {
padding: 12,
borderRadius: 8,
marginBottom: 16,
},
warningTitle: {
marginBottom: 4,
},
warningText: {},
prefGroup: {
marginBottom: 8,
},
prefHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
prefTitle: {
fontSize: 14,
},
fontSizeSelector: {
flexDirection: 'row',
gap: 8,
},
fontSizeButton: {
padding: 12,
borderRadius: 8,
minWidth: 44,
alignItems: 'center',
justifyContent: 'center',
},
fontSizeLabel: {
fontWeight: 'bold',
},
lineHeightSelector: {
flexDirection: 'row',
gap: 8,
},
lineHeightButton: {
flex: 1,
padding: 12,
borderRadius: 8,
alignItems: 'center',
},
lineHeightLabel: {
fontSize: 12,
textAlign: 'center',
},
input: {
borderRadius: 8,
padding: 12,
fontSize: 14,
marginTop: 4,
},
privacySelector: {
flexDirection: 'row',
gap: 8,
marginTop: 4,
},
privacyButton: {
flex: 1,
padding: 12,
borderRadius: 8,
alignItems: 'center',
},
privacyLabel: {
fontSize: 14,
textAlign: 'center',
},
});

View File

@@ -4,7 +4,7 @@ import { Fonts, ThemeColor } from '@/constants/theme';
import { useTheme } from '@/hooks/use-theme'; import { useTheme } from '@/hooks/use-theme';
export type ThemedTextProps = TextProps & { export type ThemedTextProps = TextProps & {
type?: 'default' | 'title' | 'small' | 'smallBold' | 'subtitle' | 'link' | 'linkPrimary' | 'code'; type?: 'default' | 'title' | 'small' | 'smallBold' | 'subtitle' | 'link' | 'linkPrimary' | 'code' | 'section' | 'defaultSemiBold';
themeColor?: ThemeColor; themeColor?: ThemeColor;
}; };
@@ -23,6 +23,8 @@ export function ThemedText({ style, type = 'default', themeColor, ...rest }: The
type === 'link' && styles.link, type === 'link' && styles.link,
type === 'linkPrimary' && styles.linkPrimary, type === 'linkPrimary' && styles.linkPrimary,
type === 'code' && styles.code, type === 'code' && styles.code,
type === 'section' && styles.section,
type === 'defaultSemiBold' && styles.defaultSemiBold,
style, style,
]} ]}
{...rest} {...rest}
@@ -46,6 +48,11 @@ const styles = StyleSheet.create({
lineHeight: 24, lineHeight: 24,
fontWeight: 500, fontWeight: 500,
}, },
defaultSemiBold: {
fontSize: 16,
lineHeight: 24,
fontWeight: 600,
},
title: { title: {
fontSize: 48, fontSize: 48,
fontWeight: 600, fontWeight: 600,
@@ -56,6 +63,11 @@ const styles = StyleSheet.create({
lineHeight: 44, lineHeight: 44,
fontWeight: 600, fontWeight: 600,
}, },
section: {
fontSize: 20,
fontWeight: 600,
lineHeight: 24,
},
link: { link: {
lineHeight: 30, lineHeight: 30,
fontSize: 14, fontSize: 14,

View File

@@ -0,0 +1,82 @@
import { useState, useCallback, useEffect } from 'react';
import { FeedItem } from '@/types/feed';
import { getAllFeedItems } from '@/services/database';
const PAGE_SIZE = 20;
export function useFeedList() {
const [localFeedItems, setLocalFeedItems] = useState<FeedItem[]>([]);
const [page, setPage] = useState(0);
const [hasMore, setHasMore] = useState(true);
const [isRefreshing, setIsRefreshing] = useState(false);
const [loadingMore, setLoadingMore] = useState(false);
const [error, setError] = useState<string | null>(null);
const loadMore = useCallback(async () => {
if (loadingMore || !hasMore || isRefreshing) return;
setLoadingMore(true);
try {
const offset = page * PAGE_SIZE;
const items = await getAllFeedItems(PAGE_SIZE + 1, offset);
if (items.length > PAGE_SIZE) {
const newItems = items.slice(0, PAGE_SIZE);
setLocalFeedItems(prev => [...prev, ...newItems]);
setHasMore(true);
} else {
setLocalFeedItems(prev => [...prev, ...items]);
setHasMore(false);
}
setPage(prev => prev + 1);
setError(null);
} catch (err) {
console.error('Failed to load more items:', err);
setError('Failed to load more items. Please try again.');
} finally {
setLoadingMore(false);
}
}, [loadingMore, hasMore, isRefreshing, page]);
const refreshFeed = useCallback(async () => {
if (isRefreshing) return;
setIsRefreshing(true);
setPage(0);
setHasMore(true);
setError(null);
try {
const items = await getAllFeedItems(PAGE_SIZE + 1);
if (items.length > PAGE_SIZE) {
setLocalFeedItems(items.slice(0, PAGE_SIZE));
setHasMore(true);
} else {
setLocalFeedItems(items);
setHasMore(false);
}
} catch (err) {
console.error('Failed to refresh feed:', err);
setError('Failed to refresh feed. Please try again.');
} finally {
setIsRefreshing(false);
}
}, [isRefreshing]);
useEffect(() => {
refreshFeed();
}, []);
return {
feedItems: localFeedItems,
loading: loadingMore,
hasMore,
loadMore,
refreshFeed,
isRefreshing,
error,
};
}

99
src/hooks/use-offline.ts Normal file
View File

@@ -0,0 +1,99 @@
// Offline/Online Network Hook
import { useState, useEffect, useCallback } from 'react';
import { hasLocalData, getAllLocalFeedItems, getLocalFeedItems, syncAllFeeds } from '@/services/sync-service';
import { FeedItem } from '@/types/feed';
type NetworkStatus = 'online' | 'offline' | 'unknown';
interface UseOfflineReturn {
isOnline: boolean;
networkStatus: NetworkStatus;
hasOfflineData: boolean;
isSyncing: boolean;
lastSyncTime: Date | null;
syncNow: () => Promise<void>;
getOfflineFeedItems: (subscriptionId?: string, limit?: number, offset?: number) => Promise<FeedItem[]>;
}
export function useOffline(): UseOfflineReturn {
const [networkStatus, setNetworkStatus] = useState<NetworkStatus>('unknown');
const [hasOfflineData, setHasOfflineData] = useState(false);
const [isSyncing, setIsSyncing] = useState(false);
const [lastSyncTime, setLastSyncTime] = useState<Date | null>(null);
// Check network status (simplified - in production would use @react-native-community/netinfo)
const checkNetworkStatus = useCallback(async () => {
try {
// Try to fetch from a known endpoint to test connectivity
const response = await fetch('https://www.google.com/favicon.ico', {
method: 'HEAD',
mode: 'no-cors',
});
setNetworkStatus('online');
return true;
} catch {
setNetworkStatus('offline');
return false;
}
}, []);
// Check if we have offline data
const checkOfflineData = useCallback(async () => {
const hasData = await hasLocalData();
setHasOfflineData(hasData);
return hasData;
}, []);
// Sync now
const syncNow = useCallback(async () => {
if (networkStatus === 'offline') {
console.log('[Offline] Cannot sync while offline');
return;
}
setIsSyncing(true);
try {
await syncAllFeeds();
setLastSyncTime(new Date());
await checkOfflineData();
} finally {
setIsSyncing(false);
}
}, [networkStatus, checkOfflineData]);
// Get offline feed items
const getOfflineFeedItems = useCallback(async (
subscriptionId?: string,
limit: number = 50,
offset: number = 0
): Promise<FeedItem[]> => {
if (subscriptionId) {
return getLocalFeedItems(subscriptionId, limit, offset);
}
return getAllLocalFeedItems(limit);
}, []);
// Initial checks
useEffect(() => {
checkNetworkStatus();
checkOfflineData();
// Periodic network check
const interval = setInterval(() => {
checkNetworkStatus();
}, 30000); // Every 30 seconds
return () => clearInterval(interval);
}, [checkNetworkStatus, checkOfflineData]);
return {
isOnline: networkStatus === 'online',
networkStatus,
hasOfflineData,
isSyncing,
lastSyncTime,
syncNow,
getOfflineFeedItems,
};
}

113
src/i18n/index.ts Normal file
View File

@@ -0,0 +1,113 @@
// Internationalization Setup
import { getLocales } from 'expo-localization';
type IntlConfig = {
enableHighAccuracy: boolean;
fallbackLocale: string;
supportedLocales: string[];
};
const intlConfig: IntlConfig = {
enableHighAccuracy: true,
fallbackLocale: 'en',
supportedLocales: ['en', 'es', 'fr', 'de', 'ja', 'ko', 'zh'],
};
export const locales = {
en: {
name: 'English',
strings: {
'app.name': 'Rssuper',
'tab.home': 'Home',
'tab.explore': 'Explore',
'tab.settings': 'Settings',
'feed.add': 'Add Feed',
'feed.loading': 'Loading feeds...',
'feed.error': 'Error loading feed',
'feed.noItems': 'No items in this feed',
'explore.discover': 'Discover new feeds and podcasts',
'explore.searchPlaceholder': 'Search feeds, topics, podcasts',
'explore.all': 'All',
'explore.trending': 'Trending Feeds',
'explore.recommended': 'Recommended Podcasts',
'explore.noFeeds': 'No feeds found matching your criteria',
'common.cancel': 'Cancel',
'common.save': 'Save',
'common.delete': 'Delete',
'common.edit': 'Edit',
},
},
es: {
name: 'Espa\u00f1ol',
strings: {
'app.name': 'Rssuper',
'tab.home': 'Inicio',
'tab.explore': 'Explorar',
'tab.settings': 'Ajustes',
'feed.add': 'A\u00f1adir Feed',
'feed.loading': 'Cargando feeds...',
'feed.error': 'Error cargando feed',
'feed.noItems': 'No hay items en este feed',
'explore.discover': 'Descubre nuevos feeds y podcasts',
'explore.searchPlaceholder': 'Buscar feeds, temas, podcasts',
'explore.all': 'Todo',
'explore.trending': 'Feeds en tendencia',
'explore.recommended': 'Podcasts recomendados',
'explore.noFeeds': 'No se encontraron feeds',
'common.cancel': 'Cancelar',
'common.save': 'Guardar',
'common.delete': 'Eliminar',
'common.edit': 'Editar',
},
},
fr: {
name: 'Fran\u00e7ais',
strings: {
'app.name': 'Rssuper',
'tab.home': 'Accueil',
'tab.explore': 'Explorer',
'tab.settings': 'Paramètres',
'feed.add': 'Ajouter un flux',
'feed.loading': 'Chargement des flux...',
'feed.error': 'Erreur de chargement',
'feed.noItems': 'Aucun \u00e9l\u00e9ment dans ce flux',
'explore.discover': 'D\u00e9couvrez de nouveaux flux et podcasts',
'explore.searchPlaceholder': 'Rechercher des flux, th\u00e8mes, podcasts',
'explore.all': 'Tout',
'explore.trending': 'Flux tendances',
'explore.recommended': 'Podcasts recommand\u00e9s',
'explore.noFeeds': 'Aucun flux trouv\u00e9',
'common.cancel': 'Annuler',
'common.save': 'Enregistrer',
'common.delete': 'Supprimer',
'common.edit': 'Modifier',
},
},
// Add more languages as needed
};
export function t(key: string, locale?: string): string {
const currentLocale = locale || getLocales()[0]?.languageTag?.split('-')[0] || 'en';
const localeData = locales[currentLocale as keyof typeof locales];
if (!localeData) return key;
return localeData.strings[key as keyof typeof localeData.strings] || key;
}
export function useTranslation() {
const currentLocale = getLocales()[0]?.languageTag?.split('-')[0] || 'en';
return {
t: (key: string) => t(key, currentLocale),
locale: currentLocale,
locales: Object.entries(locales).map(([code, data]) => ({
code,
name: data.name,
})),
};
}
export { intlConfig };
export default intlConfig;

39
src/services/api.ts Normal file
View File

@@ -0,0 +1,39 @@
// API Layer Setup
import axios, { AxiosInstance, AxiosError } from 'axios';
const API_BASE_URL = process.env.EXPO_PUBLIC_API_URL || 'http://localhost:3000/api';
// Create axios instance with defaults
const apiClient: AxiosInstance = axios.create({
baseURL: API_BASE_URL,
timeout: 15000,
headers: {
'Content-Type': 'application/json',
},
});
// Request interceptor for auth tokens
apiClient.interceptors.request.use(
(config) => {
// TODO: Add auth token from store
// const token = useAuthStore.getState().token;
// if (token) {
// config.headers.Authorization = `Bearer ${token}`;
// }
return config;
},
(error) => Promise.reject(error)
);
// Response interceptor for error handling
apiClient.interceptors.response.use(
(response) => response,
(error: AxiosError) => {
// TODO: Add centralized error handling
console.error('API Error:', error.response?.status, error.message);
return Promise.reject(error);
}
);
export default apiClient;

View File

@@ -0,0 +1,162 @@
// Audio Player Service for Podcast Playback
import { Audio, AVPlaybackStatus, AVPlaybackStatusSuccess } from 'expo-av';
import { FeedItem } from '@/types/feed';
type PlaybackState = 'idle' | 'loading' | 'playing' | 'paused' | 'error';
type PlaybackSpeed = 0.5 | 0.75 | 1 | 1.25 | 1.5 | 1.75 | 2;
interface PlayerState {
state: PlaybackState;
currentItem: FeedItem | null;
position: number;
duration: number;
playbackSpeed: PlaybackSpeed;
isBuffering: boolean;
}
type StateChangeCallback = (state: PlayerState) => void;
class AudioPlayerService {
private sound: Audio.Sound | null = null;
private state: PlayerState = {
state: 'idle',
currentItem: null,
position: 0,
duration: 0,
playbackSpeed: 1,
isBuffering: false,
};
private listeners: Set<StateChangeCallback> = new Set();
async initialize(): Promise<void> {
await Audio.setAudioModeAsync({
allowsRecordingIOS: false,
playsInSilentModeIOS: true,
staysActiveInBackground: true,
shouldDuckAndroid: true,
playThroughEarpieceAndroid: false,
});
}
subscribe(callback: StateChangeCallback): () => void {
this.listeners.add(callback);
callback(this.state);
return () => this.listeners.delete(callback);
}
private updateState(updates: Partial<PlayerState>): void {
this.state = { ...this.state, ...updates };
this.listeners.forEach(cb => cb(this.state));
}
async play(item: FeedItem): Promise<void> {
if (!item.enclosure?.url) {
this.updateState({ state: 'error' });
return;
}
try {
this.updateState({ state: 'loading', currentItem: item, isBuffering: true });
// Unload previous sound
if (this.sound) {
await this.sound.unloadAsync();
this.sound = null;
}
// Create and load new sound
const { sound } = await Audio.Sound.createAsync(
{ uri: item.enclosure.url },
{ shouldPlay: true, progressUpdateIntervalMillis: 1000 },
this.onPlaybackStatusUpdate
);
this.sound = sound;
} catch (error) {
console.error('Playback error:', error);
this.updateState({ state: 'error' });
}
}
private onPlaybackStatusUpdate = (status: AVPlaybackStatus) => {
if (!status.isLoaded) {
if (status.error) {
this.updateState({ state: 'error' });
}
return;
}
const loadedStatus = status as AVPlaybackStatusSuccess;
this.updateState({
state: loadedStatus.isPlaying ? 'playing' : 'paused',
position: loadedStatus.positionMillis,
duration: loadedStatus.durationMillis || 0,
isBuffering: loadedStatus.isBuffering || false,
});
};
async pause(): Promise<void> {
if (this.sound) {
await this.sound.pauseAsync();
}
}
async resume(): Promise<void> {
if (this.sound) {
await this.sound.playAsync();
}
}
async stop(): Promise<void> {
if (this.sound) {
await this.sound.stopAsync();
await this.sound.unloadAsync();
this.sound = null;
}
this.updateState({
state: 'idle',
currentItem: null,
position: 0,
duration: 0,
});
}
async seekTo(positionMs: number): Promise<void> {
if (this.sound) {
await this.sound.setPositionAsync(positionMs);
}
}
async setPlaybackSpeed(speed: PlaybackSpeed): Promise<void> {
if (this.sound) {
await this.sound.setRateAsync(speed, true);
this.updateState({ playbackSpeed: speed });
}
}
async skipForward(seconds: number = 30): Promise<void> {
if (this.sound) {
const newPosition = Math.min(
this.state.position + seconds * 1000,
this.state.duration
);
await this.sound.setPositionAsync(newPosition);
}
}
async skipBackward(seconds: number = 10): Promise<void> {
if (this.sound) {
const newPosition = Math.max(this.state.position - seconds * 1000, 0);
await this.sound.setPositionAsync(newPosition);
}
}
getState(): PlayerState {
return this.state;
}
}
export const audioPlayer = new AudioPlayerService();
export type { PlaybackState, PlaybackSpeed, PlayerState };

View File

@@ -0,0 +1,90 @@
// Background Sync Service using expo-task-manager
import * as TaskManager from 'expo-task-manager';
import { syncAllFeeds, getFeedsDueForSync } from './sync-service';
import { useFeedStore } from '@/stores/feed-store';
const BACKGROUND_SYNC_TASK_NAME = 'BACKGROUND_SYNC_TASK';
const BACKGROUND_SYNC_INTERVAL = 15; // minutes
// Task manager result types
enum BackgroundFetchResult {
NoData = 'noData',
NewData = 'newData',
Failed = 'failed',
}
// Define the background task
TaskManager.defineTask(BACKGROUND_SYNC_TASK_NAME, async () => {
try {
console.log('[Background] Starting background sync...');
const subscriptions = useFeedStore.getState().subscriptions;
if (subscriptions.length === 0) {
console.log('[Background] No subscriptions to sync');
return BackgroundFetchResult.NoData;
}
const result = await syncAllFeeds();
if (result.success) {
console.log(`[Background] Synced ${result.totalItemsSynced} items`);
return BackgroundFetchResult.NewData;
} else {
console.log('[Background] Sync completed with errors');
return BackgroundFetchResult.Failed;
}
} catch (error) {
console.error('[Background] Sync error:', error);
return BackgroundFetchResult.Failed;
}
});
// Register background fetch task
export async function registerBackgroundSync(): Promise<boolean> {
try {
const task = TaskManager.defineTask(BACKGROUND_SYNC_TASK_NAME, async () => {
try {
console.log('[Background] Starting background sync...');
const subscriptions = useFeedStore.getState().subscriptions;
if (subscriptions.length === 0) {
console.log('[Background] No subscriptions to sync');
return;
}
const result = await syncAllFeeds();
console.log(`[Background] Synced ${result.totalItemsSynced} items`);
} catch (error) {
console.error('[Background] Sync error:', error);
}
});
const status = await TaskManager.isTaskRegisteredAsync(BACKGROUND_SYNC_TASK_NAME);
if (status) {
console.log('[Background] Background sync already registered');
return true;
}
console.log('[Background] Background sync registered successfully');
return true;
} catch (error) {
console.error('[Background] Failed to register background sync:', error);
return false;
}
}
// Unregister background fetch task
export async function unregisterBackgroundSync(): Promise<void> {
try {
await TaskManager.unregisterTaskAsync(BACKGROUND_SYNC_TASK_NAME);
console.log('[Background] Background sync unregistered');
} catch (error) {
console.error('[Background] Failed to unregister:', error);
}
}
// Check if background sync task is registered
export async function isBackgroundSyncRegistered(): Promise<boolean> {
return TaskManager.isTaskRegisteredAsync(BACKGROUND_SYNC_TASK_NAME);
}

320
src/services/database.ts Normal file
View File

@@ -0,0 +1,320 @@
// Database Layer for Feed Storage
import * as SQLite from 'expo-sqlite';
import { FeedSubscription, FeedItem } from '@/types/feed';
let db: SQLite.SQLiteDatabase | null = null;
export async function initDatabase(): Promise<void> {
if (db) return;
try {
db = await SQLite.openDatabaseAsync('rssuper.db');
await db.execAsync(`
CREATE TABLE IF NOT EXISTS subscriptions (
id TEXT PRIMARY KEY,
url TEXT NOT NULL UNIQUE,
title TEXT,
category TEXT,
enabled INTEGER DEFAULT 1,
fetch_interval INTEGER DEFAULT 60,
http_auth_username TEXT,
http_auth_password TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
last_fetched_at DATETIME,
next_fetch_at DATETIME,
error TEXT
);
CREATE TABLE IF NOT EXISTS feeds (
id TEXT PRIMARY KEY,
subscription_id TEXT NOT NULL,
title TEXT,
link TEXT,
description TEXT,
author TEXT,
published DATETIME,
updated DATETIME,
content TEXT,
guid TEXT UNIQUE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (subscription_id) REFERENCES subscriptions(id)
);
CREATE INDEX IF NOT EXISTS idx_feeds_subscription ON feeds(subscription_id);
CREATE INDEX IF NOT EXISTS idx_feeds_published ON feeds(published DESC);
CREATE INDEX IF NOT EXISTS idx_subscriptions_next_fetch ON subscriptions(next_fetch_at);
CREATE INDEX IF NOT EXISTS idx_feeds_author ON feeds(author);
CREATE INDEX IF NOT EXISTS idx_feeds_link ON feeds(link);
CREATE VIRTUAL TABLE IF NOT EXISTS feeds_fts USING fts5(
title,
description,
content,
content='feeds',
content_rowid='rowid'
);
CREATE VIRTUAL TABLE IF NOT EXISTS subscriptions_fts USING fts5(
title,
url,
content='subscriptions',
content_rowid='rowid'
);
CREATE TRIGGER IF NOT EXISTS feeds_ai AFTER INSERT ON feeds BEGIN
INSERT INTO feeds_fts(rowid, title, description, content)
VALUES (new.rowid, new.title, new.description, new.content);
END;
CREATE TRIGGER IF NOT EXISTS feeds_ad AFTER DELETE ON feeds BEGIN
INSERT INTO feeds_fts(feeds_fts, rowid, title, description, content)
VALUES ('delete', old.rowid, old.title, old.description, old.content);
END;
CREATE TRIGGER IF NOT EXISTS feeds_au AFTER UPDATE ON feeds BEGIN
INSERT INTO feeds_fts(feeds_fts, rowid, title, description, content)
VALUES ('delete', old.rowid, old.title, old.description, old.content);
INSERT INTO feeds_fts(rowid, title, description, content)
VALUES (new.rowid, new.title, new.description, new.content);
END;
CREATE TRIGGER IF NOT EXISTS subscriptions_ai AFTER INSERT ON subscriptions BEGIN
INSERT INTO subscriptions_fts(rowid, title, url)
VALUES (new.rowid, new.title, new.url);
END;
CREATE TRIGGER IF NOT EXISTS subscriptions_ad AFTER DELETE ON subscriptions BEGIN
INSERT INTO subscriptions_fts(subscriptions_fts, rowid, title, url)
VALUES ('delete', old.rowid, old.title, old.url);
END;
CREATE TRIGGER IF NOT EXISTS subscriptions_au AFTER UPDATE ON subscriptions BEGIN
INSERT INTO subscriptions_fts(subscriptions_fts, rowid, title, url)
VALUES ('delete', old.rowid, old.title, old.url);
INSERT INTO subscriptions_fts(rowid, title, url)
VALUES (new.rowid, new.title, new.url);
END;
CREATE TABLE IF NOT EXISTS search_history (
id TEXT PRIMARY KEY,
query TEXT NOT NULL,
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_search_history_timestamp ON search_history(timestamp DESC);
`);
console.log('Database initialized successfully');
} catch (error) {
console.error('Failed to initialize database:', error);
throw error;
}
}
// Helper to get database instance
export async function getDb(): Promise<SQLite.SQLiteDatabase> {
if (!db) await initDatabase();
if (!db) throw new Error('Database not initialized');
return db;
}
// Subscription CRUD
export async function saveSubscription(sub: FeedSubscription): Promise<void> {
const database = await getDb();
await database.runAsync(
`INSERT OR REPLACE INTO subscriptions
(id, url, title, category, enabled, fetch_interval,
http_auth_username, http_auth_password, updated_at,
last_fetched_at, next_fetch_at, error)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, ?, ?, ?)`,
[
sub.id,
sub.url,
sub.title,
sub.category || null,
sub.enabled ? 1 : 0,
sub.fetchInterval,
sub.httpAuth?.username || null,
sub.httpAuth?.password || null,
sub.lastFetchedAt?.toISOString() || null,
sub.nextFetchAt?.toISOString() || null,
sub.error || null,
]
);
}
export async function getSubscription(id: string): Promise<FeedSubscription | null> {
const database = await getDb();
const row = await database.getFirstAsync<any>(
'SELECT * FROM subscriptions WHERE id = ?',
[id]
);
if (!row) return null;
return rowToSubscription(row);
}
export async function getAllSubscriptions(): Promise<FeedSubscription[]> {
const database = await getDb();
const rows = await database.getAllAsync<any>('SELECT * FROM subscriptions ORDER BY title');
return rows.map(rowToSubscription);
}
export async function deleteSubscription(id: string): Promise<void> {
const database = await getDb();
await database.runAsync('DELETE FROM subscriptions WHERE id = ?', [id]);
await database.runAsync('DELETE FROM feeds WHERE subscription_id = ?', [id]);
}
// Feed Item CRUD
export async function saveFeedItems(
subscriptionId: string,
items: FeedItem[]
): Promise<void> {
const database = await getDb();
const insertQuery = `
INSERT OR REPLACE INTO feeds
(id, subscription_id, title, link, description, author,
published, updated, content, guid)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`;
for (const item of items) {
await database.runAsync(insertQuery, [
item.id,
subscriptionId,
item.title || null,
item.link || null,
item.description || null,
item.author || null,
item.published?.toISOString() || null,
item.updated?.toISOString() || null,
item.content || null,
item.guid || null,
]);
}
}
export async function getFeedItems(
subscriptionId?: string,
limit: number = 50,
offset: number = 0
): Promise<FeedItem[]> {
const database = await getDb();
let query = 'SELECT f.*, s.title as subscription_title FROM feeds f '
+ 'LEFT JOIN subscriptions s ON f.subscription_id = s.id ';
const params: any[] = [];
if (subscriptionId) {
query += 'WHERE f.subscription_id = ? ';
params.push(subscriptionId);
}
query += 'ORDER BY f.published DESC LIMIT ? OFFSET ?';
params.push(limit, offset);
const rows = await database.getAllAsync<any>(query, params);
return rows.map(rowToFeedItem);
}
export async function getAllFeedItems(limit: number = 100, offset: number = 0): Promise<FeedItem[]> {
const database = await getDb();
const rows = await database.getAllAsync<any>(
'SELECT f.*, s.title as subscription_title FROM feeds f '
+ 'LEFT JOIN subscriptions s ON f.subscription_id = s.id '
+ 'ORDER BY f.published DESC LIMIT ? OFFSET ?',
[limit, offset]
);
return rows.map(rowToFeedItem);
}
// Helper functions
function rowToSubscription(row: any): FeedSubscription {
return {
id: row.id,
url: row.url,
title: row.title || 'Untitled',
category: row.category,
enabled: row.enabled === 1,
fetchInterval: row.fetch_interval,
httpAuth: row.http_auth_username && row.http_auth_password ? {
username: row.http_auth_username,
password: row.http_auth_password,
} : undefined,
createdAt: new Date(row.created_at),
updatedAt: new Date(row.updated_at),
lastFetchedAt: row.last_fetched_at ? new Date(row.last_fetched_at) : undefined,
nextFetchAt: row.next_fetch_at ? new Date(row.next_fetch_at) : undefined,
error: row.error,
};
}
function rowToFeedItem(row: any): FeedItem {
return {
id: row.id,
title: row.title || '',
link: row.link,
description: row.description,
content: row.content,
author: row.author,
published: row.published ? new Date(row.published) : undefined,
updated: row.updated ? new Date(row.updated) : undefined,
guid: row.guid,
subscriptionTitle: row.subscription_title,
};
}
export async function closeDatabase(): Promise<void> {
if (db) {
await db.closeAsync();
db = null;
}
}
// Search History Functions
export async function addToSearchHistory(query: string): Promise<void> {
const database = await getDb();
const id = `${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
await database.runAsync(
'INSERT OR REPLACE INTO search_history (id, query, timestamp) VALUES (?, ?, CURRENT_TIMESTAMP)',
[id, query]
);
}
export async function getSearchHistory(limit: number = 10): Promise<{ query: string; timestamp: string }[]> {
const database = await getDb();
const rows = await database.getAllAsync<any>(
'SELECT query, timestamp FROM search_history ORDER BY timestamp DESC LIMIT ?',
[limit]
);
return rows.map(row => ({ query: row.query, timestamp: row.timestamp }));
}
export async function clearSearchHistory(): Promise<void> {
const database = await getDb();
await database.runAsync('DELETE FROM search_history');
}
export async function removeSearchHistoryItem(query: string): Promise<void> {
const database = await getDb();
await database.runAsync('DELETE FROM search_history WHERE query = ?', [query]);
}

View File

@@ -0,0 +1,185 @@
import axios from 'axios';
import { parseStringPromise } from 'xml2js';
import { Feed, FeedItem, ParseResult, FeedSubscription } from '@/types/feed';
const parseOptions = {
explicitArray: false,
mergeAttrs: true,
trim: true,
};
// Feed detection
function detectFeedType(data: string): 'rss' | 'atom' | 'unknown' {
if (data.includes('<rss')) return 'rss';
if (data.includes('<feed')) return 'atom';
return 'unknown';
}
// RSS 2.0 Parser with iTunes podcast support
async function parseRSS(xml: any): Promise<Feed> {
const rss = xml.rss;
const channel = rss?.channel;
if (!channel) {
throw new Error('Invalid RSS: No channel found');
}
// Extract iTunes namespace fields
const itunes = channel['itunes:navigation'] || {};
const itunesImage = itunes.image || channel['itunes:image'];
const items: FeedItem[] = (channel.item || []).map((item: any) => {
const itunesItem = item['itunes:navigation'] || {};
const enclosure = item.enclosure?.['$'] || item.enclosure;
return {
id: item.guid?.['$']?.value || item.link || crypto.randomUUID(),
title: item.title,
link: item.link,
description: item.description || itunesItem.summary,
content: item['content:encoded'] || item.description,
author: item.author || itunesItem.author,
published: item.pubDate ? new Date(item.pubDate) : undefined,
categories: item.category?.map((c: any) => c) || [],
enclosure: enclosure ? {
url: enclosure.url,
type: enclosure.type,
length: enclosure.length ? parseInt(enclosure.length) : undefined,
} : undefined,
guid: item.guid?.['$']?.value,
};
});
return {
id: crypto.randomUUID(),
title: channel.title || 'Untitled Feed',
link: channel.link,
description: channel.description,
subtitle: itunes.summary,
language: channel.language,
lastBuildDate: channel.lastBuildDate ? new Date(channel.lastBuildDate) : undefined,
generator: channel.generator,
ttl: channel.ttl ? parseInt(channel.ttl) : undefined,
items,
rawUrl: '',
};
}
// Atom 1.0 Parser
async function parseAtom(xml: any): Promise<Feed> {
const feed = xml.feed;
if (!feed) {
throw new Error('Invalid Atom: No feed found');
}
const entries: FeedItem[] = (feed.entry || []).map((entry: any) => ({
id: entry.id?.$?.value || entry.link?.href || crypto.randomUUID(),
title: entry.title,
link: Array.isArray(entry.link)
? entry.link.find((l: any) => !l.rel)?.href
: entry.link?.href,
summary: entry.summary,
content: entry.content,
author: Array.isArray(entry.author)
? entry.author[0]?.name
: entry.author?.name,
published: entry.published ? new Date(entry.published) : undefined,
updated: entry.updated ? new Date(entry.updated) : undefined,
categories: (feed.category || []).map((c: any) => c.term || c),
guid: entry.id?.$?.value,
}));
return {
id: crypto.randomUUID(),
title: feed.title || 'Untitled Feed',
link: feed.link?.href,
subtitle: feed.subtitle,
updated: feed.updated ? new Date(feed.updated) : undefined,
generator: feed.generator,
items: entries,
rawUrl: '',
};
}
// Main parse function
export async function parseFeed(url: string, data: string): Promise<ParseResult> {
try {
const feedType = detectFeedType(data);
if (feedType === 'unknown') {
return {
success: false,
error: 'Could not detect feed type',
feedType,
};
}
const xml = await parseStringPromise(data, parseOptions);
let feed: Feed;
if (feedType === 'rss') {
feed = await parseRSS(xml);
} else {
feed = await parseAtom(xml);
}
feed.rawUrl = url;
feed.lastFetchedAt = new Date();
return {
success: true,
feed,
feedType,
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error occurred',
feedType: detectFeedType(data),
};
}
}
// Fetch feed from URL
export async function fetchFeed(
url: string,
auth?: { username: string; password: string }
): Promise<ParseResult> {
try {
const config: any = {
timeout: 15000,
validateStatus: (status: number) => status >= 200 && status < 400,
};
if (auth) {
config.auth = {
username: auth.username,
password: auth.password,
};
}
const response = await axios.get(url, config);
return await parseFeed(url, response.data);
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to fetch feed',
};
}
}
// Batch fetch multiple feeds
export async function fetchFeeds(
subscriptions: FeedSubscription[]
): Promise<Map<string, ParseResult>> {
const results = new Map<string, ParseResult>();
for (const subscription of subscriptions) {
const auth = subscription.httpAuth;
const result = await fetchFeed(subscription.url, auth);
results.set(subscription.id, result);
}
return results;
}

View File

@@ -0,0 +1,276 @@
// Notification Service for Push Notifications
import { Platform } from 'react-native';
import * as Notifications from 'expo-notifications';
import { useSettingsStore } from '@/stores/settings-store';
import { NotificationType } from '@/types/global';
// Define local types compatible with expo-notifications
interface NotificationConfig {
id: string;
type: NotificationType;
title: string;
body: string;
data?: Record<string, unknown>;
urgency?: 'normal' | 'high';
sound?: string;
badge?: number;
}
// Configure notification behavior
Notifications.setNotificationHandler({
handleNotification: async () => {
const settings = useSettingsStore.getState();
// Check if notifications are enabled
if (!settings.accountSettings.notificationsEnabled) {
return {
shouldShowBanner: false,
shouldShowList: false,
shouldPlaySound: false,
shouldSetBadge: false,
};
}
return {
shouldShowBanner: true,
shouldShowList: true,
shouldPlaySound: settings.notificationPreferences.sound,
shouldVibrate: settings.notificationPreferences.vibration,
shouldSetBadge: settings.notificationPreferences.badgeCount,
};
},
});
// Notification channel configuration (Android)
export async function registerNotificationChannels(): Promise<void> {
if (Platform.OS === 'android') {
await Notifications.setNotificationChannelAsync('default', {
name: 'default',
importance: Notifications.AndroidImportance.MAX,
vibrationPattern: [0, 250, 250, 250],
lightColor: '#FF231F7C',
});
await Notifications.setNotificationChannelAsync('new-articles', {
name: 'new-articles',
importance: Notifications.AndroidImportance.DEFAULT,
vibrationPattern: [0, 100],
});
await Notifications.setNotificationChannelAsync('episode-releases', {
name: 'episode-releases',
importance: Notifications.AndroidImportance.DEFAULT,
vibrationPattern: [0, 100],
});
await Notifications.setNotificationChannelAsync('custom-alerts', {
name: 'custom-alerts',
importance: Notifications.AndroidImportance.MAX,
vibrationPattern: [0, 250, 250, 250],
});
}
}
// Request notification permissions
export async function requestNotificationPermissions(): Promise<boolean> {
const { status } = await Notifications.requestPermissionsAsync();
return status === 'granted';
}
// Get current permission status
export async function getNotificationPermissionStatus(): Promise<'denied' | 'granted' | 'undetermined'> {
const { status } = await Notifications.getPermissionsAsync();
return status;
}
// Schedule a local notification
export async function scheduleNotification(
notification: NotificationConfig
): Promise<string | null> {
const settings = useSettingsStore.getState();
// Check if notification type is enabled
const isEnabled = getNotificationTypeEnabled(notification.type, settings);
if (!isEnabled) {
return null;
}
const trigger: Notifications.SchedulableNotificationTriggerInput = {
type: Notifications.SchedulableTriggerInputTypes.TIME_INTERVAL,
seconds: 1,
};
const content: Notifications.NotificationContentInput = {
title: notification.title,
body: notification.body,
data: notification.data,
sound: notification.sound,
badge: notification.badge,
priority: notification.urgency === 'high'
? Notifications.AndroidNotificationPriority.HIGH
: Notifications.AndroidNotificationPriority.DEFAULT,
};
// Add channel ID for Android
if (Platform.OS === 'android') {
const channelId = getChannelIdForType(notification.type);
if (channelId) {
(trigger as Notifications.TimeIntervalTriggerInput).channelId = channelId;
}
}
const scheduled = await Notifications.scheduleNotificationAsync({
content,
trigger,
});
return scheduled;
}
// Schedule notification at a specific time
export async function scheduleNotificationAtTime(
notification: NotificationConfig,
date: Date
): Promise<string | null> {
const settings = useSettingsStore.getState();
const isEnabled = getNotificationTypeEnabled(notification.type, settings);
if (!isEnabled) {
return null;
}
const trigger: Notifications.DateTriggerInput = {
type: Notifications.SchedulableTriggerInputTypes.DATE,
date,
};
const content: Notifications.NotificationContentInput = {
title: notification.title,
body: notification.body,
data: notification.data,
sound: notification.sound,
badge: notification.badge,
};
// Add channel ID for Android
if (Platform.OS === 'android') {
const channelId = getChannelIdForType(notification.type);
if (channelId) {
trigger.channelId = channelId;
}
}
const scheduled = await Notifications.scheduleNotificationAsync({
content,
trigger,
});
return scheduled;
}
// Cancel a scheduled notification
export async function cancelNotification(
identifier: string
): Promise<void> {
await Notifications.cancelScheduledNotificationAsync(identifier);
}
// Cancel all scheduled notifications
export async function cancelAllNotifications(): Promise<void> {
await Notifications.cancelAllScheduledNotificationsAsync();
}
// Get all scheduled notifications
export async function getScheduledNotifications(): Promise<
Notifications.NotificationRequest[]
> {
return await Notifications.getAllScheduledNotificationsAsync();
}
// Show an immediate notification
export async function showNotification(
notification: NotificationConfig
): Promise<void> {
const settings = useSettingsStore.getState();
const isEnabled = getNotificationTypeEnabled(notification.type, settings);
if (!isEnabled) {
return;
}
const content: Notifications.NotificationContentInput = {
title: notification.title,
body: notification.body,
data: notification.data,
sound: notification.sound,
badge: notification.badge,
};
// For immediate notification, use null trigger
await Notifications.scheduleNotificationAsync({
content,
trigger: null,
});
}
// Set badge count
export async function setBadgeCount(count: number): Promise<void> {
await Notifications.setBadgeCountAsync(count);
}
// Get badge count
export async function getBadgeCount(): Promise<number> {
return await Notifications.getBadgeCountAsync();
}
// Add notification listener
export function addNotificationListener(
callback: (notification: Notifications.Notification) => void
): () => void {
const subscription = Notifications.addNotificationReceivedListener(callback);
return () => subscription.remove();
}
// Add response listener
export function addNotificationResponseListener(
callback: (response: Notifications.NotificationResponse) => void
): () => void {
const subscription = Notifications.addNotificationResponseReceivedListener(callback);
return () => subscription.remove();
}
// Check if notification type is enabled based on settings
function getNotificationTypeEnabled(
type: NotificationType,
settings: any
): boolean {
switch (type) {
case NotificationType.NEW_ARTICLE:
return settings.notificationPreferences.newArticles;
case NotificationType.EPISODE_RELEASE:
return settings.notificationPreferences.episodeReleases;
case NotificationType.CUSTOM_ALERT:
return settings.notificationPreferences.customAlerts;
default:
return true;
}
}
// Get channel ID for notification type (Android)
function getChannelIdForType(type: NotificationType): string | undefined {
switch (type) {
case NotificationType.NEW_ARTICLE:
return 'new-articles';
case NotificationType.EPISODE_RELEASE:
return 'episode-releases';
case NotificationType.CUSTOM_ALERT:
return 'custom-alerts';
default:
return 'default';
}
}
// Export notification types for use in other modules
export type { NotificationConfig };
export { NotificationType };

View File

@@ -0,0 +1,13 @@
// React Query Configuration
import { QueryClient } from '@tanstack/react-query';
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5 minutes
retry: 1,
refetchOnWindowFocus: false,
},
},
});

View File

@@ -0,0 +1,266 @@
// Search Service
import { FeedItem, SearchFilters, SearchSortOption, SearchResult } from '@/types/feed';
import { getDb } from './database';
export interface SearchOptions {
filters?: SearchFilters;
sort?: SearchSortOption;
page?: number;
pageSize?: number;
}
export async function searchArticles(
query: string,
options: SearchOptions = {}
): Promise<SearchResult[]> {
if (!query || query.trim().length === 0) {
return [];
}
const database = await getDb();
const { filters = {}, sort = 'relevance', page = 1, pageSize = 20 } = options;
const offset = (page - 1) * pageSize;
// Build WHERE clause for filters
const whereClauses: string[] = [];
const params: any[] = [];
// Date filters
if (filters.dateFrom) {
whereClauses.push('f.published >= ?');
params.push(filters.dateFrom.toISOString());
}
if (filters.dateTo) {
whereClauses.push('f.published <= ?');
params.push(filters.dateTo.toISOString());
}
// Feed filters
if (filters.feedIds && filters.feedIds.length > 0) {
const placeholders = filters.feedIds.map(() => '?').join(',');
whereClauses.push(`f.subscription_id IN (${placeholders})`);
params.push(...filters.feedIds);
}
// Author filters
if (filters.authors && filters.authors.length > 0) {
const placeholders = filters.authors.map(() => '?').join(',');
whereClauses.push(`f.author IN (${placeholders})`);
params.push(...filters.authors);
}
// Content type filter
if (filters.contentType === 'audio') {
whereClauses.push('(f.content LIKE "%enclosure%" OR f.description LIKE "%enclosure%")');
} else if (filters.contentType === 'video') {
whereClauses.push('(f.content LIKE "%video%" OR f.description LIKE "%video%")');
}
const whereClause = whereClauses.length > 0
? `WHERE ${whereClauses.join(' AND ')}`
: '';
// Build ORDER BY clause
let orderByClause = '';
switch (sort) {
case 'date_desc':
orderByClause = 'ORDER BY f.published DESC';
break;
case 'date_asc':
orderByClause = 'ORDER BY f.published ASC';
break;
case 'title_asc':
orderByClause = 'ORDER BY f.title ASC';
break;
case 'title_desc':
orderByClause = 'ORDER BY f.title DESC';
break;
case 'feed_asc':
orderByClause = 'ORDER BY s.title ASC';
break;
case 'feed_desc':
orderByClause = 'ORDER BY s.title DESC';
break;
case 'relevance':
default:
// For relevance, we rely on FTS ranking
break;
}
// Use FTS for full-text search when available
let results: any[] = [];
try {
// Try FTS search first
const ftsQuery = `SELECT f.rowid, f.rank FROM feeds_fts
WHERE feeds_fts MATCH ? ${whereClause}`;
const ftsResults = await database.getAllAsync<any>(ftsQuery, [...params, query]);
if (ftsResults.length > 0) {
const rowIds = ftsResults.map((r: any) => r.rowid);
const placeholders = rowIds.map(() => '?').join(',');
const selectQuery = `
SELECT f.*, s.title as subscription_title, feeds_fts.rank
FROM feeds f
INNER JOIN feeds_fts ON f.rowid = feeds_fts.rowid
LEFT JOIN subscriptions s ON f.subscription_id = s.id
WHERE f.rowid IN (${placeholders})
${orderByClause || 'ORDER BY feeds_fts.rank'}
LIMIT ? OFFSET ?
`;
results = await database.getAllAsync<any>(
selectQuery,
[...rowIds, pageSize, offset]
);
}
} catch (error) {
// Fallback to LIKE search if FTS fails
console.warn('FTS search failed, falling back to LIKE search:', error);
const likeQuery = `
SELECT f.*, s.title as subscription_title
FROM feeds f
LEFT JOIN subscriptions s ON f.subscription_id = s.id
${whereClause}
WHERE f.title LIKE ? OR f.description LIKE ? OR f.content LIKE ?
${orderByClause}
LIMIT ? OFFSET ?
`;
const likeParams = [
`%${query}%`,
`%${query}%`,
`%${query}%`,
...params,
pageSize,
offset
];
results = await database.getAllAsync<any>(likeQuery, likeParams);
}
return results.map(row => ({
id: row.id,
type: 'article' as const,
title: row.title || 'Untitled',
snippet: extractSnippet(row.title, row.description, row.content, query),
link: row.link,
feedTitle: row.subscription_title,
published: row.published ? new Date(row.published) : undefined,
score: row.rank !== undefined ? row.rank : undefined,
}));
}
export async function searchFeeds(
query: string,
limit: number = 10
): Promise<SearchResult[]> {
if (!query || query.trim().length === 0) {
return [];
}
const database = await getDb();
try {
// Try FTS search first
const ftsQuery = `SELECT s.rowid, s.title, s.url, subscriptions_fts.rank
FROM subscriptions s
INNER JOIN subscriptions_fts ON s.rowid = subscriptions_fts.rowid
WHERE subscriptions_fts MATCH ?
ORDER BY subscriptions_fts.rank
LIMIT ?`;
const ftsResults = await database.getAllAsync<any>(ftsQuery, [query, limit]);
return ftsResults.map((row: any) => ({
id: row.rowid.toString(),
type: 'feed' as const,
title: row.title || 'Untitled Feed',
link: row.url,
score: row.rank !== undefined ? row.rank : undefined,
}));
} catch (error) {
// Fallback to LIKE search if FTS fails
console.warn('FTS feed search failed, falling back to LIKE search:', error);
const likeQuery = `
SELECT id, title, url
FROM subscriptions
WHERE title LIKE ? OR url LIKE ?
LIMIT ?
`;
const likeResults = await database.getAllAsync<any>(
likeQuery,
[`%${query}%`, `%${query}%`, limit]
);
return likeResults.map((row: any) => ({
id: row.id,
type: 'feed' as const,
title: row.title || 'Untitled Feed',
link: row.url,
}));
}
}
export async function combinedSearch(
query: string,
options: SearchOptions = {}
): Promise<{ articles: SearchResult[]; feeds: SearchResult[] }> {
const [articles, feeds] = await Promise.all([
searchArticles(query, options),
searchFeeds(query, options.pageSize || 10)
]);
return { articles, feeds };
}
// Helper function to extract a snippet highlighting the search terms
function extractSnippet(
title: string | null,
description: string | null,
content: string | null,
query: string
): string | undefined {
const texts = [
title || '',
description || '',
content || ''
].filter(Boolean);
if (texts.length === 0) return undefined;
const queryLower = query.toLowerCase();
const words = queryLower.split(/\s+/).filter(w => w.length > 0);
for (const text of texts) {
const textLower = text.toLowerCase();
const matchIndex = words.some(word => textLower.includes(word))
? textLower.indexOf(words.find(w => textLower.includes(w)) || '')
: -1;
if (matchIndex >= 0) {
const start = Math.max(0, matchIndex - 50);
const end = Math.min(text.length, matchIndex + query.length + 100);
let snippet = text.substring(start, end);
if (start > 0) snippet = '...' + snippet;
if (end < text.length) snippet = snippet + '...';
return snippet.trim();
}
}
// Fallback to first 150 chars of description or content
const fallback = description || content || title || '';
if (fallback.length > 150) {
return fallback.substring(0, 150).trim() + '...';
}
return fallback.trim() || undefined;
}

View File

@@ -0,0 +1,133 @@
// Feed Sync Service
import { fetchFeeds, fetchFeed } from './feed-service';
import { useFeedStore } from '@/stores/feed-store';
import { FeedSubscription, FeedItem } from '@/types/feed';
import {
saveFeedItems,
getAllFeedItems,
getFeedItems as getFeedItemsFromDb,
saveSubscription,
} from './database';
// Calculate next fetch time based on interval
function calculateNextFetchTime(intervalMinutes: number): Date {
const now = new Date();
now.setMinutes(now.getMinutes() + intervalMinutes);
return now;
}
// Manual sync function for a single feed
export async function syncFeed(subscription: FeedSubscription): Promise<{ success: boolean; itemsSynced: number }> {
const result = await fetchFeed(
subscription.url,
subscription.httpAuth
);
if (result.success && result.feed) {
// Save feed items to database
if (result.feed.items && result.feed.items.length > 0) {
await saveFeedItems(subscription.id, result.feed.items);
}
// Update subscription in store and database
const updates = {
lastFetchedAt: new Date(),
nextFetchAt: calculateNextFetchTime(subscription.fetchInterval),
error: undefined,
title: result.feed.title,
};
useFeedStore.getState().updateSubscription(subscription.id, updates);
await saveSubscription({ ...subscription, ...updates });
return { success: true, itemsSynced: result.feed.items?.length || 0 };
} else {
useFeedStore.getState().updateSubscription(subscription.id, {
error: result.error || 'Unknown error',
});
return { success: false, itemsSynced: 0 };
}
}
// Sync all enabled feeds
export async function syncAllFeeds(): Promise<{ success: boolean; totalItemsSynced: number }> {
const subscriptions = useFeedStore
.getState()
.subscriptions.filter((sub) => sub.enabled);
let totalItemsSynced = 0;
let hasErrors = false;
for (const subscription of subscriptions) {
const result = await syncFeed(subscription);
if (!result.success) {
hasErrors = true;
}
totalItemsSynced += result.itemsSynced;
}
return { success: !hasErrors, totalItemsSynced };
}
// Get feeds that are due for sync
export function getFeedsDueForSync(): FeedSubscription[] {
const now = new Date();
return useFeedStore.getState().subscriptions.filter(
(sub) => sub.enabled && new Date(sub.nextFetchAt || 0) <= now
);
}
// Get feed items from local database (offline support)
export async function getLocalFeedItems(
subscriptionId?: string,
limit: number = 50,
offset: number = 0
): Promise<FeedItem[]> {
return getFeedItemsFromDb(subscriptionId, limit, offset);
}
// Get all feed items from local database
export async function getAllLocalFeedItems(limit: number = 100): Promise<FeedItem[]> {
return getAllFeedItems(limit);
}
// Check if we have any local data
export async function hasLocalData(): Promise<boolean> {
const items = await getAllFeedItems(1);
return items.length > 0;
}
// Conflict resolution strategies
export type ConflictResolutionStrategy = 'newer' | 'older' | 'local' | 'remote';
interface ConflictResolutionOptions {
strategy: ConflictResolutionStrategy;
localItem: FeedItem;
remoteItem: FeedItem;
}
export function resolveConflict(options: ConflictResolutionOptions): FeedItem {
const { strategy, localItem, remoteItem } = options;
switch (strategy) {
case 'newer':
const localDate = localItem.updated || localItem.published || new Date(0);
const remoteDate = remoteItem.updated || remoteItem.published || new Date(0);
return remoteDate > localDate ? remoteItem : localItem;
case 'older':
const olderLocalDate = localItem.updated || localItem.published || new Date();
const olderRemoteDate = remoteItem.updated || remoteItem.published || new Date();
return olderRemoteDate < olderLocalDate ? remoteItem : localItem;
case 'local':
return localItem;
case 'remote':
return remoteItem;
default:
return remoteItem;
}
}

View File

@@ -0,0 +1,63 @@
// Bookmark Store
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
interface BookmarkState {
bookmarkedIds: Set<string>;
// Actions
addBookmark: (articleId: string) => void;
removeBookmark: (articleId: string) => void;
toggleBookmark: (articleId: string) => void;
isBookmarked: (articleId: string) => boolean;
getBookmarks: () => string[];
}
export const useBookmarkStore = create<BookmarkState>()(
persist(
(set, get) => ({
bookmarkedIds: new Set<string>(),
addBookmark: (articleId: string) =>
set((state) => ({
bookmarkedIds: new Set([...state.bookmarkedIds, articleId]),
})),
removeBookmark: (articleId: string) =>
set((state) => {
const newSet = new Set(state.bookmarkedIds);
newSet.delete(articleId);
return { bookmarkedIds: newSet };
}),
toggleBookmark: (articleId: string) =>
set((state) => {
const newSet = new Set(state.bookmarkedIds);
if (newSet.has(articleId)) {
newSet.delete(articleId);
} else {
newSet.add(articleId);
}
return { bookmarkedIds: newSet };
}),
isBookmarked: (articleId: string) =>
get().bookmarkedIds.has(articleId),
getBookmarks: () =>
Array.from(get().bookmarkedIds),
}),
{
name: 'rssuper-bookmarks',
storage: createJSONStorage(() => localStorage),
partialize: (state) => ({
bookmarkedIds: Array.from(state.bookmarkedIds),
}),
merge: (persisted: any, current) => ({
...current,
bookmarkedIds: new Set(persisted?.bookmarkedIds || []),
}),
}
)
);

103
src/stores/feed-store.ts Normal file
View File

@@ -0,0 +1,103 @@
import { create } from 'zustand';
import { FeedSubscription, FeedItem } from '@/types/feed';
import {
getAllSubscriptions,
saveSubscription,
deleteSubscription as deleteSubscriptionDb,
getAllFeedItems,
getFeedItems as getFeedItemsFromDb,
} from '@/services/database';
interface FeedStoreState {
subscriptions: FeedSubscription[];
feedItems: FeedItem[];
loading: boolean;
error: string | null;
// Actions
addSubscription: (subscription: FeedSubscription) => Promise<void>;
updateSubscription: (id: string, updates: Partial<FeedSubscription>) => void;
removeSubscription: (id: string) => Promise<void>;
loadSubscriptions: () => Promise<void>;
loadFeedItems: (subscriptionId?: string, limit?: number, offset?: number) => Promise<void>;
setLoading: (loading: boolean) => void;
setError: (error: string | null) => void;
clearError: () => void;
}
export const useFeedStore = create<FeedStoreState>((set, get) => ({
subscriptions: [],
feedItems: [],
loading: false,
error: null,
addSubscription: async (subscription) => {
try {
await saveSubscription(subscription);
set((state) => ({
subscriptions: [...state.subscriptions, subscription],
}));
} catch (error) {
set({ error: error instanceof Error ? error.message : 'Failed to add subscription' });
throw error;
}
},
updateSubscription: (id, updates) =>
set((state) => ({
subscriptions: state.subscriptions.map((sub) =>
sub.id === id ? { ...sub, ...updates } : sub
),
})),
removeSubscription: async (id) => {
try {
await deleteSubscriptionDb(id);
set((state) => ({
subscriptions: state.subscriptions.filter((sub) => sub.id !== id),
}));
} catch (error) {
set({ error: error instanceof Error ? error.message : 'Failed to remove subscription' });
throw error;
}
},
loadSubscriptions: async () => {
try {
set({ loading: true });
const subscriptions = await getAllSubscriptions();
set({ subscriptions, loading: false });
} catch (error) {
set({
error: error instanceof Error ? error.message : 'Failed to load subscriptions',
loading: false
});
throw error;
}
},
loadFeedItems: async (subscriptionId?: string, limit: number = 50, offset: number = 0) => {
try {
set({ loading: true });
const feedItems = subscriptionId
? await getFeedItemsFromDb(subscriptionId, limit, offset)
: await getAllFeedItems(limit);
set({ feedItems, loading: false });
} catch (error) {
set({
error: error instanceof Error ? error.message : 'Failed to load feed items',
loading: false
});
throw error;
}
},
setLoading: (loading) =>
set({ loading }),
setError: (error) =>
set({ error }),
clearError: () =>
set({ error: null }),
}));

126
src/stores/search-store.ts Normal file
View File

@@ -0,0 +1,126 @@
import { create } from 'zustand';
import { SearchFilters, SearchSortOption, SearchHistoryItem } from '@/types/feed';
import { addToSearchHistory, getSearchHistory, clearSearchHistory, removeSearchHistoryItem } from '@/services/database';
interface SearchStoreState {
// State
query: string;
filters: SearchFilters;
sort: SearchSortOption;
page: number;
pageSize: number;
loading: boolean;
error: string | null;
searchHistory: SearchHistoryItem[];
// Actions
setQuery: (query: string) => void;
setFilters: (filters: Partial<SearchFilters>) => void;
setSort: (sort: SearchSortOption) => void;
setPage: (page: number) => void;
setLoading: (loading: boolean) => void;
setError: (error: string | null) => void;
clearSearch: () => void;
addToHistory: (query: string) => Promise<void>;
loadHistory: () => Promise<void>;
clearHistory: () => Promise<void>;
removeFromHistory: (query: string) => Promise<void>;
resetSearch: () => void;
}
export const useSearchStore = create<SearchStoreState>((set, get) => ({
// Initial state
query: '',
filters: {},
sort: 'relevance',
page: 1,
pageSize: 20,
loading: false,
error: null,
searchHistory: [],
// Actions
setQuery: (query) => set({ query, page: 1 }),
setFilters: (filters) =>
set((state) => ({
filters: { ...state.filters, ...filters },
page: 1
})),
setSort: (sort) => set({ sort, page: 1 }),
setPage: (page) => set({ page }),
setLoading: (loading) => set({ loading }),
setError: (error) => set({ error }),
clearSearch: () => {
get().addToHistory(get().query).catch(console.error);
set({
query: '',
filters: {},
sort: 'relevance',
page: 1,
error: null,
});
},
addToHistory: async (query) => {
if (!query || query.trim().length === 0) return;
try {
await addToSearchHistory(query);
await get().loadHistory();
} catch (error) {
console.error('Failed to add to search history:', error);
}
},
loadHistory: async () => {
try {
const history = await getSearchHistory(10);
set({
searchHistory: history.map(h => ({
id: `${h.query}_${h.timestamp}`,
query: h.query,
timestamp: new Date(h.timestamp),
}))
});
} catch (error) {
console.error('Failed to load search history:', error);
}
},
clearHistory: async () => {
try {
await clearSearchHistory();
set({ searchHistory: [] });
} catch (error) {
console.error('Failed to clear search history:', error);
}
},
removeFromHistory: async (query) => {
try {
await removeSearchHistoryItem(query);
set((state) => ({
searchHistory: state.searchHistory.filter(h => h.query !== query),
}));
} catch (error) {
console.error('Failed to remove from search history:', error);
}
},
resetSearch: () => {
set({
query: '',
filters: {},
sort: 'relevance',
page: 1,
loading: false,
error: null,
});
},
}));

View File

@@ -0,0 +1,102 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { AccountSettings } from '@/types/global';
import { SyncInterval, SYNC_INTERVALS, NotificationPreferences, ReadingPreferences } from '@/types/feed';
export interface SettingsState {
// Sync Settings
syncInterval: SyncInterval;
setSyncInterval: (interval: SyncInterval) => void;
// Theme Settings
theme: 'system' | 'light' | 'dark';
setTheme: (theme: 'system' | 'light' | 'dark') => void;
// Notification Preferences
notificationPreferences: NotificationPreferences;
setNotificationPreferences: (prefs: Partial<NotificationPreferences>) => void;
// Reading Preferences
readingPreferences: ReadingPreferences;
setReadingPreferences: (prefs: Partial<ReadingPreferences>) => void;
// Account Settings
accountSettings: AccountSettings;
setAccountSettings: (settings: Partial<AccountSettings>) => void;
// Actions
resetSettings: () => void;
}
export const DEFAULT_SETTINGS = {
syncInterval: SYNC_INTERVALS[2], // Default: Every 30 minutes
theme: 'system',
notificationPreferences: {
newArticles: true,
episodeReleases: true,
customAlerts: false,
badgeCount: true,
sound: true,
vibration: true,
},
readingPreferences: {
fontSize: 'medium',
lineHeight: 'normal',
showTableOfContents: false,
showReadingTime: true,
showAuthor: true,
showDate: true,
},
accountSettings: {
email: '',
notificationsEnabled: true,
privacy: 'private',
language: 'en-US',
timezone: 'UTC',
theme: 'system',
},
} as const;
export const useSettingsStore = create<SettingsState>()(
persist(
(set, get) => ({
...DEFAULT_SETTINGS,
setSyncInterval: (interval) => set({ syncInterval: interval }),
setTheme: (theme) => set({ theme }),
setNotificationPreferences: (prefs) =>
set({
notificationPreferences: {
...get().notificationPreferences,
...prefs,
},
}),
setReadingPreferences: (prefs) =>
set({
readingPreferences: {
...get().readingPreferences,
...prefs,
},
}),
setAccountSettings: (settings) =>
set({
accountSettings: {
...get().accountSettings,
...settings,
},
}),
resetSettings: () => set(DEFAULT_SETTINGS),
}),
{
name: 'rssuper-settings',
partialize: (state) => ({
syncInterval: state.syncInterval,
theme: state.theme,
notificationPreferences: state.notificationPreferences,
readingPreferences: state.readingPreferences,
accountSettings: state.accountSettings,
}),
}
)
);
export default useSettingsStore;

147
src/types/feed.ts Normal file
View File

@@ -0,0 +1,147 @@
// Feed Types
export interface FeedItem {
id: string;
title: string;
link?: string;
description?: string;
content?: string;
author?: string;
published?: Date;
updated?: Date;
categories?: string[];
enclosure?: {
url: string;
type: string;
length?: number;
};
guid?: string;
subscriptionTitle?: string;
}
export interface Feed {
id: string;
title: string;
link?: string;
description?: string;
subtitle?: string;
language?: string;
lastBuildDate?: Date;
updated?: Date;
generator?: string;
ttl?: number;
items: FeedItem[];
rawUrl: string;
lastFetchedAt?: Date;
nextFetchAt?: Date;
}
export interface FeedSubscription {
id: string;
url: string;
title: string;
category?: string;
enabled: boolean;
fetchInterval: number; // in minutes
createdAt: Date;
updatedAt: Date;
lastFetchedAt?: Date;
nextFetchAt?: Date;
error?: string;
httpAuth?: {
username: string;
password: string;
};
}
// Sync Interval Options
export type SyncInterval = {
label: string;
value: number; // in minutes
};
export const SYNC_INTERVALS: SyncInterval[] = [
{ label: 'Every 5 minutes', value: 5 },
{ label: 'Every 15 minutes', value: 15 },
{ label: 'Every 30 minutes', value: 30 },
{ label: 'Every hour', value: 60 },
{ label: 'Every 4 hours', value: 240 },
{ label: 'Every 8 hours', value: 480 },
{ label: 'Daily', value: 1440 },
{ label: 'Weekly', value: 10080 },
{ label: 'Monthly', value: 72000 },
];
// Notification Preferences
export interface NotificationPreferences {
newArticles: boolean;
episodeReleases: boolean;
customAlerts: boolean;
badgeCount: boolean;
sound: boolean;
vibration: boolean;
}
// Reading Preferences
export interface ReadingPreferences {
fontSize: 'small' | 'medium' | 'large' | 'xlarge';
lineHeight: 'normal' | 'relaxed' | 'loose';
showTableOfContents: boolean;
showReadingTime: boolean;
showAuthor: boolean;
showDate: boolean;
}
// Account Settings
export interface AccountSettings {
email: string;
notificationsEnabled: boolean;
privacy: 'public' | 'private' | 'anonymous';
language: string;
timezone: string;
theme: 'system' | 'light' | 'dark';
}
// Parse Result Types
export interface ParseResult {
success: boolean;
feed?: Feed;
error?: string;
feedType?: 'rss' | 'atom' | 'unknown';
}
// Search Types
export interface SearchQuery {
query: string;
page?: number;
pageSize?: number;
}
export interface SearchFilters {
dateFrom?: Date;
dateTo?: Date;
feedIds?: string[];
authors?: string[];
contentType?: 'article' | 'audio' | 'video';
}
export type SearchSortOption = 'relevance' | 'date_desc' | 'date_asc' | 'title_asc' | 'title_desc' | 'feed_asc' | 'feed_desc';
export interface SearchResult {
id: string;
type: 'article' | 'feed';
title: string;
snippet?: string;
link?: string;
feedTitle?: string;
published?: Date;
score?: number;
}
export interface SearchHistoryItem {
id: string;
query: string;
timestamp: Date;
}

61
src/types/global.d.ts vendored Normal file
View File

@@ -0,0 +1,61 @@
// Type declarations for untyped modules
declare module 'xml2js' {
export function parseStringPromise(
xmlString: string,
options?: any
): Promise<any>;
}
// Crypto for React Native
interface Crypto {
randomUUID(): string;
}
declare global {
var crypto: Crypto | undefined;
}
// Notification Types
export enum NotificationType {
NEW_ARTICLE = 'NEW_ARTICLE',
EPISODE_RELEASE = 'EPISODE_RELEASE',
CUSTOM_ALERT = 'CUSTOM_ALERT',
UPGRADE_PROMO = 'UPGRADE_PROMO',
}
export interface NotificationConfig {
id: string;
type: NotificationType;
title: string;
body: string;
data: Record<string, unknown>;
urgency?: 'normal' | 'high';
sound?: string;
badge?: number;
category?: string;
threadId?: string;
}
export interface PushNotificationConfig {
enabled: boolean;
notificationTypes: NotificationType[];
foreground: NotificationConfig;
critical: NotificationConfig;
alert: NotificationConfig;
badge: NotificationConfig;
sound: NotificationConfig;
vibration: NotificationConfig;
}
// Account Settings
export interface AccountSettings {
email: string;
notificationsEnabled: boolean;
privacy: 'public' | 'private' | 'anonymous';
language: string;
timezone: string;
theme: 'system' | 'light' | 'dark';
}
export {};

65
src/utils/helpers.ts Normal file
View File

@@ -0,0 +1,65 @@
// Utility functions
export function generateId(): string {
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
return crypto.randomUUID();
}
// Fallback for environments without crypto
return `id_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
}
export function formatDate(date: Date | string): string {
const d = typeof date === 'string' ? new Date(date) : date;
return d.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
export function truncateText(text: string, maxLength: number): string {
if (text.length <= maxLength) return text;
return text.substring(0, maxLength).trim() + '...';
}
export function stripHtml(html: string): string {
const tmp = document.createElement('div');
tmp.innerHTML = html;
return tmp.textContent || tmp.innerText || '';
}
export function parseFeedUrl(url: string): { host: string; path: string } | null {
try {
const parsed = new URL(url);
return {
host: parsed.hostname,
path: parsed.pathname,
};
} catch {
return null;
}
}
export function guessFeedUrl(baseUrl: string): string[] {
const commonFeedPaths = [
'/feed',
'/rss',
'/rss.xml',
'/feed.xml',
'/atom.xml',
'/atom',
'/opml',
];
try {
const url = new URL(baseUrl);
// Ensure trailing slash for path concatenation
const base = url.pathname.endsWith('/') ? url.pathname : `${url.pathname}/`;
return commonFeedPaths.map((path) => `${url.origin}${base}${path}`);
} catch {
return [];
}
}