improved layout for tablets

This commit is contained in:
tapframe 2025-08-11 14:15:05 +05:30
parent 69e5141c58
commit 63c673bfae
2 changed files with 543 additions and 247 deletions

View file

@ -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<string | null>(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 = () => {
</View>
);
// Render enough skeletons for at least two rows
const skeletonCount = numColumns * 2;
return (
<View style={styles.skeletonContainer}>
{[...Array(6)].map((_, index) => (
<View key={index} style={{ width: itemWidth, margin: 8 }}>
{Array.from({ length: skeletonCount }).map((_, index) => (
<View key={index} style={{ width: itemWidth, marginBottom: 16 }}>
{renderSkeletonItem()}
</View>
))}
@ -205,6 +221,7 @@ const LibraryScreen = () => {
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
const isDarkMode = useColorScheme() === 'dark';
const { width } = useWindowDimensions();
const { numColumns, itemWidth } = useMemo(() => getGridLayout(width), [width]);
const [loading, setLoading] = useState(true);
const [libraryItems, setLibraryItems] = useState<LibraryItem[]>([]);
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 }) => (
<TouchableOpacity
style={[styles.itemContainer, { width: itemWidth }]}
@ -717,7 +732,7 @@ const LibraryScreen = () => {
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}

View file

@ -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<SettingsCardProps> = ({ children, title }) => {
const SettingsCard: React.FC<SettingsCardProps> = ({ children, title, isTablet = false }) => {
const { currentTheme } = useTheme();
return (
<View
style={[styles.cardContainer]}
style={[
styles.cardContainer,
isTablet && styles.tabletCardContainer
]}
>
{title && (
<Text style={[
styles.cardTitle,
{ color: currentTheme.colors.mediumEmphasis }
{ color: currentTheme.colors.mediumEmphasis },
isTablet && styles.tabletCardTitle
]}>
{title}
</Text>
)}
<View style={[
styles.card,
{ backgroundColor: currentTheme.colors.elevation1 }
{ backgroundColor: currentTheme.colors.elevation1 },
isTablet && styles.tabletCard
]}>
{children}
</View>
@ -74,6 +93,7 @@ interface SettingItemProps {
isLast?: boolean;
onPress?: () => void;
badge?: string | number;
isTablet?: boolean;
}
const SettingItem: React.FC<SettingItemProps> = ({
@ -83,7 +103,8 @@ const SettingItem: React.FC<SettingItemProps> = ({
renderControl,
isLast = false,
onPress,
badge
badge,
isTablet = false
}) => {
const { currentTheme } = useTheme();
@ -94,22 +115,36 @@ const SettingItem: React.FC<SettingItemProps> = ({
style={[
styles.settingItem,
!isLast && styles.settingItemBorder,
{ borderBottomColor: currentTheme.colors.elevation2 }
{ borderBottomColor: currentTheme.colors.elevation2 },
isTablet && styles.tabletSettingItem
]}
>
<View style={[
styles.settingIconContainer,
{ backgroundColor: currentTheme.colors.elevation2 }
{ backgroundColor: currentTheme.colors.elevation2 },
isTablet && styles.tabletSettingIconContainer
]}>
<MaterialIcons name={icon} size={20} color={currentTheme.colors.primary} />
<MaterialIcons
name={icon}
size={isTablet ? 24 : 20}
color={currentTheme.colors.primary}
/>
</View>
<View style={styles.settingContent}>
<View style={styles.settingTextContainer}>
<Text style={[styles.settingTitle, { color: currentTheme.colors.highEmphasis }]}>
<Text style={[
styles.settingTitle,
{ color: currentTheme.colors.highEmphasis },
isTablet && styles.tabletSettingTitle
]}>
{title}
</Text>
{description && (
<Text style={[styles.settingDescription, { color: currentTheme.colors.mediumEmphasis }]} numberOfLines={1}>
<Text style={[
styles.settingDescription,
{ color: currentTheme.colors.mediumEmphasis },
isTablet && styles.tabletSettingDescription
]} numberOfLines={1}>
{description}
</Text>
)}
@ -129,6 +164,62 @@ const SettingItem: React.FC<SettingItemProps> = ({
);
};
// Tablet Sidebar Component
interface SidebarProps {
selectedCategory: string;
onCategorySelect: (category: string) => void;
currentTheme: any;
categories: typeof SETTINGS_CATEGORIES;
}
const Sidebar: React.FC<SidebarProps> = ({ selectedCategory, onCategorySelect, currentTheme, categories }) => {
return (
<View style={[styles.sidebar, { backgroundColor: currentTheme.colors.elevation1 }]}>
<View style={styles.sidebarHeader}>
<Text style={[styles.sidebarTitle, { color: currentTheme.colors.highEmphasis }]}>
Settings
</Text>
</View>
<ScrollView style={styles.sidebarContent} showsVerticalScrollIndicator={false}>
{categories.map((category) => (
<TouchableOpacity
key={category.id}
style={[
styles.sidebarItem,
selectedCategory === category.id && [
styles.sidebarItemActive,
{ backgroundColor: `${currentTheme.colors.primary}15` }
]
]}
onPress={() => onCategorySelect(category.id)}
>
<MaterialIcons
name={category.icon}
size={22}
color={
selectedCategory === category.id
? currentTheme.colors.primary
: currentTheme.colors.mediumEmphasis
}
/>
<Text style={[
styles.sidebarItemText,
{
color: selectedCategory === category.id
? currentTheme.colors.primary
: currentTheme.colors.mediumEmphasis
}
]}>
{category.title}
</Text>
</TouchableOpacity>
))}
</ScrollView>
</View>
);
};
const SettingsScreen: React.FC = () => {
const { settings, updateSetting } = useSettings();
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
@ -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,36 +361,23 @@ const SettingsScreen: React.FC = () => {
const ChevronRight = () => (
<MaterialIcons
name="chevron-right"
size={20}
size={isTablet ? 24 : 20}
color={currentTheme.colors.mediumEmphasis}
/>
);
const headerBaseHeight = Platform.OS === 'android' ? 80 : 60;
const topSpacing = Platform.OS === 'android' ? (StatusBar.currentHeight || 0) : insets.top;
const headerHeight = headerBaseHeight + topSpacing;
// 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 (
<View style={[
styles.container,
{ backgroundColor: currentTheme.colors.darkBackground }
]}>
<StatusBar barStyle={'light-content'} />
<View style={{ flex: 1 }}>
<View style={[styles.header, { height: headerHeight, paddingTop: topSpacing }]}>
<Text style={[styles.headerTitle, { color: currentTheme.colors.text }]}>
Settings
</Text>
</View>
<View style={styles.contentContainer}>
<ScrollView
style={styles.scrollView}
showsVerticalScrollIndicator={false}
contentContainerStyle={styles.scrollContent}
>
{/* Account Section */}
<SettingsCard title="ACCOUNT">
<SettingsCard title="ACCOUNT" isTablet={isTablet}>
{user ? (
<>
<SettingItem
@ -304,6 +385,7 @@ const SettingsScreen: React.FC = () => {
description="Manage account"
icon="account-circle"
onPress={() => navigation.navigate('AccountManage')}
isTablet={isTablet}
/>
</>
) : (
@ -312,6 +394,7 @@ const SettingsScreen: React.FC = () => {
description="Sync across devices"
icon="login"
onPress={() => navigation.navigate('Account')}
isTablet={isTablet}
/>
)}
<SettingItem
@ -320,6 +403,7 @@ const SettingsScreen: React.FC = () => {
icon="person"
renderControl={ChevronRight}
onPress={() => navigation.navigate('TraktSettings')}
isTablet={isTablet}
/>
{isAuthenticated && (
<SettingItem
@ -329,18 +413,22 @@ const SettingsScreen: React.FC = () => {
renderControl={ChevronRight}
onPress={() => navigation.navigate('ProfilesSettings')}
isLast={true}
isTablet={isTablet}
/>
)}
</SettingsCard>
);
{/* Content & Discovery */}
<SettingsCard title="CONTENT & DISCOVERY">
case 'content':
return (
<SettingsCard title="CONTENT & DISCOVERY" isTablet={isTablet}>
<SettingItem
title="Addons"
description={`${addonCount} installed`}
icon="extension"
renderControl={ChevronRight}
onPress={() => navigation.navigate('Addons')}
isTablet={isTablet}
/>
<SettingItem
title="Plugins"
@ -348,6 +436,7 @@ const SettingsScreen: React.FC = () => {
icon="code"
renderControl={ChevronRight}
onPress={() => navigation.navigate('ScraperSettings')}
isTablet={isTablet}
/>
<SettingItem
title="Catalogs"
@ -355,6 +444,7 @@ const SettingsScreen: React.FC = () => {
icon="view-list"
renderControl={ChevronRight}
onPress={() => navigation.navigate('CatalogSettings')}
isTablet={isTablet}
/>
<SettingItem
title="Home Screen"
@ -363,17 +453,21 @@ const SettingsScreen: React.FC = () => {
renderControl={ChevronRight}
onPress={() => navigation.navigate('HomeScreenSettings')}
isLast={true}
isTablet={isTablet}
/>
</SettingsCard>
);
{/* Appearance & Interface */}
<SettingsCard title="APPEARANCE">
case 'appearance':
return (
<SettingsCard title="APPEARANCE" isTablet={isTablet}>
<SettingItem
title="Theme"
description={currentTheme.name}
icon="palette"
renderControl={ChevronRight}
onPress={() => navigation.navigate('ThemeSettings')}
isTablet={isTablet}
/>
<SettingItem
title="Episode Layout"
@ -386,17 +480,21 @@ const SettingsScreen: React.FC = () => {
/>
)}
isLast={true}
isTablet={isTablet}
/>
</SettingsCard>
);
{/* Integrations */}
<SettingsCard title="INTEGRATIONS">
case 'integrations':
return (
<SettingsCard title="INTEGRATIONS" isTablet={isTablet}>
<SettingItem
title="MDBList"
description={mdblistKeySet ? "Connected" : "Enable to add ratings & reviews"}
icon="star"
renderControl={ChevronRight}
onPress={() => navigation.navigate('MDBListSettings')}
isTablet={isTablet}
/>
<SettingItem
title="TMDB"
@ -404,6 +502,7 @@ const SettingsScreen: React.FC = () => {
icon="movie"
renderControl={ChevronRight}
onPress={() => navigation.navigate('TMDBSettings')}
isTablet={isTablet}
/>
<SettingItem
title="Media Sources"
@ -412,11 +511,14 @@ const SettingsScreen: React.FC = () => {
renderControl={ChevronRight}
onPress={() => navigation.navigate('LogoSourceSettings')}
isLast={true}
isTablet={isTablet}
/>
</SettingsCard>
);
{/* Playback & Experience */}
<SettingsCard title="PLAYBACK">
case 'playback':
return (
<SettingsCard title="PLAYBACK" isTablet={isTablet}>
<SettingItem
title="Video Player"
description={Platform.OS === 'ios'
@ -426,6 +528,7 @@ const SettingsScreen: React.FC = () => {
icon="play-circle-outline"
renderControl={ChevronRight}
onPress={() => navigation.navigate('PlayerSettings')}
isTablet={isTablet}
/>
<SettingItem
title="Notifications"
@ -434,39 +537,47 @@ const SettingsScreen: React.FC = () => {
renderControl={ChevronRight}
onPress={() => navigation.navigate('NotificationSettings')}
isLast={true}
isTablet={isTablet}
/>
</SettingsCard>
);
{/* About & Support */}
<SettingsCard title="ABOUT">
case 'about':
return (
<SettingsCard title="ABOUT" isTablet={isTablet}>
<SettingItem
title="Privacy Policy"
icon="lock"
onPress={() => Linking.openURL('https://github.com/Stremio/stremio-expo/blob/main/PRIVACY_POLICY.md')}
renderControl={ChevronRight}
isTablet={isTablet}
/>
<SettingItem
title="Report Issue"
icon="bug-report"
onPress={() => Sentry.showFeedbackWidget()}
renderControl={ChevronRight}
isTablet={isTablet}
/>
<SettingItem
title="Version"
description="1.0.0"
icon="info-outline"
isLast={true}
isTablet={isTablet}
/>
</SettingsCard>
);
{/* Developer Options - Only show in development */}
{__DEV__ && (
<SettingsCard title="DEVELOPER">
case 'developer':
return __DEV__ ? (
<SettingsCard title="DEVELOPER" isTablet={isTablet}>
<SettingItem
title="Test Onboarding"
icon="play-circle-outline"
onPress={() => navigation.navigate('Onboarding')}
renderControl={ChevronRight}
isTablet={isTablet}
/>
<SettingItem
title="Reset Onboarding"
@ -480,6 +591,7 @@ const SettingsScreen: React.FC = () => {
}
}}
renderControl={ChevronRight}
isTablet={isTablet}
/>
<SettingItem
title="Clear All Data"
@ -506,21 +618,98 @@ const SettingsScreen: React.FC = () => {
);
}}
isLast={true}
isTablet={isTablet}
/>
</SettingsCard>
)}
) : null;
{/* Cache Management - Only show if MDBList is connected */}
{mdblistKeySet && (
<SettingsCard title="CACHE MANAGEMENT">
case 'cache':
return mdblistKeySet ? (
<SettingsCard title="CACHE MANAGEMENT" isTablet={isTablet}>
<SettingItem
title="Clear MDBList Cache"
icon="cached"
onPress={handleClearMDBListCache}
isLast={true}
isTablet={isTablet}
/>
</SettingsCard>
) : 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 (
<View style={[
styles.container,
{ backgroundColor: currentTheme.colors.darkBackground }
]}>
<StatusBar barStyle={'light-content'} />
<View style={styles.tabletContainer}>
<Sidebar
selectedCategory={selectedCategory}
onCategorySelect={setSelectedCategory}
currentTheme={currentTheme}
categories={visibleCategories}
/>
<View style={styles.tabletContent}>
<ScrollView
style={styles.tabletScrollView}
showsVerticalScrollIndicator={false}
contentContainerStyle={styles.tabletScrollContent}
>
{renderCategoryContent(selectedCategory)}
{selectedCategory === 'about' && (
<View style={styles.footer}>
<Text style={[styles.footerText, { color: currentTheme.colors.mediumEmphasis }]}>
Made with by the Nuvio team
</Text>
</View>
)}
</ScrollView>
</View>
</View>
</View>
);
}
// Mobile Layout (original)
return (
<View style={[
styles.container,
{ backgroundColor: currentTheme.colors.darkBackground }
]}>
<StatusBar barStyle={'light-content'} />
<View style={{ flex: 1 }}>
<View style={[styles.header, { height: headerHeight, paddingTop: topSpacing }]}>
<Text style={[styles.headerTitle, { color: currentTheme.colors.text }]}>
Settings
</Text>
</View>
<View style={styles.contentContainer}>
<ScrollView
style={styles.scrollView}
showsVerticalScrollIndicator={false}
contentContainerStyle={styles.scrollContent}
>
{renderCategoryContent('account')}
{renderCategoryContent('content')}
{renderCategoryContent('appearance')}
{renderCategoryContent('integrations')}
{renderCategoryContent('playback')}
{renderCategoryContent('about')}
{renderCategoryContent('developer')}
{renderCategoryContent('cache')}
<View style={styles.footer}>
<Text style={[styles.footerText, { color: currentTheme.colors.mediumEmphasis }]}>
@ -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',