mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-04-19 07:42:09 +00:00
android freeze fix
This commit is contained in:
parent
0fcc4edde1
commit
877a4c5dc6
8 changed files with 647 additions and 73 deletions
21
App.tsx
21
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 && <AppNavigator initialRouteName={initialRouteName} />}
|
||||
|
||||
{/* Update Popup */}
|
||||
<UpdatePopup
|
||||
visible={showUpdatePopup}
|
||||
updateInfo={updateInfo}
|
||||
onUpdateNow={handleUpdateNow}
|
||||
onUpdateLater={handleUpdateLater}
|
||||
onDismiss={handleDismiss}
|
||||
isInstalling={isInstalling}
|
||||
/>
|
||||
{Platform.OS === 'ios' && (
|
||||
<UpdatePopup
|
||||
visible={showUpdatePopup}
|
||||
updateInfo={updateInfo}
|
||||
onUpdateNow={handleUpdateNow}
|
||||
onUpdateLater={handleUpdateLater}
|
||||
onDismiss={handleDismiss}
|
||||
isInstalling={isInstalling}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</NavigationContainer>
|
||||
</PaperProvider>
|
||||
|
|
|
|||
431
src/components/AndroidUpdatePopup.tsx
Normal file
431
src/components/AndroidUpdatePopup.tsx
Normal file
|
|
@ -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<AndroidUpdatePopupProps> = ({
|
||||
visible,
|
||||
updateInfo,
|
||||
onUpdateNow,
|
||||
onUpdateLater,
|
||||
onDismiss,
|
||||
isInstalling = false,
|
||||
}) => {
|
||||
const { currentTheme } = useTheme();
|
||||
const insets = useSafeAreaInsets();
|
||||
const backHandlerRef = useRef<any>(null);
|
||||
const timeoutRef = useRef<NodeJS.Timeout | null>(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 (
|
||||
<View style={styles.overlay}>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.overlayContent,
|
||||
{
|
||||
opacity: fadeAnim,
|
||||
transform: [{ scale: scaleAnim }],
|
||||
}
|
||||
]}
|
||||
>
|
||||
<TouchableOpacity
|
||||
style={styles.backdrop}
|
||||
activeOpacity={1}
|
||||
onPress={onDismiss}
|
||||
/>
|
||||
<View style={[
|
||||
styles.popup,
|
||||
{
|
||||
backgroundColor: currentTheme.colors.darkBackground || '#1a1a1a',
|
||||
borderColor: currentTheme.colors.elevation2 || '#333333',
|
||||
marginTop: insets.top + 20,
|
||||
marginBottom: insets.bottom + 20,
|
||||
}
|
||||
]}>
|
||||
{/* Header */}
|
||||
<View style={styles.header}>
|
||||
<View style={[
|
||||
styles.iconContainer,
|
||||
{ backgroundColor: `${currentTheme.colors.primary}20` }
|
||||
]}>
|
||||
<MaterialIcons
|
||||
name="system-update"
|
||||
size={32}
|
||||
color={currentTheme.colors.primary}
|
||||
/>
|
||||
</View>
|
||||
<Text style={[
|
||||
styles.title,
|
||||
{ color: currentTheme.colors.highEmphasis }
|
||||
]}>
|
||||
Update Available
|
||||
</Text>
|
||||
<Text style={[
|
||||
styles.subtitle,
|
||||
{ color: currentTheme.colors.mediumEmphasis }
|
||||
]}>
|
||||
A new version of Nuvio is ready to install
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Update Info */}
|
||||
<View style={styles.updateInfo}>
|
||||
<View style={styles.infoRow}>
|
||||
<MaterialIcons
|
||||
name="info-outline"
|
||||
size={16}
|
||||
color={currentTheme.colors.primary}
|
||||
/>
|
||||
<Text style={[
|
||||
styles.infoLabel,
|
||||
{ color: currentTheme.colors.mediumEmphasis }
|
||||
]}>
|
||||
Version:
|
||||
</Text>
|
||||
<Text
|
||||
style={[
|
||||
styles.infoValue,
|
||||
{ color: currentTheme.colors.highEmphasis }
|
||||
]}
|
||||
numberOfLines={3}
|
||||
ellipsizeMode="tail"
|
||||
selectable
|
||||
>
|
||||
{updateInfo.manifest?.id || 'Latest'}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{!!getReleaseNotes() && (
|
||||
<View style={styles.descriptionContainer}>
|
||||
<Text style={[
|
||||
styles.description,
|
||||
{ color: currentTheme.colors.mediumEmphasis }
|
||||
]}>
|
||||
{getReleaseNotes()}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Actions */}
|
||||
<View style={styles.actions}>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.button,
|
||||
styles.primaryButton,
|
||||
{ backgroundColor: currentTheme.colors.primary },
|
||||
isInstalling && styles.disabledButton
|
||||
]}
|
||||
onPress={onUpdateNow}
|
||||
disabled={isInstalling}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
{isInstalling ? (
|
||||
<>
|
||||
<MaterialIcons name="install-mobile" size={18} color="white" />
|
||||
<Text style={styles.buttonText}>Installing...</Text>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<MaterialIcons name="download" size={18} color="white" />
|
||||
<Text style={styles.buttonText}>Update Now</Text>
|
||||
</>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
<View style={styles.secondaryActions}>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.button,
|
||||
styles.secondaryButton,
|
||||
{
|
||||
backgroundColor: currentTheme.colors.darkBackground || '#2a2a2a',
|
||||
borderColor: currentTheme.colors.elevation3 || '#444444',
|
||||
}
|
||||
]}
|
||||
onPress={onUpdateLater}
|
||||
disabled={isInstalling}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={[
|
||||
styles.secondaryButtonText,
|
||||
{ color: currentTheme.colors.mediumEmphasis }
|
||||
]}>
|
||||
Later
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.button,
|
||||
styles.secondaryButton,
|
||||
{
|
||||
backgroundColor: currentTheme.colors.darkBackground || '#2a2a2a',
|
||||
borderColor: currentTheme.colors.elevation3 || '#444444',
|
||||
}
|
||||
]}
|
||||
onPress={onDismiss}
|
||||
disabled={isInstalling}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={[
|
||||
styles.secondaryButtonText,
|
||||
{ color: currentTheme.colors.mediumEmphasis }
|
||||
]}>
|
||||
Dismiss
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Animated.View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
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;
|
||||
|
|
@ -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<UpdatePopupProps> = ({
|
|||
};
|
||||
|
||||
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<UpdatePopupProps> = ({
|
|||
return null;
|
||||
}
|
||||
|
||||
// Completely disable popup on Android
|
||||
if (Platform.OS === 'android') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// iOS implementation with full features
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
transparent
|
||||
animationType="fade"
|
||||
statusBarTranslucent
|
||||
statusBarTranslucent={true}
|
||||
presentationStyle="overFullScreen"
|
||||
>
|
||||
<View style={styles.overlay}>
|
||||
<View style={[
|
||||
|
|
@ -134,8 +148,8 @@ const UpdatePopup: React.FC<UpdatePopupProps> = ({
|
|||
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<UpdatePopupProps> = ({
|
|||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Footer removed: hardcoded message no longer shown */}
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<UpdateInfo>({ 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) {
|
||||
|
|
|
|||
|
|
@ -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<any>}> = ({ 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');
|
||||
|
|
|
|||
|
|
@ -226,6 +226,19 @@ const Sidebar: React.FC<SidebarProps> = ({ 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<NavigationProp<RootStackParamList>>();
|
||||
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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue