feat: Implement automatic update checking and user notifications for new versions

This commit is contained in:
zimplexing
2025-08-01 13:53:29 +08:00
parent 10bfbbbf8e
commit 18aba87ddb
18 changed files with 1524 additions and 4 deletions

253
components/UpdateModal.tsx Normal file
View File

@@ -0,0 +1,253 @@
import React from 'react';
import {
Modal,
View,
Text,
TouchableOpacity,
StyleSheet,
ActivityIndicator,
Platform,
} from 'react-native';
import { useUpdateStore } from '../stores/updateStore';
import { Colors } from '../constants/Colors';
export function UpdateModal() {
const {
showUpdateModal,
currentVersion,
remoteVersion,
downloading,
downloadProgress,
error,
setShowUpdateModal,
startDownload,
installUpdate,
skipThisVersion,
downloadedPath,
} = useUpdateStore();
const updateButtonRef = React.useRef<TouchableOpacity>(null);
const laterButtonRef = React.useRef<TouchableOpacity>(null);
const skipButtonRef = React.useRef<TouchableOpacity>(null);
async function handleUpdate() {
if (!downloading && !downloadedPath) {
// 开始下载
await startDownload();
} else if (downloadedPath) {
// 已下载完成,安装
await installUpdate();
}
}
function handleLater() {
setShowUpdateModal(false);
}
async function handleSkip() {
await skipThisVersion();
}
React.useEffect(() => {
if (showUpdateModal && Platform.isTV) {
// TV平台自动聚焦到更新按钮
setTimeout(() => {
updateButtonRef.current?.focus();
}, 100);
}
}, [showUpdateModal]);
const getButtonText = () => {
if (downloading) {
return `下载中 ${downloadProgress}%`;
} else if (downloadedPath) {
return '立即安装';
} else {
return '立即更新';
}
};
return (
<Modal
visible={showUpdateModal}
transparent
animationType="fade"
onRequestClose={handleLater}
>
<View style={styles.overlay}>
<View style={styles.container}>
<Text style={styles.title}></Text>
<View style={styles.versionInfo}>
<Text style={styles.versionText}>
当前版本: v{currentVersion}
</Text>
<Text style={styles.arrow}></Text>
<Text style={[styles.versionText, styles.newVersion]}>
新版本: v{remoteVersion}
</Text>
</View>
{downloading && (
<View style={styles.progressContainer}>
<View style={styles.progressBar}>
<View
style={[
styles.progressFill,
{ width: `${downloadProgress}%` },
]}
/>
</View>
<Text style={styles.progressText}>{downloadProgress}%</Text>
</View>
)}
{error && (
<Text style={styles.errorText}>{error}</Text>
)}
<View style={styles.buttonContainer}>
<TouchableOpacity
ref={updateButtonRef}
style={[styles.button, styles.primaryButton]}
onPress={handleUpdate}
disabled={downloading && !downloadedPath}
>
{downloading && !downloadedPath ? (
<ActivityIndicator color="#fff" />
) : (
<Text style={styles.buttonText}>{getButtonText()}</Text>
)}
</TouchableOpacity>
{!downloading && !downloadedPath && (
<>
<TouchableOpacity
ref={laterButtonRef}
style={[styles.button, styles.secondaryButton]}
onPress={handleLater}
>
<Text style={[styles.buttonText, styles.secondaryButtonText]}>
</Text>
</TouchableOpacity>
<TouchableOpacity
ref={skipButtonRef}
style={[styles.button, styles.textButton]}
onPress={handleSkip}
>
<Text style={[styles.buttonText, styles.textButtonText]}>
</Text>
</TouchableOpacity>
</>
)}
</View>
</View>
</View>
</Modal>
);
}
const styles = StyleSheet.create({
overlay: {
flex: 1,
backgroundColor: 'rgba(0, 0, 0, 0.7)',
justifyContent: 'center',
alignItems: 'center',
},
container: {
backgroundColor: Colors.dark.background,
borderRadius: 12,
padding: 24,
width: Platform.isTV ? 500 : '90%',
maxWidth: 500,
alignItems: 'center',
},
title: {
fontSize: Platform.isTV ? 28 : 24,
fontWeight: 'bold',
color: Colors.dark.text,
marginBottom: 20,
},
versionInfo: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 24,
},
versionText: {
fontSize: Platform.isTV ? 18 : 16,
color: Colors.dark.text,
},
newVersion: {
color: Colors.dark.primary || '#00bb5e',
fontWeight: 'bold',
},
arrow: {
fontSize: Platform.isTV ? 20 : 18,
color: Colors.dark.text,
marginHorizontal: 12,
},
progressContainer: {
width: '100%',
marginBottom: 20,
},
progressBar: {
height: 6,
backgroundColor: Colors.dark.border,
borderRadius: 3,
overflow: 'hidden',
marginBottom: 8,
},
progressFill: {
height: '100%',
backgroundColor: Colors.dark.primary || '#00bb5e',
},
progressText: {
fontSize: Platform.isTV ? 16 : 14,
color: Colors.dark.text,
textAlign: 'center',
},
errorText: {
fontSize: Platform.isTV ? 16 : 14,
color: '#ff4444',
marginBottom: 16,
textAlign: 'center',
},
buttonContainer: {
width: '100%',
gap: 12,
},
button: {
paddingVertical: Platform.isTV ? 14 : 12,
paddingHorizontal: 24,
borderRadius: 8,
alignItems: 'center',
justifyContent: 'center',
minHeight: Platform.isTV ? 56 : 48,
},
primaryButton: {
backgroundColor: Colors.dark.primary || '#00bb5e',
},
secondaryButton: {
backgroundColor: 'transparent',
borderWidth: 1,
borderColor: Colors.dark.border,
},
textButton: {
backgroundColor: 'transparent',
},
buttonText: {
fontSize: Platform.isTV ? 18 : 16,
fontWeight: '600',
color: '#fff',
},
secondaryButtonText: {
color: Colors.dark.text,
},
textButtonText: {
color: Colors.dark.text,
fontWeight: 'normal',
},
});

View File

@@ -0,0 +1,133 @@
import React from "react";
import { View, StyleSheet, Platform, ActivityIndicator } from "react-native";
import { ThemedText } from "../ThemedText";
import { StyledButton } from "../StyledButton";
import { useUpdateStore } from "@/stores/updateStore";
import { UPDATE_CONFIG } from "@/constants/UpdateConfig";
export function UpdateSection() {
const {
currentVersion,
remoteVersion,
updateAvailable,
downloading,
downloadProgress,
checkForUpdate,
setShowUpdateModal,
} = useUpdateStore();
const [checking, setChecking] = React.useState(false);
const handleCheckUpdate = async () => {
setChecking(true);
try {
await checkForUpdate(false);
} finally {
setChecking(false);
}
};
return (
<View style={styles.sectionContainer}>
<ThemedText style={styles.sectionTitle}></ThemedText>
<View style={styles.row}>
<ThemedText style={styles.label}></ThemedText>
<ThemedText style={styles.value}>v{currentVersion}</ThemedText>
</View>
{updateAvailable && (
<View style={styles.row}>
<ThemedText style={styles.label}></ThemedText>
<ThemedText style={[styles.value, styles.newVersion]}>
v{remoteVersion}
</ThemedText>
</View>
)}
{downloading && (
<View style={styles.row}>
<ThemedText style={styles.label}></ThemedText>
<ThemedText style={styles.value}>{downloadProgress}%</ThemedText>
</View>
)}
<View style={styles.buttonContainer}>
<StyledButton
title={checking ? "检查中..." : "检查更新"}
onPress={handleCheckUpdate}
disabled={checking || downloading}
style={styles.button}
>
{checking && <ActivityIndicator color="#fff" size="small" />}
</StyledButton>
{updateAvailable && !downloading && (
<StyledButton
title="立即更新"
onPress={() => setShowUpdateModal(true)}
style={[styles.button, styles.updateButton]}
/>
)}
</View>
{UPDATE_CONFIG.AUTO_CHECK && (
<ThemedText style={styles.hint}>
{UPDATE_CONFIG.CHECK_INTERVAL / (60 * 60 * 1000)}
</ThemedText>
)}
</View>
);
}
const styles = StyleSheet.create({
sectionContainer: {
marginBottom: 24,
padding: 16,
backgroundColor: Platform.select({
ios: "rgba(255, 255, 255, 0.05)",
android: "rgba(255, 255, 255, 0.05)",
default: "transparent",
}),
borderRadius: 8,
},
sectionTitle: {
fontSize: Platform.isTV ? 24 : 20,
fontWeight: "bold",
marginBottom: 16,
},
row: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
marginBottom: 12,
},
label: {
fontSize: Platform.isTV ? 18 : 16,
color: "#999",
},
value: {
fontSize: Platform.isTV ? 18 : 16,
},
newVersion: {
color: "#00bb5e",
fontWeight: "bold",
},
buttonContainer: {
flexDirection: "row",
gap: 12,
marginTop: 16,
},
button: {
flex: 1,
},
updateButton: {
backgroundColor: "#00bb5e",
},
hint: {
fontSize: Platform.isTV ? 14 : 12,
color: "#666",
marginTop: 12,
textAlign: "center",
},
});