This update modifies the ContentItem component by introducing a new itemContainer style for better layout management. A title Text element has been added below the image, enhancing the user interface by displaying the item's name. The overall structure has been adjusted for improved readability and maintainability.
670 lines
No EOL
22 KiB
TypeScript
670 lines
No EOL
22 KiB
TypeScript
import React, { useState, useEffect, useCallback } from 'react';
|
|
import {
|
|
View,
|
|
Text,
|
|
StyleSheet,
|
|
FlatList,
|
|
TouchableOpacity,
|
|
ActivityIndicator,
|
|
SafeAreaView,
|
|
StatusBar,
|
|
RefreshControl,
|
|
Dimensions,
|
|
Platform,
|
|
} from 'react-native';
|
|
import { RouteProp } from '@react-navigation/native';
|
|
import { StackNavigationProp } from '@react-navigation/stack';
|
|
import { RootStackParamList } from '../navigation/AppNavigator';
|
|
import { Meta, stremioService } from '../services/stremioService';
|
|
import { useTheme } from '../contexts/ThemeContext';
|
|
import { Image } from 'expo-image';
|
|
import { MaterialIcons } from '@expo/vector-icons';
|
|
import { logger } from '../utils/logger';
|
|
import { useCustomCatalogNames } from '../hooks/useCustomCatalogNames';
|
|
import { catalogService, DataSource, StreamingContent } from '../services/catalogService';
|
|
|
|
type CatalogScreenProps = {
|
|
route: RouteProp<RootStackParamList, 'Catalog'>;
|
|
navigation: StackNavigationProp<RootStackParamList, 'Catalog'>;
|
|
};
|
|
|
|
// Constants for layout
|
|
const SPACING = {
|
|
xs: 4,
|
|
sm: 8,
|
|
md: 12,
|
|
lg: 16,
|
|
xl: 24,
|
|
};
|
|
|
|
const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0;
|
|
|
|
// Screen dimensions and grid layout
|
|
const { width } = Dimensions.get('window');
|
|
|
|
// Dynamic column calculation based on screen width
|
|
const calculateCatalogLayout = (screenWidth: number) => {
|
|
const MIN_ITEM_WIDTH = 120; // Increased minimum for better readability
|
|
const MAX_ITEM_WIDTH = 160; // Adjusted maximum
|
|
const HORIZONTAL_PADDING = SPACING.lg * 2; // Total horizontal padding
|
|
const ITEM_SPACING = SPACING.sm; // Space between items
|
|
|
|
// Calculate how many columns can fit
|
|
const availableWidth = screenWidth - HORIZONTAL_PADDING;
|
|
const maxColumns = Math.floor(availableWidth / (MIN_ITEM_WIDTH + ITEM_SPACING));
|
|
|
|
// Limit to reasonable number of columns (2-4 for better UX)
|
|
const numColumns = Math.min(Math.max(maxColumns, 2), 4);
|
|
|
|
// Calculate actual item width with proper spacing
|
|
const totalSpacing = ITEM_SPACING * (numColumns - 1);
|
|
const itemWidth = (availableWidth - totalSpacing) / numColumns;
|
|
|
|
// For 2 columns, ensure we use the full available width
|
|
const finalItemWidth = numColumns === 2 ? itemWidth : Math.min(itemWidth, MAX_ITEM_WIDTH);
|
|
|
|
return {
|
|
numColumns,
|
|
itemWidth: finalItemWidth
|
|
};
|
|
};
|
|
|
|
const catalogLayout = calculateCatalogLayout(width);
|
|
const NUM_COLUMNS = catalogLayout.numColumns;
|
|
const ITEM_MARGIN = SPACING.sm;
|
|
const ITEM_WIDTH = catalogLayout.itemWidth;
|
|
|
|
// Create a styles creator function that accepts the theme colors
|
|
const createStyles = (colors: any) => StyleSheet.create({
|
|
container: {
|
|
flex: 1,
|
|
backgroundColor: colors.darkBackground,
|
|
},
|
|
header: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
paddingHorizontal: 16,
|
|
paddingTop: Platform.OS === 'android' ? ANDROID_STATUSBAR_HEIGHT + 8 : 8,
|
|
},
|
|
backButton: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
padding: 8,
|
|
},
|
|
backText: {
|
|
fontSize: 17,
|
|
fontWeight: '400',
|
|
color: colors.primary,
|
|
},
|
|
headerTitle: {
|
|
fontSize: 34,
|
|
fontWeight: '700',
|
|
color: colors.white,
|
|
paddingHorizontal: 16,
|
|
paddingBottom: 16,
|
|
paddingTop: 8,
|
|
},
|
|
list: {
|
|
padding: SPACING.lg,
|
|
paddingTop: SPACING.sm,
|
|
},
|
|
item: {
|
|
marginBottom: SPACING.lg,
|
|
borderRadius: 8,
|
|
overflow: 'hidden',
|
|
backgroundColor: colors.elevation2,
|
|
shadowColor: '#000',
|
|
shadowOffset: { width: 0, height: 2 },
|
|
shadowOpacity: 0.1,
|
|
shadowRadius: 4,
|
|
elevation: 2,
|
|
},
|
|
poster: {
|
|
width: '100%',
|
|
aspectRatio: 2/3,
|
|
borderTopLeftRadius: 8,
|
|
borderTopRightRadius: 8,
|
|
backgroundColor: colors.elevation3,
|
|
},
|
|
itemContent: {
|
|
padding: SPACING.sm,
|
|
},
|
|
title: {
|
|
fontSize: 14,
|
|
fontWeight: '600',
|
|
color: colors.white,
|
|
lineHeight: 18,
|
|
},
|
|
releaseInfo: {
|
|
fontSize: 12,
|
|
marginTop: SPACING.xs,
|
|
color: colors.mediumGray,
|
|
},
|
|
footer: {
|
|
padding: SPACING.lg,
|
|
alignItems: 'center',
|
|
},
|
|
button: {
|
|
marginTop: SPACING.md,
|
|
paddingVertical: SPACING.md,
|
|
paddingHorizontal: SPACING.xl,
|
|
backgroundColor: colors.primary,
|
|
borderRadius: 8,
|
|
elevation: 2,
|
|
},
|
|
buttonText: {
|
|
color: colors.white,
|
|
fontWeight: '600',
|
|
fontSize: 16,
|
|
},
|
|
centered: {
|
|
flex: 1,
|
|
justifyContent: 'center',
|
|
alignItems: 'center',
|
|
padding: SPACING.xl,
|
|
},
|
|
emptyText: {
|
|
color: colors.white,
|
|
fontSize: 16,
|
|
textAlign: 'center',
|
|
marginTop: SPACING.md,
|
|
marginBottom: SPACING.sm,
|
|
},
|
|
errorText: {
|
|
color: colors.white,
|
|
fontSize: 16,
|
|
textAlign: 'center',
|
|
marginTop: SPACING.md,
|
|
marginBottom: SPACING.sm,
|
|
},
|
|
loadingText: {
|
|
color: colors.white,
|
|
fontSize: 16,
|
|
marginTop: SPACING.lg,
|
|
}
|
|
});
|
|
|
|
const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
|
|
const { addonId, type, id, name: originalName, genreFilter } = route.params;
|
|
const [items, setItems] = useState<Meta[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [paginating, setPaginating] = useState(false);
|
|
const [refreshing, setRefreshing] = useState(false);
|
|
const [page, setPage] = useState(1);
|
|
const [hasMore, setHasMore] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [dataSource, setDataSource] = useState<DataSource>(DataSource.STREMIO_ADDONS);
|
|
const [actualCatalogName, setActualCatalogName] = useState<string | null>(null);
|
|
const { currentTheme } = useTheme();
|
|
const colors = currentTheme.colors;
|
|
const styles = createStyles(colors);
|
|
const isDarkMode = true;
|
|
const isInitialRender = React.useRef(true);
|
|
|
|
const { getCustomName, isLoadingCustomNames } = useCustomCatalogNames();
|
|
|
|
// Create display name with proper type suffix
|
|
const createDisplayName = (catalogName: string) => {
|
|
if (!catalogName) return '';
|
|
|
|
// Check if the name already includes content type indicators
|
|
const lowerName = catalogName.toLowerCase();
|
|
const contentType = type === 'movie' ? 'Movies' : type === 'series' ? 'TV Shows' : `${type.charAt(0).toUpperCase() + type.slice(1)}s`;
|
|
|
|
// If the name already contains type information, return as is
|
|
if (lowerName.includes('movie') || lowerName.includes('tv') || lowerName.includes('show') || lowerName.includes('series')) {
|
|
return catalogName;
|
|
}
|
|
|
|
// Otherwise append the content type
|
|
return `${catalogName} ${contentType}`;
|
|
};
|
|
|
|
// Use actual catalog name if available, otherwise fallback to custom name or original name
|
|
const displayName = actualCatalogName
|
|
? getCustomName(addonId || '', type || '', id || '', createDisplayName(actualCatalogName))
|
|
: getCustomName(addonId || '', type || '', id || '', originalName ? createDisplayName(originalName) : '') ||
|
|
(genreFilter ? `${genreFilter} ${type === 'movie' ? 'Movies' : 'TV Shows'}` :
|
|
`${type.charAt(0).toUpperCase() + type.slice(1)}s`);
|
|
|
|
// Add effect to get the actual catalog name from addon manifest
|
|
useEffect(() => {
|
|
const getActualCatalogName = async () => {
|
|
if (addonId && type && id) {
|
|
try {
|
|
const manifests = await stremioService.getInstalledAddonsAsync();
|
|
const addon = manifests.find(a => a.id === addonId);
|
|
|
|
if (addon && addon.catalogs) {
|
|
const catalog = addon.catalogs.find(c => c.type === type && c.id === id);
|
|
if (catalog && catalog.name) {
|
|
setActualCatalogName(catalog.name);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
logger.error('Failed to get actual catalog name:', error);
|
|
}
|
|
}
|
|
};
|
|
|
|
getActualCatalogName();
|
|
}, [addonId, type, id]);
|
|
|
|
// Add effect to get data source preference when component mounts
|
|
useEffect(() => {
|
|
const getDataSourcePreference = async () => {
|
|
const preference = await catalogService.getDataSourcePreference();
|
|
setDataSource(preference);
|
|
};
|
|
|
|
getDataSourcePreference();
|
|
}, []);
|
|
|
|
const loadItems = useCallback(async (pageNum: number, shouldRefresh: boolean = false) => {
|
|
try {
|
|
if (shouldRefresh) {
|
|
setRefreshing(true);
|
|
setHasMore(true); // Reset hasMore on refresh
|
|
} else if (pageNum > 1) {
|
|
setPaginating(true);
|
|
} else {
|
|
setLoading(true);
|
|
}
|
|
|
|
setError(null);
|
|
|
|
// Process the genre filter - ignore "All" and clean up the value
|
|
let effectiveGenreFilter = genreFilter;
|
|
if (effectiveGenreFilter === 'All') {
|
|
effectiveGenreFilter = undefined;
|
|
logger.log('Genre "All" detected, removing genre filter');
|
|
} else if (effectiveGenreFilter) {
|
|
// Clean up the genre filter
|
|
effectiveGenreFilter = effectiveGenreFilter.trim();
|
|
logger.log(`Using cleaned genre filter: "${effectiveGenreFilter}"`);
|
|
}
|
|
|
|
// Check if using TMDB as data source and not requesting a specific addon
|
|
if (dataSource === DataSource.TMDB && !addonId) {
|
|
logger.log('Using TMDB data source for CatalogScreen');
|
|
try {
|
|
const catalogs = await catalogService.getCatalogByType(type, effectiveGenreFilter);
|
|
if (catalogs && catalogs.length > 0) {
|
|
// Flatten all items from all catalogs
|
|
const allItems: StreamingContent[] = [];
|
|
catalogs.forEach(catalog => {
|
|
allItems.push(...catalog.items);
|
|
});
|
|
|
|
// Convert StreamingContent to Meta format
|
|
const metaItems: Meta[] = allItems.map(item => ({
|
|
id: item.id,
|
|
type: item.type,
|
|
name: item.name,
|
|
poster: item.poster,
|
|
background: item.banner,
|
|
logo: item.logo,
|
|
description: item.description,
|
|
releaseInfo: item.year?.toString() || '',
|
|
imdbRating: item.imdbRating,
|
|
year: item.year,
|
|
genres: item.genres || [],
|
|
runtime: item.runtime,
|
|
certification: item.certification,
|
|
}));
|
|
|
|
// Remove duplicates
|
|
const uniqueItems = metaItems.filter((item, index, self) =>
|
|
index === self.findIndex((t) => t.id === item.id)
|
|
);
|
|
|
|
setItems(uniqueItems);
|
|
setHasMore(false); // TMDB already returns a full set
|
|
setLoading(false);
|
|
setRefreshing(false);
|
|
return;
|
|
} else {
|
|
setError("No content found for the selected filters");
|
|
setItems([]);
|
|
setLoading(false);
|
|
setRefreshing(false);
|
|
return;
|
|
}
|
|
} catch (error) {
|
|
logger.error('Failed to get TMDB catalog:', error);
|
|
setError('Failed to load content from TMDB');
|
|
setItems([]);
|
|
setLoading(false);
|
|
setRefreshing(false);
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Use this flag to track if we found and processed any items
|
|
let foundItems = false;
|
|
let allItems: Meta[] = [];
|
|
|
|
// Get all installed addon manifests directly
|
|
const manifests = await stremioService.getInstalledAddonsAsync();
|
|
|
|
if (addonId) {
|
|
// If addon ID is provided, find the specific addon
|
|
const addon = manifests.find(a => a.id === addonId);
|
|
|
|
if (!addon) {
|
|
throw new Error(`Addon ${addonId} not found`);
|
|
}
|
|
|
|
// Create filters array for genre filtering if provided
|
|
const filters = effectiveGenreFilter ? [{ title: 'genre', value: effectiveGenreFilter }] : [];
|
|
|
|
// Load items from the catalog
|
|
const newItems = await stremioService.getCatalog(addon, type, id, pageNum, filters);
|
|
|
|
if (newItems.length === 0) {
|
|
setHasMore(false);
|
|
} else {
|
|
foundItems = true;
|
|
setHasMore(true); // Ensure hasMore is true if we found items
|
|
}
|
|
|
|
if (shouldRefresh || pageNum === 1) {
|
|
setItems(newItems);
|
|
} else {
|
|
setItems(prev => [...prev, ...newItems]);
|
|
}
|
|
} else if (effectiveGenreFilter) {
|
|
// Get all addons that have catalogs of the specified type
|
|
const typeManifests = manifests.filter(manifest =>
|
|
manifest.catalogs && manifest.catalogs.some(catalog => catalog.type === type)
|
|
);
|
|
|
|
// Add debug logging for genre filter
|
|
logger.log(`Using genre filter: "${effectiveGenreFilter}" for type: ${type}`);
|
|
|
|
// For each addon, try to get content with the genre filter
|
|
for (const manifest of typeManifests) {
|
|
try {
|
|
// Find catalogs of this type
|
|
const typeCatalogs = manifest.catalogs?.filter(catalog => catalog.type === type) || [];
|
|
|
|
// For each catalog, try to get content
|
|
for (const catalog of typeCatalogs) {
|
|
try {
|
|
const filters = [{ title: 'genre', value: effectiveGenreFilter }];
|
|
|
|
// Debug logging for each catalog request
|
|
logger.log(`Requesting from ${manifest.name}, catalog ${catalog.id} with genre "${effectiveGenreFilter}"`);
|
|
|
|
const catalogItems = await stremioService.getCatalog(manifest, type, catalog.id, pageNum, filters);
|
|
|
|
if (catalogItems && catalogItems.length > 0) {
|
|
// Log first few items' genres to debug
|
|
const sampleItems = catalogItems.slice(0, 3);
|
|
sampleItems.forEach(item => {
|
|
logger.log(`Item "${item.name}" has genres: ${JSON.stringify(item.genres)}`);
|
|
});
|
|
|
|
// Filter items client-side to ensure they contain the requested genre
|
|
// Some addons might not properly filter by genre on the server
|
|
let filteredItems = catalogItems;
|
|
if (effectiveGenreFilter) {
|
|
const normalizedGenreFilter = effectiveGenreFilter.toLowerCase().trim();
|
|
|
|
filteredItems = catalogItems.filter(item => {
|
|
// Skip items without genres
|
|
if (!item.genres || !Array.isArray(item.genres)) {
|
|
return false;
|
|
}
|
|
|
|
// Check for genre match (exact or substring)
|
|
return item.genres.some(genre => {
|
|
const normalizedGenre = genre.toLowerCase().trim();
|
|
return normalizedGenre === normalizedGenreFilter ||
|
|
normalizedGenre.includes(normalizedGenreFilter) ||
|
|
normalizedGenreFilter.includes(normalizedGenre);
|
|
});
|
|
});
|
|
|
|
logger.log(`Filtered ${catalogItems.length} items to ${filteredItems.length} matching genre "${effectiveGenreFilter}"`);
|
|
}
|
|
|
|
allItems = [...allItems, ...filteredItems];
|
|
foundItems = filteredItems.length > 0;
|
|
}
|
|
} catch (error) {
|
|
logger.log(`Failed to load items from ${manifest.name} catalog ${catalog.id}:`, error);
|
|
// Continue with other catalogs
|
|
}
|
|
}
|
|
} catch (error) {
|
|
logger.log(`Failed to process addon ${manifest.name}:`, error);
|
|
// Continue with other addons
|
|
}
|
|
}
|
|
|
|
// Remove duplicates by ID
|
|
const uniqueItems = allItems.filter((item, index, self) =>
|
|
index === self.findIndex((t) => t.id === item.id)
|
|
);
|
|
|
|
if (uniqueItems.length === 0 && allItems.length === 0) {
|
|
setHasMore(false);
|
|
} else {
|
|
foundItems = true;
|
|
setHasMore(true); // Ensure hasMore is true if we found items
|
|
}
|
|
|
|
if (shouldRefresh || pageNum === 1) {
|
|
setItems(uniqueItems);
|
|
} else {
|
|
// Add new items while avoiding duplicates
|
|
setItems(prev => {
|
|
const prevIds = new Set(prev.map(item => item.id));
|
|
const newItems = uniqueItems.filter(item => !prevIds.has(item.id));
|
|
return [...prev, ...newItems];
|
|
});
|
|
}
|
|
}
|
|
|
|
if (!foundItems) {
|
|
setError("No content found for the selected filters");
|
|
}
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Failed to load catalog items');
|
|
logger.error('Failed to load catalog:', err);
|
|
} finally {
|
|
setLoading(false);
|
|
setRefreshing(false);
|
|
setPaginating(false);
|
|
}
|
|
}, [addonId, type, id, genreFilter, dataSource]);
|
|
|
|
useEffect(() => {
|
|
loadItems(1, true);
|
|
}, [loadItems]);
|
|
|
|
const handleRefresh = useCallback(() => {
|
|
setPage(1);
|
|
setItems([]); // Clear items on refresh
|
|
loadItems(1, true);
|
|
}, [loadItems]);
|
|
|
|
const handleLoadMore = useCallback(() => {
|
|
if (isInitialRender.current) {
|
|
isInitialRender.current = false;
|
|
return;
|
|
}
|
|
if (!loading && !paginating && hasMore && !refreshing) {
|
|
const nextPage = page + 1;
|
|
setPage(nextPage);
|
|
loadItems(nextPage);
|
|
}
|
|
}, [loading, paginating, hasMore, page, loadItems, refreshing]);
|
|
|
|
const renderItem = useCallback(({ item, index }: { item: Meta; index: number }) => {
|
|
// Calculate if this is the last item in a row
|
|
const isLastInRow = (index + 1) % NUM_COLUMNS === 0;
|
|
// For 2-column layout, ensure proper spacing
|
|
const rightMargin = isLastInRow ? 0 : SPACING.sm;
|
|
|
|
return (
|
|
<TouchableOpacity
|
|
style={[
|
|
styles.item,
|
|
{
|
|
marginRight: rightMargin,
|
|
// For 2 columns, ensure items fill the available space properly
|
|
width: NUM_COLUMNS === 2 ? ITEM_WIDTH : ITEM_WIDTH
|
|
}
|
|
]}
|
|
onPress={() => navigation.navigate('Metadata', { id: item.id, type: item.type, addonId })}
|
|
activeOpacity={0.7}
|
|
>
|
|
<Image
|
|
source={{ uri: item.poster || 'https://via.placeholder.com/300x450/333333/666666?text=No+Image' }}
|
|
style={styles.poster}
|
|
contentFit="cover"
|
|
transition={200}
|
|
/>
|
|
<View style={styles.itemContent}>
|
|
<Text
|
|
style={styles.title}
|
|
numberOfLines={2}
|
|
>
|
|
{item.name}
|
|
</Text>
|
|
{item.releaseInfo && (
|
|
<Text style={styles.releaseInfo}>
|
|
{item.releaseInfo}
|
|
</Text>
|
|
)}
|
|
</View>
|
|
</TouchableOpacity>
|
|
);
|
|
}, [navigation, styles, NUM_COLUMNS, ITEM_WIDTH]);
|
|
|
|
const renderEmptyState = () => (
|
|
<View style={styles.centered}>
|
|
<MaterialIcons name="search-off" size={56} color={colors.mediumGray} />
|
|
<Text style={styles.emptyText}>
|
|
No content found
|
|
</Text>
|
|
<TouchableOpacity
|
|
style={styles.button}
|
|
onPress={handleRefresh}
|
|
>
|
|
<Text style={styles.buttonText}>Try Again</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
);
|
|
|
|
const renderErrorState = () => (
|
|
<View style={styles.centered}>
|
|
<MaterialIcons name="error-outline" size={56} color={colors.mediumGray} />
|
|
<Text style={styles.errorText}>
|
|
{error}
|
|
</Text>
|
|
<TouchableOpacity
|
|
style={styles.button}
|
|
onPress={() => loadItems(1)}
|
|
>
|
|
<Text style={styles.buttonText}>Retry</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
);
|
|
|
|
const renderLoadingState = () => (
|
|
<View style={styles.centered}>
|
|
<ActivityIndicator size="large" color={colors.primary} />
|
|
<Text style={styles.loadingText}>Loading content...</Text>
|
|
</View>
|
|
);
|
|
|
|
const isScreenLoading = loading || isLoadingCustomNames;
|
|
|
|
if (isScreenLoading && items.length === 0) {
|
|
return (
|
|
<SafeAreaView style={styles.container}>
|
|
<StatusBar barStyle="light-content" />
|
|
<View style={styles.header}>
|
|
<TouchableOpacity
|
|
style={styles.backButton}
|
|
onPress={() => navigation.goBack()}
|
|
>
|
|
<MaterialIcons name="chevron-left" size={28} color={colors.white} />
|
|
<Text style={styles.backText}>Back</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
<Text style={styles.headerTitle}>{displayName || originalName || `${type.charAt(0).toUpperCase() + type.slice(1)}s`}</Text>
|
|
{renderLoadingState()}
|
|
</SafeAreaView>
|
|
);
|
|
}
|
|
|
|
if (error && items.length === 0) {
|
|
return (
|
|
<SafeAreaView style={styles.container}>
|
|
<StatusBar barStyle="light-content" />
|
|
<View style={styles.header}>
|
|
<TouchableOpacity
|
|
style={styles.backButton}
|
|
onPress={() => navigation.goBack()}
|
|
>
|
|
<MaterialIcons name="chevron-left" size={28} color={colors.white} />
|
|
<Text style={styles.backText}>Back</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
<Text style={styles.headerTitle}>{displayName || `${type.charAt(0).toUpperCase() + type.slice(1)}s`}</Text>
|
|
{renderErrorState()}
|
|
</SafeAreaView>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<SafeAreaView style={styles.container}>
|
|
<StatusBar barStyle="light-content" />
|
|
<View style={styles.header}>
|
|
<TouchableOpacity
|
|
style={styles.backButton}
|
|
onPress={() => navigation.goBack()}
|
|
>
|
|
<MaterialIcons name="chevron-left" size={28} color={colors.white} />
|
|
<Text style={styles.backText}>Back</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
<Text style={styles.headerTitle}>{displayName || `${type.charAt(0).toUpperCase() + type.slice(1)}s`}</Text>
|
|
|
|
{items.length > 0 ? (
|
|
<FlatList
|
|
data={items}
|
|
renderItem={renderItem}
|
|
keyExtractor={(item) => `${item.id}-${item.type}`}
|
|
numColumns={NUM_COLUMNS}
|
|
key={NUM_COLUMNS}
|
|
refreshControl={
|
|
<RefreshControl
|
|
refreshing={refreshing}
|
|
onRefresh={handleRefresh}
|
|
colors={[colors.primary]}
|
|
tintColor={colors.primary}
|
|
/>
|
|
}
|
|
onEndReached={handleLoadMore}
|
|
onEndReachedThreshold={0.5}
|
|
ListFooterComponent={
|
|
paginating ? (
|
|
<View style={styles.footer}>
|
|
<ActivityIndicator size="small" color={colors.primary} />
|
|
</View>
|
|
) : null
|
|
}
|
|
contentContainerStyle={styles.list}
|
|
showsVerticalScrollIndicator={false}
|
|
/>
|
|
) : renderEmptyState()}
|
|
</SafeAreaView>
|
|
);
|
|
};
|
|
|
|
export default CatalogScreen;
|