From c18f984eacd2b7ab04eaad1c916c3fedb643177c Mon Sep 17 00:00:00 2001 From: tapframe Date: Mon, 15 Sep 2025 20:38:37 +0530 Subject: [PATCH] ui changes --- android/app/build.gradle | 4 +- src/components/calendar/CalendarSection.tsx | 42 ++-- src/components/metadata/MetadataDetails.tsx | 101 ++++++-- src/components/player/AndroidVideoPlayer.tsx | 29 ++- src/components/player/VideoPlayer.tsx | 29 ++- src/navigation/AppNavigator.tsx | 19 +- src/screens/CalendarScreen.tsx | 38 ++- src/screens/LibraryScreen.tsx | 242 +++++++++---------- src/screens/OnboardingScreen.tsx | 236 +++++++++++++----- src/screens/SettingsScreen.tsx | 2 +- 10 files changed, 504 insertions(+), 238 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index 676847c..8904494 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -93,8 +93,8 @@ android { applicationId 'com.nuvio.app' minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 11 - versionName "0.6.0-beta.11" + versionCode 1 + versionName "1.0.0" } splits { diff --git a/src/components/calendar/CalendarSection.tsx b/src/components/calendar/CalendarSection.tsx index 337598d..9f1dc04 100644 --- a/src/components/calendar/CalendarSection.tsx +++ b/src/components/calendar/CalendarSection.tsx @@ -7,6 +7,7 @@ import { TouchableOpacity, Dimensions } from 'react-native'; +import { InteractionManager } from 'react-native'; import { MaterialIcons } from '@expo/vector-icons'; import { format, addMonths, subMonths, startOfMonth, endOfMonth, eachDayOfInterval, isSameMonth, isToday, isSameDay } from 'date-fns'; import Animated, { FadeIn } from 'react-native-reanimated'; @@ -79,26 +80,35 @@ export const CalendarSection: React.FC = ({ const [currentDate, setCurrentDate] = useState(new Date()); const [selectedDate, setSelectedDate] = useState(null); const scrollViewRef = useRef(null); + const [uiReady, setUiReady] = useState(false); // Map of dates with episodes const [datesWithEpisodes, setDatesWithEpisodes] = useState<{ [key: string]: boolean }>({}); - // Process episodes to identify dates with content + // Defer initial heavy work until after interactions useEffect(() => { - if (__DEV__) console.log(`[CalendarSection] Processing ${episodes.length} episodes for calendar dots`); + const task = InteractionManager.runAfterInteractions(() => setUiReady(true)); + return () => task.cancel(); + }, []); + + // Process episodes to identify dates with content (bounded and deferred) + useEffect(() => { + if (!uiReady) return; + const MAX_TO_PROCESS = 3000; // cap to prevent massive loops const dateMap: { [key: string]: boolean } = {}; - - episodes.forEach(episode => { - if (episode.releaseDate) { + const len = Math.min(episodes.length, MAX_TO_PROCESS); + for (let i = 0; i < len; i++) { + const episode = episodes[i]; + if (episode && episode.releaseDate) { const releaseDate = new Date(episode.releaseDate); - const dateKey = format(releaseDate, 'yyyy-MM-dd'); - dateMap[dateKey] = true; + if (!isNaN(releaseDate.getTime())) { + const dateKey = format(releaseDate, 'yyyy-MM-dd'); + dateMap[dateKey] = true; + } } - }); - - if (__DEV__) console.log(`[CalendarSection] Found ${Object.keys(dateMap).length} unique dates with episodes`); + } setDatesWithEpisodes(dateMap); - }, [episodes]); + }, [episodes, uiReady]); const goToPreviousMonth = useCallback(() => { setCurrentDate(prev => subMonths(prev, 1)); @@ -213,9 +223,13 @@ export const CalendarSection: React.FC = ({ ))} - - {renderDays()} - + {uiReady ? ( + + {renderDays()} + + ) : ( + + )} ); }; diff --git a/src/components/metadata/MetadataDetails.tsx b/src/components/metadata/MetadataDetails.tsx index f87016b..a314875 100644 --- a/src/components/metadata/MetadataDetails.tsx +++ b/src/components/metadata/MetadataDetails.tsx @@ -10,6 +10,12 @@ import { MaterialIcons } from '@expo/vector-icons'; import { Image } from 'expo-image'; import Animated, { FadeIn, + useAnimatedStyle, + useSharedValue, + withTiming, + runOnJS, + interpolate, + Extrapolate, } from 'react-native-reanimated'; import { useTheme } from '../../contexts/ThemeContext'; import { isMDBListEnabled } from '../../screens/MDBListSettingsScreen'; @@ -36,6 +42,11 @@ const MetadataDetails: React.FC = ({ const { currentTheme } = useTheme(); const [isFullDescriptionOpen, setIsFullDescriptionOpen] = useState(false); const [isMDBEnabled, setIsMDBEnabled] = useState(false); + const [isTextTruncated, setIsTextTruncated] = useState(false); + + // Animation values for smooth height transition + const animatedHeight = useSharedValue(0); + const [measuredHeights, setMeasuredHeights] = useState({ collapsed: 0, expanded: 0 }); useEffect(() => { const checkMDBListEnabled = async () => { @@ -50,6 +61,42 @@ const MetadataDetails: React.FC = ({ checkMDBListEnabled(); }, []); + const handleTextLayout = (event: any) => { + const { lines } = event.nativeEvent; + // If we have 3 or more lines, it means the text was truncated + setIsTextTruncated(lines.length >= 3); + }; + + const handleCollapsedTextLayout = (event: any) => { + const { height } = event.nativeEvent.layout; + setMeasuredHeights(prev => ({ ...prev, collapsed: height })); + }; + + const handleExpandedTextLayout = (event: any) => { + const { height } = event.nativeEvent.layout; + setMeasuredHeights(prev => ({ ...prev, expanded: height })); + }; + + // Animate height changes + const toggleDescription = () => { + const targetHeight = isFullDescriptionOpen ? measuredHeights.collapsed : measuredHeights.expanded; + animatedHeight.value = withTiming(targetHeight, { duration: 300 }); + setIsFullDescriptionOpen(!isFullDescriptionOpen); + }; + + // Initialize height when component mounts or text changes + useEffect(() => { + if (measuredHeights.collapsed > 0) { + animatedHeight.value = measuredHeights.collapsed; + } + }, [measuredHeights.collapsed]); + + // Animated style for smooth height transition + const animatedDescriptionStyle = useAnimatedStyle(() => ({ + height: animatedHeight.value, + overflow: 'hidden', + })); + return ( <> {/* Metadata Source Selector removed */} @@ -115,23 +162,47 @@ const MetadataDetails: React.FC = ({ style={[styles.descriptionContainer, loadingMetadata && styles.dimmed]} entering={FadeIn.duration(300)} > - setIsFullDescriptionOpen(!isFullDescriptionOpen)} - activeOpacity={0.7} + {/* Hidden text elements to measure heights */} + - - {metadata.description} - - - - {isFullDescriptionOpen ? 'Show Less' : 'Show More'} + {metadata.description} + + + {metadata.description} + + + + + + {metadata.description} - - + + {(isTextTruncated || isFullDescriptionOpen) && ( + + + {isFullDescriptionOpen ? 'Show Less' : 'Show More'} + + + + )} )} diff --git a/src/components/player/AndroidVideoPlayer.tsx b/src/components/player/AndroidVideoPlayer.tsx index 03d6e51..0882b3d 100644 --- a/src/components/player/AndroidVideoPlayer.tsx +++ b/src/components/player/AndroidVideoPlayer.tsx @@ -1,8 +1,8 @@ import React, { useState, useRef, useEffect, useMemo, useCallback } from 'react'; -import { View, TouchableOpacity, TouchableWithoutFeedback, Dimensions, Animated, ActivityIndicator, Platform, NativeModules, StatusBar, Text, Image, StyleSheet, Modal } from 'react-native'; +import { View, TouchableOpacity, TouchableWithoutFeedback, Dimensions, Animated, ActivityIndicator, Platform, NativeModules, StatusBar, Text, Image, StyleSheet, Modal, AppState } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import Video, { VideoRef, SelectedTrack, SelectedTrackType, BufferingStrategyType } from 'react-native-video'; -import { useNavigation, useRoute, RouteProp } from '@react-navigation/native'; +import { useNavigation, useRoute, RouteProp, useFocusEffect } from '@react-navigation/native'; import { RootStackParamList } from '../../navigation/AppNavigator'; import { PinchGestureHandler, PanGestureHandler, TapGestureHandler, State, PinchGestureHandlerGestureEvent, PanGestureHandlerGestureEvent, TapGestureHandlerGestureEvent } from 'react-native-gesture-handler'; import RNImmersiveMode from 'react-native-immersive-mode'; @@ -588,6 +588,8 @@ const AndroidVideoPlayer: React.FC = () => { useEffect(() => { const subscription = Dimensions.addEventListener('change', ({ screen }) => { setScreenDimensions(screen); + // Re-apply immersive mode on layout changes to keep system bars hidden + enableImmersiveMode(); }); const initializePlayer = async () => { StatusBar.setHidden(true, 'none'); @@ -620,6 +622,27 @@ const AndroidVideoPlayer: React.FC = () => { }; }, []); + // Re-apply immersive mode when screen gains focus + useFocusEffect( + useCallback(() => { + enableImmersiveMode(); + return () => {}; + }, []) + ); + + // Re-apply immersive mode when app returns to foreground + useEffect(() => { + const onAppStateChange = (state: string) => { + if (state === 'active') { + enableImmersiveMode(); + } + }; + const sub = AppState.addEventListener('change', onAppStateChange); + return () => { + sub.remove(); + }; + }, []); + const startOpeningAnimation = () => { // Logo entrance animation - optimized for faster appearance Animated.parallel([ @@ -1206,6 +1229,8 @@ const AndroidVideoPlayer: React.FC = () => { if (newShowControls) { controlsTimeout.current = setTimeout(hideControls, 5000); } + // Reinforce immersive mode after any UI toggle + enableImmersiveMode(); return newShowControls; }); }; diff --git a/src/components/player/VideoPlayer.tsx b/src/components/player/VideoPlayer.tsx index b2d0037..985f19c 100644 --- a/src/components/player/VideoPlayer.tsx +++ b/src/components/player/VideoPlayer.tsx @@ -1,8 +1,8 @@ import React, { useState, useRef, useEffect, useMemo, useCallback } from 'react'; -import { View, TouchableOpacity, Dimensions, Animated, ActivityIndicator, Platform, NativeModules, StatusBar, Text, Image, StyleSheet, Modal } from 'react-native'; +import { View, TouchableOpacity, Dimensions, Animated, ActivityIndicator, Platform, NativeModules, StatusBar, Text, Image, StyleSheet, Modal, AppState } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { VLCPlayer } from 'react-native-vlc-media-player'; -import { useNavigation, useRoute, RouteProp } from '@react-navigation/native'; +import { useNavigation, useRoute, RouteProp, useFocusEffect } from '@react-navigation/native'; import { RootStackParamList, RootStackNavigationProp } from '../../navigation/AppNavigator'; import { PinchGestureHandler, PanGestureHandler, TapGestureHandler, State, PinchGestureHandlerGestureEvent, PanGestureHandlerGestureEvent, TapGestureHandlerGestureEvent } from 'react-native-gesture-handler'; import RNImmersiveMode from 'react-native-immersive-mode'; @@ -567,6 +567,8 @@ const VideoPlayer: React.FC = () => { useEffect(() => { const subscription = Dimensions.addEventListener('change', ({ screen }) => { setScreenDimensions(screen); + // Re-apply immersive mode on layout changes (Android) + enableImmersiveMode(); }); const initializePlayer = async () => { StatusBar.setHidden(true, 'none'); @@ -599,6 +601,27 @@ const VideoPlayer: React.FC = () => { }; }, []); + // Re-apply immersive mode when screen gains focus (Android) + useFocusEffect( + useCallback(() => { + enableImmersiveMode(); + return () => {}; + }, []) + ); + + // Re-apply immersive mode when app returns to foreground (Android) + useEffect(() => { + const onAppStateChange = (state: string) => { + if (state === 'active') { + enableImmersiveMode(); + } + }; + const sub = AppState.addEventListener('change', onAppStateChange); + return () => { + sub.remove(); + }; + }, []); + const startOpeningAnimation = () => { // Logo entrance animation - optimized for faster appearance Animated.parallel([ @@ -1166,6 +1189,8 @@ const VideoPlayer: React.FC = () => { if (newShowControls) { controlsTimeout.current = setTimeout(hideControls, 5000); } + // Reinforce immersive mode after any UI toggle (Android) + enableImmersiveMode(); return newShowControls; }); }; diff --git a/src/navigation/AppNavigator.tsx b/src/navigation/AppNavigator.tsx index 821dd44..4918b6f 100644 --- a/src/navigation/AppNavigator.tsx +++ b/src/navigation/AppNavigator.tsx @@ -333,7 +333,8 @@ export const CustomNavigationDarkTheme: Theme = { type IconNameType = 'home' | 'home-outline' | 'compass' | 'compass-outline' | 'play-box-multiple' | 'play-box-multiple-outline' | 'puzzle' | 'puzzle-outline' | - 'cog' | 'cog-outline' | 'feature-search' | 'feature-search-outline'; + 'cog' | 'cog-outline' | 'feature-search' | 'feature-search-outline' | + 'magnify' | 'heart' | 'heart-outline'; // Add TabIcon component const TabIcon = React.memo(({ focused, color, iconName }: { @@ -352,7 +353,11 @@ const TabIcon = React.memo(({ focused, color, iconName }: { }).start(); }, [focused]); - const finalIconName = focused ? iconName : `${iconName}-outline` as IconNameType; + // Use outline variant when available, but keep single-form icons (like 'magnify') the same + const finalIconName = (() => { + if (iconName === 'magnify') return 'magnify'; + return focused ? iconName : `${iconName}-outline` as IconNameType; + })(); return ( { iconName = 'home'; break; case 'Library': - iconName = 'play-box-multiple'; + iconName = 'heart'; break; case 'Search': - iconName = 'feature-search'; + iconName = 'magnify'; break; case 'Settings': iconName = 'cog'; @@ -755,7 +760,7 @@ const MainTabs = () => { options={{ tabBarLabel: 'Library', tabBarIcon: ({ color, size, focused }) => ( - + ), }} /> @@ -764,8 +769,8 @@ const MainTabs = () => { component={SearchScreen} options={{ tabBarLabel: 'Search', - tabBarIcon: ({ color, size, focused }) => ( - + tabBarIcon: ({ color, size }) => ( + ), }} /> diff --git a/src/screens/CalendarScreen.tsx b/src/screens/CalendarScreen.tsx index 684f059..a3d01ac 100644 --- a/src/screens/CalendarScreen.tsx +++ b/src/screens/CalendarScreen.tsx @@ -13,6 +13,7 @@ import { SectionList, Platform } from 'react-native'; +import { InteractionManager } from 'react-native'; import { useNavigation } from '@react-navigation/native'; import { NavigationProp } from '@react-navigation/native'; import { Image } from 'expo-image'; @@ -69,6 +70,7 @@ const CalendarScreen = () => { logger.log(`[Calendar] Initial load - Library has ${libraryItems?.length || 0} items, loading: ${libraryLoading}`); const [refreshing, setRefreshing] = useState(false); + const [uiReady, setUiReady] = useState(false); const [selectedDate, setSelectedDate] = useState(null); const [filteredEpisodes, setFilteredEpisodes] = useState([]); @@ -79,6 +81,14 @@ const CalendarScreen = () => { refresh(true); setRefreshing(false); }, [refresh]); + + // Defer heavy UI work until after interactions to reduce jank/crashes + useEffect(() => { + const task = InteractionManager.runAfterInteractions(() => { + setUiReady(true); + }); + return () => task.cancel(); + }, []); const handleSeriesPress = useCallback((seriesId: string, episode?: CalendarEpisode) => { navigation.navigate('Metadata', { @@ -211,12 +221,15 @@ const CalendarScreen = () => { // Process all episodes once data is loaded - using memory-efficient approach const allEpisodes = React.useMemo(() => { - const episodes = calendarData.reduce((acc: CalendarEpisode[], section: CalendarSection) => - [...acc, ...section.data], [] as CalendarEpisode[]); - - // Limit episodes to prevent memory issues in large libraries - return memoryManager.limitArraySize(episodes, 1000); - }, [calendarData]); + if (!uiReady) return [] as CalendarEpisode[]; + const episodes = calendarData.reduce((acc: CalendarEpisode[], section: CalendarSection) => { + // Pre-trim section arrays defensively + const trimmed = memoryManager.limitArraySize(section.data, 500); + return acc.length > 1500 ? acc : [...acc, ...trimmed]; + }, [] as CalendarEpisode[]); + // Global cap to keep memory bounded + return memoryManager.limitArraySize(episodes, 1500); + }, [calendarData, uiReady]); // Log when rendering with relevant state info logger.log(`[Calendar] Rendering: loading=${loading}, calendarData sections=${calendarData.length}, allEpisodes=${allEpisodes.length}`); @@ -244,7 +257,7 @@ const CalendarScreen = () => { setFilteredEpisodes([]); }, []); - if (loading && !refreshing) { + if ((loading || !uiReady) && !refreshing) { return ( @@ -293,6 +306,11 @@ const CalendarScreen = () => { keyExtractor={(item) => item.id} renderItem={renderEpisodeItem} contentContainerStyle={styles.listContent} + initialNumToRender={8} + maxToRenderPerBatch={8} + updateCellsBatchingPeriod={50} + windowSize={7} + removeClippedSubviews refreshControl={ { renderItem={renderEpisodeItem} renderSectionHeader={renderSectionHeader} contentContainerStyle={styles.listContent} - removeClippedSubviews={false} + removeClippedSubviews + initialNumToRender={8} + maxToRenderPerBatch={8} + updateCellsBatchingPeriod={50} + windowSize={7} refreshControl={ - - {posterUrl ? ( - - ) : ( - - + + + {posterUrl ? ( + + ) : ( + + + + )} + + + + + {item.type === 'movie' ? 'Movie' : 'Series'} + - )} - - - {item.name} - - {item.lastWatched && ( - - Last watched: {item.lastWatched} - - )} - {item.plays && item.plays > 1 && ( - - {item.plays} plays - - )} - - - - - - {item.type === 'movie' ? 'Movie' : 'Series'} - + + {item.name} + ); @@ -255,14 +240,35 @@ const LibraryScreen = () => { StatusBar.setBackgroundColor('transparent'); } }; - + applyStatusBarConfig(); - + // Re-apply on focus const unsubscribe = navigation.addListener('focus', applyStatusBarConfig); return unsubscribe; }, [navigation]); + // Handle hardware back button and gesture navigation + useEffect(() => { + const backAction = () => { + if (showTraktContent) { + if (selectedTraktFolder) { + // If in a specific folder, go back to folder list + setSelectedTraktFolder(null); + } else { + // If in Trakt collections view, go back to main library + setShowTraktContent(false); + } + return true; // Prevent default back behavior + } + return false; // Allow default back behavior (navigate back) + }; + + const backHandler = BackHandler.addEventListener('hardwareBackPress', backAction); + + return () => backHandler.remove(); + }, [showTraktContent, selectedTraktFolder]); + useEffect(() => { const loadLibrary = async () => { setLoading(true); @@ -306,7 +312,7 @@ const LibraryScreen = () => { icon: 'visibility', description: 'Your watched content', itemCount: (watchedMovies?.length || 0) + (watchedShows?.length || 0), - gradient: ['#4CAF50', '#2E7D32'] + gradient: ['#2C3E50', '#34495E'] }, { id: 'continue-watching', @@ -314,7 +320,7 @@ const LibraryScreen = () => { icon: 'play-circle-outline', description: 'Resume your progress', itemCount: continueWatching?.length || 0, - gradient: ['#FF9800', '#F57C00'] + gradient: ['#2980B9', '#3498DB'] }, { id: 'watchlist', @@ -322,7 +328,7 @@ const LibraryScreen = () => { icon: 'bookmark', description: 'Want to watch', itemCount: (watchlistMovies?.length || 0) + (watchlistShows?.length || 0), - gradient: ['#2196F3', '#1976D2'] + gradient: ['#6C3483', '#9B59B6'] }, { id: 'collection', @@ -330,7 +336,7 @@ const LibraryScreen = () => { icon: 'library-add', description: 'Your collection', itemCount: (collectionMovies?.length || 0) + (collectionShows?.length || 0), - gradient: ['#9C27B0', '#7B1FA2'] + gradient: ['#1B2631', '#283747'] }, { id: 'ratings', @@ -338,7 +344,7 @@ const LibraryScreen = () => { icon: 'star', description: 'Your ratings', itemCount: ratedContent?.length || 0, - gradient: ['#FF5722', '#D84315'] + gradient: ['#5D6D7E', '#85929E'] } ]; @@ -352,51 +358,40 @@ const LibraryScreen = () => { onPress={() => navigation.navigate('Metadata', { id: item.id, type: item.type })} activeOpacity={0.7} > - - - - - {item.name} - - {item.lastWatched && ( - - {item.lastWatched} - + + + + + {item.progress !== undefined && item.progress < 1 && ( + + + )} - - - {item.progress !== undefined && item.progress < 1 && ( - - - - )} - {item.type === 'series' && ( - - - Series - - )} + {item.type === 'series' && ( + + + Series + + )} + + + {item.name} + ); @@ -450,34 +445,31 @@ const LibraryScreen = () => { }} activeOpacity={0.7} > - - - - - Trakt Collection - - {traktAuthenticated && traktFolders.length > 0 && ( - - {traktFolders.length} items + + + + + + Trakt - )} - {!traktAuthenticated && ( - - Tap to connect - - )} - - - {/* Trakt badge */} - - - - Trakt - + {traktAuthenticated && traktFolders.length > 0 && ( + + {traktFolders.length} items + + )} + {!traktAuthenticated && ( + + Tap to connect + + )} + + + Trakt collections + ); @@ -921,7 +913,7 @@ const LibraryScreen = () => { <> Library navigation.navigate('Calendar')} activeOpacity={0.7} > @@ -1096,6 +1088,13 @@ const styles = StyleSheet.create({ textShadowRadius: 2, letterSpacing: 0.3, }, + cardTitle: { + fontSize: 14, + fontWeight: '600', + marginTop: 8, + textAlign: 'center', + paddingHorizontal: 4, + }, lastWatched: { fontSize: 12, color: 'rgba(255,255,255,0.7)', @@ -1246,13 +1245,8 @@ const styles = StyleSheet.create({ calendarButton: { width: 44, height: 44, - borderRadius: 22, justifyContent: 'center', alignItems: 'center', - elevation: 3, - shadowOffset: { width: 0, height: 2 }, - shadowOpacity: 0.2, - shadowRadius: 4, }, }); diff --git a/src/screens/OnboardingScreen.tsx b/src/screens/OnboardingScreen.tsx index b47e2dd..967eaea 100644 --- a/src/screens/OnboardingScreen.tsx +++ b/src/screens/OnboardingScreen.tsx @@ -10,7 +10,6 @@ import { StatusBar, Platform, } from 'react-native'; -import { SafeAreaView } from 'react-native-safe-area-context'; import { MaterialIcons } from '@expo/vector-icons'; import { LinearGradient } from 'expo-linear-gradient'; import Animated, { @@ -20,6 +19,10 @@ import Animated, { withTiming, FadeInDown, FadeInUp, + useAnimatedScrollHandler, + runOnJS, + interpolateColor, + interpolate, } from 'react-native-reanimated'; import { useTheme } from '../contexts/ThemeContext'; import { NavigationProp, useNavigation } from '@react-navigation/native'; @@ -71,6 +74,14 @@ const onboardingData: OnboardingSlide[] = [ icon: 'library-books', gradient: ['#43e97b', '#38f9d7'], }, + { + id: '5', + title: 'Plugins', + subtitle: 'Stream Sources Only', + description: 'Plugins add streaming sources to Nuvio.', + icon: 'widgets', + gradient: ['#ff9a9e', '#fad0c4'], + }, ]; const OnboardingScreen = () => { @@ -80,6 +91,30 @@ const OnboardingScreen = () => { const [currentIndex, setCurrentIndex] = useState(0); const flatListRef = useRef(null); const progressValue = useSharedValue(0); + const scrollX = useSharedValue(0); + const currentSlide = onboardingData[currentIndex]; + + const onScroll = useAnimatedScrollHandler({ + onScroll: (event) => { + scrollX.value = event.contentOffset.x; + }, + }); + + const getAnimatedBackgroundStyle = (slideIndex: number) => { + return useAnimatedStyle(() => { + const inputRange = [(slideIndex - 1) * width, slideIndex * width, (slideIndex + 1) * width]; + const opacity = interpolate( + scrollX.value, + inputRange, + [0, 1, 0], + 'clamp' + ); + + return { + opacity, + }; + }); + }; const animatedProgressStyle = useAnimatedStyle(() => ({ width: withSpring(`${((currentIndex + 1) / onboardingData.length) * 100}%`), @@ -149,13 +184,13 @@ const OnboardingScreen = () => { entering={FadeInUp.delay(500).duration(800)} style={styles.textContainer} > - + {item.title} - + {item.subtitle} - + {item.description} @@ -183,71 +218,106 @@ const OnboardingScreen = () => { ); return ( - - - - {/* Header */} - - - - Skip - - - - {/* Progress Bar */} - - + {/* Layered animated gradient backgrounds */} + {onboardingData.map((slide, index) => ( + + - - - - {/* Content */} - item.id} - onMomentumScrollEnd={(event) => { - const slideIndex = Math.round(event.nativeEvent.contentOffset.x / width); - setCurrentIndex(slideIndex); - }} - style={{ flex: 1 }} + + ))} + + {/* Decorative gradient blobs that change with current slide */} + + + - {/* Footer */} - - {renderPagination()} - - - - - {currentIndex === onboardingData.length - 1 ? 'Get Started' : 'Next'} + {/* Content container with status bar padding */} + + {/* Header */} + + + + Skip - + + {/* Progress Bar */} + + + + + + {/* Content */} + item.id} + onScroll={onScroll} + scrollEventThrottle={16} + onMomentumScrollEnd={(event) => { + const slideIndex = Math.round(event.nativeEvent.contentOffset.x / width); + setCurrentIndex(slideIndex); + }} + style={{ flex: 1 }} + /> + + {/* Footer */} + + {renderPagination()} + + + + + {currentIndex === onboardingData.length - 1 ? 'Get Started' : 'Next'} + + + + - + ); }; @@ -260,7 +330,7 @@ const styles = StyleSheet.create({ justifyContent: 'space-between', alignItems: 'center', paddingHorizontal: 20, - paddingTop: Platform.OS === 'ios' ? 10 : 20, + paddingTop: 10, paddingBottom: 20, }, skipButton: { @@ -288,6 +358,46 @@ const styles = StyleSheet.create({ justifyContent: 'center', paddingHorizontal: 40, }, + backgroundPanel: { + position: 'absolute', + top: 0, + bottom: 0, + left: 0, + right: 0, + borderRadius: 0, + }, + overlayPanel: { + position: 'absolute', + top: 0, + bottom: 0, + left: 0, + right: 0, + borderRadius: 0, + }, + fullScreenContainer: { + flex: 1, + paddingTop: Platform.OS === 'ios' ? 44 : StatusBar.currentHeight || 24, + }, + blobTopRight: { + position: 'absolute', + top: -60, + right: -60, + width: 220, + height: 220, + borderRadius: 110, + opacity: 0.35, + transform: [{ rotate: '15deg' }], + }, + blobBottomLeft: { + position: 'absolute', + bottom: -70, + left: -70, + width: 260, + height: 260, + borderRadius: 130, + opacity: 0.28, + transform: [{ rotate: '-20deg' }], + }, iconContainer: { width: 160, height: 160, diff --git a/src/screens/SettingsScreen.tsx b/src/screens/SettingsScreen.tsx index f412c1e..35a8714 100644 --- a/src/screens/SettingsScreen.tsx +++ b/src/screens/SettingsScreen.tsx @@ -606,7 +606,7 @@ const SettingsScreen: React.FC = () => { />