diff --git a/App.tsx b/App.tsx index 2527397c..0ff1b550 100644 --- a/App.tsx +++ b/App.tsx @@ -9,7 +9,8 @@ import React, { useState, useEffect } from 'react'; import { View, StyleSheet, - I18nManager + I18nManager, + Platform } from 'react-native'; import { NavigationContainer } from '@react-navigation/native'; import { GestureHandlerRootView } from 'react-native-gesture-handler'; @@ -155,14 +156,16 @@ const ThemedApp = () => { {shouldShowApp && } {/* Update Popup */} - + {Platform.OS === 'ios' && ( + + )} diff --git a/src/components/AndroidUpdatePopup.tsx b/src/components/AndroidUpdatePopup.tsx new file mode 100644 index 00000000..31190371 --- /dev/null +++ b/src/components/AndroidUpdatePopup.tsx @@ -0,0 +1,431 @@ +import React, { useEffect, useRef } from 'react'; +import { + View, + Text, + StyleSheet, + TouchableOpacity, + Dimensions, + BackHandler, + Animated, +} from 'react-native'; +import { MaterialIcons } from '@expo/vector-icons'; +import { useTheme } from '../contexts/ThemeContext'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +const { width } = Dimensions.get('window'); + +interface AndroidUpdatePopupProps { + visible: boolean; + updateInfo: { + isAvailable: boolean; + manifest?: { + id?: string; + version?: string; + description?: string; + }; + }; + onUpdateNow: () => void; + onUpdateLater: () => void; + onDismiss: () => void; + isInstalling?: boolean; +} + +const AndroidUpdatePopup: React.FC = ({ + visible, + updateInfo, + onUpdateNow, + onUpdateLater, + onDismiss, + isInstalling = false, +}) => { + const { currentTheme } = useTheme(); + const insets = useSafeAreaInsets(); + const backHandlerRef = useRef(null); + const timeoutRef = useRef(null); + const fadeAnim = useRef(new Animated.Value(0)).current; + const scaleAnim = useRef(new Animated.Value(0.8)).current; + + const getReleaseNotes = () => { + const manifest: any = updateInfo?.manifest || {}; + return ( + manifest.description || + manifest.releaseNotes || + manifest.extra?.releaseNotes || + manifest.metadata?.releaseNotes || + '' + ); + }; + + // Handle Android back button + useEffect(() => { + if (visible) { + backHandlerRef.current = BackHandler.addEventListener('hardwareBackPress', () => { + if (!isInstalling) { + onDismiss(); + return true; + } + return false; + }); + } + + return () => { + if (backHandlerRef.current) { + backHandlerRef.current.remove(); + backHandlerRef.current = null; + } + }; + }, [visible, isInstalling, onDismiss]); + + // Animation effects + useEffect(() => { + if (visible) { + // Animate in + Animated.parallel([ + Animated.timing(fadeAnim, { + toValue: 1, + duration: 200, + useNativeDriver: true, + }), + Animated.spring(scaleAnim, { + toValue: 1, + tension: 100, + friction: 8, + useNativeDriver: true, + }), + ]).start(); + + // Safety timeout + timeoutRef.current = setTimeout(() => { + console.warn('AndroidUpdatePopup: Timeout reached, auto-dismissing'); + onDismiss(); + }, 30000); + } else { + // Animate out + Animated.parallel([ + Animated.timing(fadeAnim, { + toValue: 0, + duration: 150, + useNativeDriver: true, + }), + Animated.timing(scaleAnim, { + toValue: 0.8, + duration: 150, + useNativeDriver: true, + }), + ]).start(); + } + + return () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + }; + }, [visible, fadeAnim, scaleAnim, onDismiss]); + + if (!visible || !updateInfo.isAvailable) { + return null; + } + + return ( + + + + + {/* Header */} + + + + + + Update Available + + + A new version of Nuvio is ready to install + + + + {/* Update Info */} + + + + + Version: + + + {updateInfo.manifest?.id || 'Latest'} + + + + {!!getReleaseNotes() && ( + + + {getReleaseNotes()} + + + )} + + + {/* Actions */} + + + {isInstalling ? ( + <> + + Installing... + + ) : ( + <> + + Update Now + + )} + + + + + + Later + + + + + + Dismiss + + + + + + + + ); +}; + +const styles = StyleSheet.create({ + overlay: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + zIndex: 9999, + backgroundColor: 'rgba(0, 0, 0, 0.8)', + justifyContent: 'center', + alignItems: 'center', + paddingHorizontal: 20, + }, + overlayContent: { + width: '100%', + height: '100%', + justifyContent: 'center', + alignItems: 'center', + }, + backdrop: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + backgroundColor: 'transparent', + }, + popup: { + width: Math.min(width - 40, 400), + borderRadius: 20, + borderWidth: 1, + backgroundColor: '#1a1a1a', + elevation: 15, + overflow: 'hidden', + }, + header: { + alignItems: 'center', + paddingHorizontal: 24, + paddingTop: 32, + paddingBottom: 20, + }, + iconContainer: { + width: 64, + height: 64, + borderRadius: 32, + alignItems: 'center', + justifyContent: 'center', + marginBottom: 16, + }, + title: { + fontSize: 24, + fontWeight: '700', + letterSpacing: 0.3, + marginBottom: 8, + textAlign: 'center', + }, + subtitle: { + fontSize: 16, + textAlign: 'center', + lineHeight: 22, + }, + updateInfo: { + paddingHorizontal: 24, + paddingBottom: 20, + }, + infoRow: { + flexDirection: 'row', + alignItems: 'flex-start', + marginBottom: 12, + }, + infoLabel: { + fontSize: 14, + fontWeight: '500', + marginLeft: 8, + marginRight: 8, + marginTop: 2, + minWidth: 60, + }, + infoValue: { + fontSize: 14, + fontWeight: '600', + flex: 1, + lineHeight: 20, + }, + descriptionContainer: { + marginTop: 8, + padding: 12, + borderRadius: 8, + backgroundColor: 'rgba(255, 255, 255, 0.15)', + }, + description: { + fontSize: 14, + lineHeight: 20, + }, + actions: { + paddingHorizontal: 24, + paddingBottom: 20, + }, + button: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + paddingVertical: 14, + paddingHorizontal: 20, + borderRadius: 12, + gap: 8, + marginBottom: 12, + }, + primaryButton: { + elevation: 4, + }, + secondaryButton: { + borderWidth: 1, + flex: 1, + marginHorizontal: 4, + }, + disabledButton: { + opacity: 0.6, + }, + buttonText: { + color: 'white', + fontSize: 16, + fontWeight: '600', + letterSpacing: 0.3, + }, + secondaryActions: { + flexDirection: 'row', + gap: 8, + }, + secondaryButtonText: { + fontSize: 15, + fontWeight: '500', + }, +}); + +export default AndroidUpdatePopup; diff --git a/src/components/UpdatePopup.tsx b/src/components/UpdatePopup.tsx index 5033e32b..f9c1351d 100644 --- a/src/components/UpdatePopup.tsx +++ b/src/components/UpdatePopup.tsx @@ -12,6 +12,7 @@ import { MaterialIcons } from '@expo/vector-icons'; import { useTheme } from '../contexts/ThemeContext'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import * as Haptics from 'expo-haptics'; +import AndroidUpdatePopup from './AndroidUpdatePopup'; const { width, height } = Dimensions.get('window'); @@ -54,17 +55,23 @@ const UpdatePopup: React.FC = ({ }; const handleUpdateNow = () => { - Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); + if (Platform.OS === 'ios') { + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); + } onUpdateNow(); }; const handleUpdateLater = () => { - Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + if (Platform.OS === 'ios') { + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + } onUpdateLater(); }; const handleDismiss = () => { - Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + if (Platform.OS === 'ios') { + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + } onDismiss(); }; @@ -72,12 +79,19 @@ const UpdatePopup: React.FC = ({ return null; } + // Completely disable popup on Android + if (Platform.OS === 'android') { + return null; + } + + // iOS implementation with full features return ( = ({ styles.infoValue, { color: currentTheme.colors.highEmphasis } ]} - numberOfLines={1} - ellipsizeMode="middle" + numberOfLines={3} + ellipsizeMode="tail" selectable > {updateInfo.manifest?.id || 'Latest'} @@ -224,8 +238,6 @@ const UpdatePopup: React.FC = ({ - - {/* Footer removed: hardcoded message no longer shown */} @@ -245,11 +257,14 @@ const styles = StyleSheet.create({ borderRadius: 20, borderWidth: 1, backgroundColor: '#1a1a1a', // Solid background - not transparent - shadowColor: '#000', - shadowOffset: { width: 0, height: 10 }, - shadowOpacity: 0.5, - shadowRadius: 20, - elevation: 15, + ...(Platform.OS === 'ios' ? { + shadowColor: '#000', + shadowOffset: { width: 0, height: 10 }, + shadowOpacity: 0.5, + shadowRadius: 20, + } : { + elevation: 15, + }), overflow: 'hidden', }, header: { @@ -284,7 +299,7 @@ const styles = StyleSheet.create({ }, infoRow: { flexDirection: 'row', - alignItems: 'center', + alignItems: 'flex-start', marginBottom: 12, }, infoLabel: { @@ -292,11 +307,14 @@ const styles = StyleSheet.create({ fontWeight: '500', marginLeft: 8, marginRight: 8, + marginTop: 2, + minWidth: 60, }, infoValue: { fontSize: 14, fontWeight: '600', flex: 1, + lineHeight: 20, }, descriptionContainer: { marginTop: 8, @@ -323,11 +341,14 @@ const styles = StyleSheet.create({ marginBottom: 12, }, primaryButton: { - shadowColor: '#000', - shadowOffset: { width: 0, height: 4 }, - shadowOpacity: 0.2, - shadowRadius: 8, - elevation: 4, + ...(Platform.OS === 'ios' ? { + shadowColor: '#000', + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.2, + shadowRadius: 8, + } : { + elevation: 4, + }), }, secondaryButton: { borderWidth: 1, diff --git a/src/hooks/useUpdatePopup.ts b/src/hooks/useUpdatePopup.ts index ffd9c5b5..b9a5296c 100644 --- a/src/hooks/useUpdatePopup.ts +++ b/src/hooks/useUpdatePopup.ts @@ -1,5 +1,6 @@ import { useState, useEffect, useCallback } from 'react'; -import { Alert } from 'react-native'; +import { Alert, Platform } from 'react-native'; +import { toast, ToastPosition } from '@backpackapp-io/react-native-toast'; import UpdateService, { UpdateInfo } from '../services/updateService'; import AsyncStorage from '@react-native-async-storage/async-storage'; @@ -16,12 +17,14 @@ interface UseUpdatePopupReturn { const UPDATE_POPUP_STORAGE_KEY = '@update_popup_dismissed'; const UPDATE_LATER_STORAGE_KEY = '@update_later_timestamp'; const UPDATE_LAST_CHECK_TS_KEY = '@update_last_check_ts'; +const UPDATE_BADGE_KEY = '@update_badge_pending'; export const useUpdatePopup = (): UseUpdatePopupReturn => { const [showUpdatePopup, setShowUpdatePopup] = useState(false); const [updateInfo, setUpdateInfo] = useState({ isAvailable: false }); const [isInstalling, setIsInstalling] = useState(false); const [hasCheckedOnStartup, setHasCheckedOnStartup] = useState(false); + const [isAppReady, setIsAppReady] = useState(false); const checkForUpdates = useCallback(async (forceCheck = false) => { try { @@ -49,13 +52,29 @@ export const useUpdatePopup = (): UseUpdatePopupReturn => { setUpdateInfo(info); if (info.isAvailable) { - setShowUpdatePopup(true); + // Android: use badge instead of popup to avoid freezes + if (Platform.OS === 'android') { + try { + await AsyncStorage.setItem(UPDATE_BADGE_KEY, 'true'); + } catch {} + // Show actionable toast instead of popup + try { + toast('Update available — go to Settings → App Updates', { + duration: 3000, + position: ToastPosition.TOP, + }); + } catch {} + setShowUpdatePopup(false); + } else { + // iOS: show popup as usual + setShowUpdatePopup(true); + } } } catch (error) { if (__DEV__) console.error('Error checking for updates:', error); // Don't show popup on error, just log it } - }, [updateInfo.manifest?.id]); + }, [updateInfo.manifest?.id, isAppReady]); const handleUpdateNow = useCallback(async () => { try { @@ -65,27 +84,9 @@ export const useUpdatePopup = (): UseUpdatePopupReturn => { const success = await UpdateService.downloadAndInstallUpdate(); if (success) { - Alert.alert( - 'Update Installed', - 'The update has been installed successfully. Please restart the app to apply the changes.', - [ - { - text: 'Restart Later', - style: 'cancel', - }, - { - text: 'Restart Now', - onPress: () => { - // On React Native, we can't programmatically restart the app - // The user will need to manually restart - Alert.alert( - 'Restart Required', - 'Please close and reopen the app to complete the update.' - ); - }, - }, - ] - ); + // Update installed successfully - no restart alert needed + // The app will automatically reload with the new version + console.log('Update installed successfully'); } else { Alert.alert( 'Update Failed', @@ -140,7 +141,21 @@ export const useUpdatePopup = (): UseUpdatePopupReturn => { setHasCheckedOnStartup(true); if (updateInfo.isAvailable) { - setShowUpdatePopup(true); + if (Platform.OS === 'android') { + // Set badge and show a toast + (async () => { + try { await AsyncStorage.setItem(UPDATE_BADGE_KEY, 'true'); } catch {} + })(); + try { + toast('Update available — go to Settings → App Updates', { + duration: 3000, + position: ToastPosition.TOP, + }); + } catch {} + setShowUpdatePopup(false); + } else { + setShowUpdatePopup(true); + } } }; @@ -153,6 +168,35 @@ export const useUpdatePopup = (): UseUpdatePopupReturn => { }; }, []); + // Mark app as ready after a delay (Android safety) + useEffect(() => { + const timer = setTimeout(() => { + setIsAppReady(true); + }, Platform.OS === 'android' ? 3000 : 1000); + + return () => clearTimeout(timer); + }, []); + + // Show popup when app becomes ready on Android (if update is available) + useEffect(() => { + if (Platform.OS === 'android' && isAppReady && updateInfo.isAvailable && !showUpdatePopup) { + // Check if user hasn't dismissed this version + (async () => { + try { + const dismissedVersion = await AsyncStorage.getItem(UPDATE_POPUP_STORAGE_KEY); + const currentVersion = updateInfo.manifest?.id; + + if (dismissedVersion !== currentVersion) { + setShowUpdatePopup(true); + } + } catch (error) { + // If we can't check, show the popup anyway + setShowUpdatePopup(true); + } + })(); + } + }, [isAppReady, updateInfo.isAvailable, updateInfo.manifest?.id, showUpdatePopup]); + // Auto-check for updates when hook is first used (fallback if startup check fails) useEffect(() => { if (hasCheckedOnStartup) { diff --git a/src/navigation/AppNavigator.tsx b/src/navigation/AppNavigator.tsx index f7957ffc..4c47283f 100644 --- a/src/navigation/AppNavigator.tsx +++ b/src/navigation/AppNavigator.tsx @@ -3,6 +3,7 @@ import { NavigationContainer, DefaultTheme as NavigationDefaultTheme, DarkTheme import { createNativeStackNavigator, NativeStackNavigationOptions, NativeStackNavigationProp } from '@react-navigation/native-stack'; import { createBottomTabNavigator, BottomTabNavigationProp } from '@react-navigation/bottom-tabs'; import { useColorScheme, Platform, Animated, StatusBar, TouchableOpacity, View, Text, AppState, Easing, Dimensions } from 'react-native'; +import AsyncStorage from '@react-native-async-storage/async-storage'; import { PaperProvider, MD3DarkTheme, MD3LightTheme, adaptNavigationTheme } from 'react-native-paper'; import type { MD3Theme } from 'react-native-paper'; import type { BottomTabBarProps } from '@react-navigation/bottom-tabs'; @@ -440,6 +441,38 @@ const WrappedScreen: React.FC<{Screen: React.ComponentType}> = ({ Screen }) // Tab Navigator const MainTabs = () => { const { currentTheme } = useTheme(); + const [hasUpdateBadge, setHasUpdateBadge] = React.useState(false); + React.useEffect(() => { + if (Platform.OS !== 'android') return; + let mounted = true; + const load = async () => { + try { + const flag = await AsyncStorage.getItem('@update_badge_pending'); + if (mounted) setHasUpdateBadge(flag === 'true'); + } catch {} + }; + load(); + // Fast poll initially for quick badge appearance, then slow down + const fast = setInterval(load, 800); + const slowTimer = setTimeout(() => { + clearInterval(fast); + const slow = setInterval(load, 10000); + // store slow interval id on closure for cleanup + (load as any)._slow = slow; + }, 6000); + const onAppStateChange = (state: string) => { + if (state === 'active') load(); + }; + const sub = AppState.addEventListener('change', onAppStateChange); + return () => { + mounted = false; + clearInterval(fast); + // @ts-ignore + if ((load as any)._slow) clearInterval((load as any)._slow); + clearTimeout(slowTimer); + sub.remove(); + }; + }, []); const { isHomeLoading } = useLoading(); const isTablet = useMemo(() => { const { width, height } = Dimensions.get('window'); diff --git a/src/screens/SettingsScreen.tsx b/src/screens/SettingsScreen.tsx index f217a99c..a385d273 100644 --- a/src/screens/SettingsScreen.tsx +++ b/src/screens/SettingsScreen.tsx @@ -226,6 +226,19 @@ const Sidebar: React.FC = ({ selectedCategory, onCategorySelect, c const SettingsScreen: React.FC = () => { const { settings, updateSetting } = useSettings(); + const [hasUpdateBadge, setHasUpdateBadge] = useState(false); + + useEffect(() => { + if (Platform.OS !== 'android') return; + let mounted = true; + (async () => { + try { + const flag = await AsyncStorage.getItem('@update_badge_pending'); + if (mounted) setHasUpdateBadge(flag === 'true'); + } catch {} + })(); + return () => { mounted = false; }; + }, []); const navigation = useNavigation>(); const { lastUpdate } = useCatalogContext(); const { isAuthenticated, userProfile, refreshAuthStatus } = useTraktContext(); @@ -681,7 +694,14 @@ const SettingsScreen: React.FC = () => { description="Check for updates and manage app version" icon="system-update" renderControl={ChevronRight} - onPress={() => navigation.navigate('Update')} + badge={Platform.OS === 'android' && hasUpdateBadge ? 1 : undefined} + onPress={async () => { + if (Platform.OS === 'android') { + try { await AsyncStorage.removeItem('@update_badge_pending'); } catch {} + setHasUpdateBadge(false); + } + navigation.navigate('Update'); + }} isLast={true} isTablet={isTablet} /> diff --git a/src/screens/UpdateScreen.tsx b/src/screens/UpdateScreen.tsx index 8fe7d4fa..2a4fd4a3 100644 --- a/src/screens/UpdateScreen.tsx +++ b/src/screens/UpdateScreen.tsx @@ -11,6 +11,7 @@ import { Platform, Dimensions } from 'react-native'; +import { toast, ToastPosition } from '@backpackapp-io/react-native-toast'; import { useNavigation } from '@react-navigation/native'; import { NavigationProp } from '@react-navigation/native'; import { MaterialIcons } from '@expo/vector-icons'; @@ -18,6 +19,7 @@ import { RootStackParamList } from '../navigation/AppNavigator'; import { useTheme } from '../contexts/ThemeContext'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import UpdateService from '../services/updateService'; +import AsyncStorage from '@react-native-async-storage/async-storage'; const { width, height } = Dimensions.get('window'); const isTablet = width >= 768; @@ -104,6 +106,22 @@ const UpdateScreen: React.FC = () => { } }; + // Auto-check on mount and keep section visible + useEffect(() => { + if (Platform.OS === 'android') { + // ensure badge clears when entering this screen + (async () => { + try { await AsyncStorage.removeItem('@update_badge_pending'); } catch {} + })(); + } + checkForUpdates(); + if (Platform.OS === 'android') { + try { + toast('Checking for updates…', { duration: 1200, position: ToastPosition.TOP }); + } catch {} + } + }, []); + const installUpdate = async () => { try { setIsInstalling(true); diff --git a/src/services/updateService.ts b/src/services/updateService.ts index 7a9df2a9..5375dd4a 100644 --- a/src/services/updateService.ts +++ b/src/services/updateService.ts @@ -250,27 +250,31 @@ export class UpdateService { this.addLog('Updates are enabled, performing initial update check...', 'INFO'); - // Perform an initial update check on app startup - try { - const updateInfo = await this.checkForUpdates(); - this.addLog(`Initial update check completed - Updates available: ${updateInfo.isAvailable}`, 'INFO'); + // Perform an initial update check on app startup (non-blocking) + // Use setTimeout to defer the check and prevent blocking the main thread + setTimeout(async () => { + try { + this.addLog('Starting deferred update check...', 'INFO'); + const updateInfo = await this.checkForUpdates(); + this.addLog(`Initial update check completed - Updates available: ${updateInfo.isAvailable}`, 'INFO'); - if (updateInfo.isAvailable) { - this.addLog('Update available! The popup will be shown to the user.', 'INFO'); - } else { - this.addLog('No updates available at startup', 'INFO'); + if (updateInfo.isAvailable) { + this.addLog('Update available! The popup will be shown to the user.', 'INFO'); + } else { + this.addLog('No updates available at startup', 'INFO'); + } + + // Notify registered callbacks about the update check result + this.notifyUpdateCheckCallbacks(updateInfo); + } catch (checkError) { + this.addLog(`Initial update check failed: ${checkError instanceof Error ? checkError.message : String(checkError)}`, 'ERROR'); + + // Notify callbacks about the failed check + this.notifyUpdateCheckCallbacks({ isAvailable: false }); + + // Don't fail initialization if update check fails } - - // Notify registered callbacks about the update check result - this.notifyUpdateCheckCallbacks(updateInfo); - } catch (checkError) { - this.addLog(`Initial update check failed: ${checkError instanceof Error ? checkError.message : String(checkError)}`, 'ERROR'); - - // Notify callbacks about the failed check - this.notifyUpdateCheckCallbacks({ isAvailable: false }); - - // Don't fail initialization if update check fails - } + }, 1000); // Defer by 1 second to let the app fully initialize this.addLog('UpdateService initialization completed successfully', 'INFO'); } catch (error) {