From 63c673bfae06ad7d8ff69619636cda94d5a139e6 Mon Sep 17 00:00:00 2001 From: tapframe Date: Mon, 11 Aug 2025 14:15:05 +0530 Subject: [PATCH] improved layout for tablets --- src/screens/LibraryScreen.tsx | 31 +- src/screens/SettingsScreen.tsx | 759 ++++++++++++++++++++++----------- 2 files changed, 543 insertions(+), 247 deletions(-) diff --git a/src/screens/LibraryScreen.tsx b/src/screens/LibraryScreen.tsx index 48c3044..5fa4297 100644 --- a/src/screens/LibraryScreen.tsx +++ b/src/screens/LibraryScreen.tsx @@ -66,6 +66,20 @@ interface TraktFolder { const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0; +// Compute responsive grid layout (more columns on tablets) +function getGridLayout(screenWidth: number): { numColumns: number; itemWidth: number } { + const horizontalPadding = 24; // matches listContainer padding (approx) + const gutter = 16; // space between items (via space-between + marginBottom) + let numColumns = 2; + if (screenWidth >= 1200) numColumns = 5; + else if (screenWidth >= 1000) numColumns = 4; + else if (screenWidth >= 700) numColumns = 3; + else numColumns = 2; + const available = screenWidth - horizontalPadding - (numColumns - 1) * gutter; + const itemWidth = Math.floor(available / numColumns); + return { numColumns, itemWidth }; +} + const TraktItem = React.memo(({ item, width, navigation, currentTheme }: { item: TraktDisplayItem; width: number; navigation: any; currentTheme: any }) => { const [posterUrl, setPosterUrl] = useState(null); @@ -146,7 +160,7 @@ const TraktItem = React.memo(({ item, width, navigation, currentTheme }: { item: const SkeletonLoader = () => { const pulseAnim = React.useRef(new RNAnimated.Value(0)).current; const { width } = useWindowDimensions(); - const itemWidth = (width - 48) / 2; + const { numColumns, itemWidth } = getGridLayout(width); const { currentTheme } = useTheme(); React.useEffect(() => { @@ -190,10 +204,12 @@ const SkeletonLoader = () => { ); + // Render enough skeletons for at least two rows + const skeletonCount = numColumns * 2; return ( - {[...Array(6)].map((_, index) => ( - + {Array.from({ length: skeletonCount }).map((_, index) => ( + {renderSkeletonItem()} ))} @@ -205,6 +221,7 @@ const LibraryScreen = () => { const navigation = useNavigation>(); const isDarkMode = useColorScheme() === 'dark'; const { width } = useWindowDimensions(); + const { numColumns, itemWidth } = useMemo(() => getGridLayout(width), [width]); const [loading, setLoading] = useState(true); const [libraryItems, setLibraryItems] = useState([]); const [filter, setFilter] = useState<'all' | 'movies' | 'series'>('all'); @@ -329,8 +346,6 @@ const LibraryScreen = () => { return folders.filter(folder => folder.itemCount > 0); }, [traktAuthenticated, watchedMovies, watchedShows, watchlistMovies, watchlistShows, collectionMovies, collectionShows, continueWatching, ratedContent]); - const itemWidth = (width - 48) / 2; // 2 items per row with padding - const renderItem = ({ item }: { item: LibraryItem }) => ( { data={traktFolders} renderItem={({ item }) => renderTraktCollectionFolder({ folder: item })} keyExtractor={item => item.id} - numColumns={2} + numColumns={numColumns} contentContainerStyle={styles.listContainer} showsVerticalScrollIndicator={false} columnWrapperStyle={styles.columnWrapper} @@ -760,7 +775,7 @@ const LibraryScreen = () => { data={folderItems} renderItem={({ item }) => renderTraktItem({ item })} keyExtractor={(item) => `${item.type}-${item.id}`} - numColumns={2} + numColumns={numColumns} columnWrapperStyle={styles.row} style={styles.traktContainer} contentContainerStyle={{ paddingBottom: insets.bottom + 80 }} @@ -856,7 +871,7 @@ const LibraryScreen = () => { return renderItem({ item: item as LibraryItem }); }} keyExtractor={item => item.id} - numColumns={2} + numColumns={numColumns} contentContainerStyle={styles.listContainer} showsVerticalScrollIndicator={false} columnWrapperStyle={styles.columnWrapper} diff --git a/src/screens/SettingsScreen.tsx b/src/screens/SettingsScreen.tsx index ab68a5c..a84d942 100644 --- a/src/screens/SettingsScreen.tsx +++ b/src/screens/SettingsScreen.tsx @@ -31,34 +31,53 @@ import { catalogService } from '../services/catalogService'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import * as Sentry from '@sentry/react-native'; -const { width } = Dimensions.get('window'); +const { width, height } = Dimensions.get('window'); +const isTablet = width >= 768; const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0; +// Settings categories for tablet sidebar +const SETTINGS_CATEGORIES = [ + { id: 'account', title: 'Account', icon: 'account-circle' }, + { id: 'content', title: 'Content & Discovery', icon: 'explore' }, + { id: 'appearance', title: 'Appearance', icon: 'palette' }, + { id: 'integrations', title: 'Integrations', icon: 'extension' }, + { id: 'playback', title: 'Playback', icon: 'play-circle-outline' }, + { id: 'about', title: 'About', icon: 'info-outline' }, + { id: 'developer', title: 'Developer', icon: 'code' }, + { id: 'cache', title: 'Cache', icon: 'cached' }, +]; + // Card component with minimalistic style interface SettingsCardProps { children: React.ReactNode; title?: string; + isTablet?: boolean; } -const SettingsCard: React.FC = ({ children, title }) => { +const SettingsCard: React.FC = ({ children, title, isTablet = false }) => { const { currentTheme } = useTheme(); return ( {title && ( {title} )} {children} @@ -74,6 +93,7 @@ interface SettingItemProps { isLast?: boolean; onPress?: () => void; badge?: string | number; + isTablet?: boolean; } const SettingItem: React.FC = ({ @@ -83,7 +103,8 @@ const SettingItem: React.FC = ({ renderControl, isLast = false, onPress, - badge + badge, + isTablet = false }) => { const { currentTheme } = useTheme(); @@ -94,22 +115,36 @@ const SettingItem: React.FC = ({ style={[ styles.settingItem, !isLast && styles.settingItemBorder, - { borderBottomColor: currentTheme.colors.elevation2 } + { borderBottomColor: currentTheme.colors.elevation2 }, + isTablet && styles.tabletSettingItem ]} > - + - + {title} {description && ( - + {description} )} @@ -129,6 +164,62 @@ const SettingItem: React.FC = ({ ); }; +// Tablet Sidebar Component +interface SidebarProps { + selectedCategory: string; + onCategorySelect: (category: string) => void; + currentTheme: any; + categories: typeof SETTINGS_CATEGORIES; +} + +const Sidebar: React.FC = ({ selectedCategory, onCategorySelect, currentTheme, categories }) => { + return ( + + + + Settings + + + + + {categories.map((category) => ( + onCategorySelect(category.id)} + > + + + {category.title} + + + ))} + + + ); +}; + const SettingsScreen: React.FC = () => { const { settings, updateSetting } = useSettings(); const navigation = useNavigation>(); @@ -138,6 +229,9 @@ const SettingsScreen: React.FC = () => { const insets = useSafeAreaInsets(); const { user, signOut } = useAccount(); + // Tablet-specific state + const [selectedCategory, setSelectedCategory] = useState('account'); + // Add a useEffect to check authentication status on focus useEffect(() => { // This will reload the Trakt auth status whenever the settings screen is focused @@ -267,15 +361,328 @@ const SettingsScreen: React.FC = () => { const ChevronRight = () => ( ); + // Filter categories based on conditions + const visibleCategories = SETTINGS_CATEGORIES.filter(category => { + if (category.id === 'developer' && !__DEV__) return false; + if (category.id === 'cache' && !mdblistKeySet) return false; + return true; + }); + + const renderCategoryContent = (categoryId: string) => { + switch (categoryId) { + case 'account': + return ( + + {user ? ( + <> + navigation.navigate('AccountManage')} + isTablet={isTablet} + /> + + ) : ( + navigation.navigate('Account')} + isTablet={isTablet} + /> + )} + navigation.navigate('TraktSettings')} + isTablet={isTablet} + /> + {isAuthenticated && ( + navigation.navigate('ProfilesSettings')} + isLast={true} + isTablet={isTablet} + /> + )} + + ); + + case 'content': + return ( + + navigation.navigate('Addons')} + isTablet={isTablet} + /> + navigation.navigate('ScraperSettings')} + isTablet={isTablet} + /> + navigation.navigate('CatalogSettings')} + isTablet={isTablet} + /> + navigation.navigate('HomeScreenSettings')} + isLast={true} + isTablet={isTablet} + /> + + ); + + case 'appearance': + return ( + + navigation.navigate('ThemeSettings')} + isTablet={isTablet} + /> + ( + updateSetting('episodeLayoutStyle', value ? 'horizontal' : 'vertical')} + /> + )} + isLast={true} + isTablet={isTablet} + /> + + ); + + case 'integrations': + return ( + + navigation.navigate('MDBListSettings')} + isTablet={isTablet} + /> + navigation.navigate('TMDBSettings')} + isTablet={isTablet} + /> + navigation.navigate('LogoSourceSettings')} + isLast={true} + isTablet={isTablet} + /> + + ); + + case 'playback': + return ( + + navigation.navigate('PlayerSettings')} + isTablet={isTablet} + /> + navigation.navigate('NotificationSettings')} + isLast={true} + isTablet={isTablet} + /> + + ); + + case 'about': + return ( + + Linking.openURL('https://github.com/Stremio/stremio-expo/blob/main/PRIVACY_POLICY.md')} + renderControl={ChevronRight} + isTablet={isTablet} + /> + Sentry.showFeedbackWidget()} + renderControl={ChevronRight} + isTablet={isTablet} + /> + + + ); + + case 'developer': + return __DEV__ ? ( + + navigation.navigate('Onboarding')} + renderControl={ChevronRight} + isTablet={isTablet} + /> + { + try { + await AsyncStorage.removeItem('hasCompletedOnboarding'); + Alert.alert('Success', 'Onboarding has been reset. Restart the app to see the onboarding flow.'); + } catch (error) { + Alert.alert('Error', 'Failed to reset onboarding.'); + } + }} + renderControl={ChevronRight} + isTablet={isTablet} + /> + { + Alert.alert( + 'Clear All Data', + 'This will reset all settings and clear all cached data. Are you sure?', + [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Clear', + style: 'destructive', + onPress: async () => { + try { + await AsyncStorage.clear(); + Alert.alert('Success', 'All data cleared. Please restart the app.'); + } catch (error) { + Alert.alert('Error', 'Failed to clear data.'); + } + } + } + ] + ); + }} + isLast={true} + isTablet={isTablet} + /> + + ) : null; + + case 'cache': + return mdblistKeySet ? ( + + + + ) : null; + + default: + return null; + } + }; + const headerBaseHeight = Platform.OS === 'android' ? 80 : 60; const topSpacing = Platform.OS === 'android' ? (StatusBar.currentHeight || 0) : insets.top; const headerHeight = headerBaseHeight + topSpacing; + if (isTablet) { + return ( + + + + + + + + {renderCategoryContent(selectedCategory)} + + {selectedCategory === 'about' && ( + + + Made with ❤️ by the Nuvio team + + + )} + + + + + ); + } + + // Mobile Layout (original) return ( { showsVerticalScrollIndicator={false} contentContainerStyle={styles.scrollContent} > - {/* Account Section */} - - {user ? ( - <> - navigation.navigate('AccountManage')} - /> - - ) : ( - navigation.navigate('Account')} - /> - )} - navigation.navigate('TraktSettings')} - /> - {isAuthenticated && ( - navigation.navigate('ProfilesSettings')} - isLast={true} - /> - )} - - - {/* Content & Discovery */} - - navigation.navigate('Addons')} - /> - navigation.navigate('ScraperSettings')} - /> - navigation.navigate('CatalogSettings')} - /> - navigation.navigate('HomeScreenSettings')} - isLast={true} - /> - - - {/* Appearance & Interface */} - - navigation.navigate('ThemeSettings')} - /> - ( - updateSetting('episodeLayoutStyle', value ? 'horizontal' : 'vertical')} - /> - )} - isLast={true} - /> - - - {/* Integrations */} - - navigation.navigate('MDBListSettings')} - /> - navigation.navigate('TMDBSettings')} - /> - navigation.navigate('LogoSourceSettings')} - isLast={true} - /> - - - {/* Playback & Experience */} - - navigation.navigate('PlayerSettings')} - /> - navigation.navigate('NotificationSettings')} - isLast={true} - /> - - - {/* About & Support */} - - Linking.openURL('https://github.com/Stremio/stremio-expo/blob/main/PRIVACY_POLICY.md')} - renderControl={ChevronRight} - /> - Sentry.showFeedbackWidget()} - renderControl={ChevronRight} - /> - - - - {/* Developer Options - Only show in development */} - {__DEV__ && ( - - navigation.navigate('Onboarding')} - renderControl={ChevronRight} - /> - { - try { - await AsyncStorage.removeItem('hasCompletedOnboarding'); - Alert.alert('Success', 'Onboarding has been reset. Restart the app to see the onboarding flow.'); - } catch (error) { - Alert.alert('Error', 'Failed to reset onboarding.'); - } - }} - renderControl={ChevronRight} - /> - { - Alert.alert( - 'Clear All Data', - 'This will reset all settings and clear all cached data. Are you sure?', - [ - { text: 'Cancel', style: 'cancel' }, - { - text: 'Clear', - style: 'destructive', - onPress: async () => { - try { - await AsyncStorage.clear(); - Alert.alert('Success', 'All data cleared. Please restart the app.'); - } catch (error) { - Alert.alert('Error', 'Failed to clear data.'); - } - } - } - ] - ); - }} - isLast={true} - /> - - )} - - {/* Cache Management - Only show if MDBList is connected */} - {mdblistKeySet && ( - - - - )} + {renderCategoryContent('account')} + {renderCategoryContent('content')} + {renderCategoryContent('appearance')} + {renderCategoryContent('integrations')} + {renderCategoryContent('playback')} + {renderCategoryContent('about')} + {renderCategoryContent('developer')} + {renderCategoryContent('cache')} @@ -538,6 +727,7 @@ const styles = StyleSheet.create({ container: { flex: 1, }, + // Mobile styles header: { paddingHorizontal: Math.max(1, width * 0.05), flexDirection: 'row', @@ -566,10 +756,69 @@ const styles = StyleSheet.create({ width: '100%', paddingBottom: 90, }, + + // Tablet-specific styles + tabletContainer: { + flex: 1, + flexDirection: 'row', + }, + sidebar: { + width: 280, + borderRightWidth: 1, + borderRightColor: 'rgba(255,255,255,0.1)', + }, + sidebarHeader: { + padding: 24, + paddingTop: Platform.OS === 'android' ? (StatusBar.currentHeight || 0) + 24 : 48, + borderBottomWidth: 1, + borderBottomColor: 'rgba(255,255,255,0.1)', + }, + sidebarTitle: { + fontSize: 28, + fontWeight: '800', + letterSpacing: 0.3, + }, + sidebarContent: { + flex: 1, + paddingTop: 16, + }, + sidebarItem: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 24, + paddingVertical: 16, + marginHorizontal: 12, + marginVertical: 2, + borderRadius: 12, + }, + sidebarItemActive: { + borderRadius: 12, + }, + sidebarItemText: { + fontSize: 16, + fontWeight: '500', + marginLeft: 16, + }, + tabletContent: { + flex: 1, + paddingTop: Platform.OS === 'android' ? (StatusBar.currentHeight || 0) + 24 : 48, + }, + tabletScrollView: { + flex: 1, + paddingHorizontal: 32, + }, + tabletScrollContent: { + paddingBottom: 32, + }, + + // Common card styles cardContainer: { width: '100%', marginBottom: 20, }, + tabletCardContainer: { + marginBottom: 32, + }, cardTitle: { fontSize: 13, fontWeight: '600', @@ -577,6 +826,11 @@ const styles = StyleSheet.create({ marginLeft: Math.max(12, width * 0.04), marginBottom: 8, }, + tabletCardTitle: { + fontSize: 14, + marginLeft: 0, + marginBottom: 12, + }, card: { marginHorizontal: Math.max(12, width * 0.04), borderRadius: 16, @@ -586,7 +840,14 @@ const styles = StyleSheet.create({ shadowOpacity: 0.1, shadowRadius: 4, elevation: 3, - width: undefined, // Let it fill the container width + width: undefined, + }, + tabletCard: { + marginHorizontal: 0, + borderRadius: 20, + shadowOpacity: 0.15, + shadowRadius: 8, + elevation: 5, }, settingItem: { flexDirection: 'row', @@ -597,6 +858,11 @@ const styles = StyleSheet.create({ minHeight: Math.max(54, width * 0.14), width: '100%', }, + tabletSettingItem: { + paddingVertical: 16, + paddingHorizontal: 24, + minHeight: 70, + }, settingItemBorder: { // Border styling handled directly in the component with borderBottomWidth }, @@ -608,6 +874,12 @@ const styles = StyleSheet.create({ alignItems: 'center', justifyContent: 'center', }, + tabletSettingIconContainer: { + width: 44, + height: 44, + borderRadius: 12, + marginRight: 20, + }, settingContent: { flex: 1, flexDirection: 'row', @@ -621,10 +893,19 @@ const styles = StyleSheet.create({ fontWeight: '500', marginBottom: 3, }, + tabletSettingTitle: { + fontSize: 18, + fontWeight: '600', + marginBottom: 4, + }, settingDescription: { fontSize: Math.min(14, width * 0.037), opacity: 0.8, }, + tabletSettingDescription: { + fontSize: 16, + opacity: 0.7, + }, settingControl: { justifyContent: 'center', alignItems: 'center',