From c903990aaff122ec4e008d7440e05a91050a3cd0 Mon Sep 17 00:00:00 2001 From: lafay <2021211506@stu.hit.edu.cn> Date: Thu, 26 Mar 2026 03:41:43 +0800 Subject: [PATCH] feat(APKUpdate): implement APK update check and enhance build workflow - Added `expo-intent-launcher` dependency to support APK launching functionality. - Introduced `APKUpdateBootstrap` component to check for APK updates during app initialization. - Enhanced build workflow in `build.yml` to resolve runtime version and upload APK to the updates server. - Updated `HomeScreen` and `TabsLayout` to manage tab visibility and scroll behavior based on user interactions. - Refactored message bubble styles for improved UI consistency and user experience. --- .gitea/workflows/build.yml | 36 +++ app/(app)/(tabs)/_layout.tsx | 14 +- app/_layout.tsx | 22 ++ package-lock.json | 10 + package.json | 1 + ...nge.ts => withMainActivityConfigChange.js} | 10 +- src/screens/home/HomeScreen.tsx | 22 +- .../components/ChatScreen/MessageBubble.tsx | 6 +- .../components/ChatScreen/SegmentRenderer.tsx | 29 +- .../message/components/ChatScreen/styles.ts | 60 ++-- src/services/apkUpdateService.ts | 277 ++++++++++++++++++ src/services/index.ts | 4 + src/stores/homeTabPressStore.ts | 13 + src/stores/index.ts | 3 + src/theme/palettes.ts | 4 +- 15 files changed, 449 insertions(+), 62 deletions(-) rename plugins/{withMainActivityConfigChange.ts => withMainActivityConfigChange.js} (91%) create mode 100644 src/services/apkUpdateService.ts create mode 100644 src/stores/homeTabPressStore.ts diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index 54648a5..889afe9 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -272,6 +272,42 @@ jobs: name: carrot-bbs-android-release-apk path: android/app/build/outputs/apk/release/app-release.apk + - name: Resolve runtime version + id: runtime + run: | + RUNTIME_VERSION="$(node -p "require('./app.json').expo.version")" + echo "runtime_version=${RUNTIME_VERSION}" >> "$GITHUB_OUTPUT" + echo "Resolved runtimeVersion: ${RUNTIME_VERSION}" + + - name: Upload APK to Updates Server + env: + OTA_AUTH_TOKEN: ${{ secrets.OTA_AUTH_TOKEN }} + UPDATES_SERVER_URL: https://updates.littlelan.cn + run: | + test -n "${OTA_AUTH_TOKEN}" || (echo "Missing secret OTA_AUTH_TOKEN" && exit 1) + + APK_PATH="android/app/build/outputs/apk/release/app-release.apk" + RUNTIME_VERSION="${{ steps.runtime.outputs.runtime_version }}" + + echo "Uploading APK for runtime version: ${RUNTIME_VERSION}" + + HTTP_CODE=$(curl -s -o response.json -w "%{http_code}" \ + -X POST \ + "${UPDATES_SERVER_URL}/admin/apk/upload?runtimeVersion=${RUNTIME_VERSION}" \ + -H "Authorization: Bearer ${OTA_AUTH_TOKEN}" \ + -H "Content-Type: application/vnd.android.package-archive" \ + --data-binary @"${APK_PATH}") + + echo "HTTP Response Code: ${HTTP_CODE}" + cat response.json + + if [ "${HTTP_CODE}" -ne 200 ]; then + echo "Failed to upload APK" + exit 1 + fi + + echo "APK uploaded successfully!" + build-and-push-web: runs-on: ubuntu-latest permissions: diff --git a/app/(app)/(tabs)/_layout.tsx b/app/(app)/(tabs)/_layout.tsx index f555b9b..d9f4d60 100644 --- a/app/(app)/(tabs)/_layout.tsx +++ b/app/(app)/(tabs)/_layout.tsx @@ -1,4 +1,4 @@ -import { useMemo } from 'react'; +import { useMemo, useCallback } from 'react'; import { Platform, useWindowDimensions } from 'react-native'; import { Tabs, usePathname } from 'expo-router'; import { MaterialCommunityIcons } from '@expo/vector-icons'; @@ -6,7 +6,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useAppColors, shadows } from '../../../src/theme'; import { BREAKPOINTS } from '../../../src/hooks/useResponsive'; -import { useHomeTabBarVisibilityStore, useTotalUnreadCount } from '../../../src/stores'; +import { useHomeTabBarVisibilityStore, useTotalUnreadCount, useHomeTabPressStore } from '../../../src/stores'; const TAB_BAR_HEIGHT = 56; const TAB_BAR_FLOATING_MARGIN = 12; @@ -19,10 +19,17 @@ export default function TabsLayout() { const pathname = usePathname(); const unreadCount = useTotalUnreadCount(); const scrollHideTabBar = useHomeTabBarVisibilityStore((s) => s.bottomTabBarHiddenByScroll); + const triggerHomeTabPress = useHomeTabPressStore((s) => s.triggerPress); const hideTabBar = Platform.OS === 'web' && width >= BREAKPOINTS.desktop; const isHomeStackRoute = pathname === '/home' || pathname.startsWith('/home/'); + const handleHomeTabPress = useCallback(() => { + if (pathname === '/home') { + triggerHomeTabPress(); + } + }, [pathname, triggerHomeTabPress]); + const tabBarStyle = useMemo(() => { if (hideTabBar) { return { display: 'none' as const, height: 0, overflow: 'hidden' as const }; @@ -81,6 +88,9 @@ export default function TabsLayout() { /> ), }} + listeners={{ + tabPress: handleHomeTabPress, + }} /> { + if (hasChecked.current) return; + hasChecked.current = true; + + // 延迟执行,避免与启动流程冲突 + const timer = setTimeout(() => { + checkForAPKUpdate().catch((error) => { + console.error('APK update check failed:', error); + }); + }, 3000); + + return () => clearTimeout(timer); + }, []); + + return null; +} + function ThemedStack() { const router = useRouter(); const colors = useAppColors(); @@ -148,6 +169,7 @@ function ThemedStack() { + diff --git a/package-lock.json b/package-lock.json index db23446..3620731 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,6 +29,7 @@ "expo-haptics": "~55.0.8", "expo-image": "^55.0.5", "expo-image-picker": "^55.0.10", + "expo-intent-launcher": "~55.0.9", "expo-linear-gradient": "^55.0.8", "expo-media-library": "~55.0.9", "expo-notifications": "^55.0.10", @@ -5747,6 +5748,15 @@ "expo": "*" } }, + "node_modules/expo-intent-launcher": { + "version": "55.0.9", + "resolved": "https://registry.npmmirror.com/expo-intent-launcher/-/expo-intent-launcher-55.0.9.tgz", + "integrity": "sha512-s8k8dF8PfgzN0c+xzNTd9ceHh+/6mt2ncnMuqFaIIry2BeDGITbsgVijHr39eB5vS+KopCcOYYBYr+O1epx4TA==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-json-utils": { "version": "55.0.0", "resolved": "https://registry.npmmirror.com/expo-json-utils/-/expo-json-utils-55.0.0.tgz", diff --git a/package.json b/package.json index aa21d88..519c398 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "expo-haptics": "~55.0.8", "expo-image": "^55.0.5", "expo-image-picker": "^55.0.10", + "expo-intent-launcher": "~55.0.9", "expo-linear-gradient": "^55.0.8", "expo-media-library": "~55.0.9", "expo-notifications": "^55.0.10", diff --git a/plugins/withMainActivityConfigChange.ts b/plugins/withMainActivityConfigChange.js similarity index 91% rename from plugins/withMainActivityConfigChange.ts rename to plugins/withMainActivityConfigChange.js index 4a8f47e..1d85287 100644 --- a/plugins/withMainActivityConfigChange.ts +++ b/plugins/withMainActivityConfigChange.js @@ -1,14 +1,10 @@ -import { - ConfigPlugin, - withMainActivity, - AndroidConfig, -} from '@expo/config-plugins'; +const { withMainActivity } = require('@expo/config-plugins'); /** * 在 MainActivity 中添加 onConfigurationChanged 方法 * 用于监听系统深色模式变化并通知 React Native */ -const withMainActivityConfigChange: ConfigPlugin = (config) => { +const withMainActivityConfigChange = (config) => { return withMainActivity(config, async (config) => { const { contents } = config.modResults; @@ -74,4 +70,4 @@ import android.content.res.Configuration`; }); }; -export default withMainActivityConfigChange; +module.exports = withMainActivityConfigChange; diff --git a/src/screens/home/HomeScreen.tsx b/src/screens/home/HomeScreen.tsx index 05e76a7..04f815d 100644 --- a/src/screens/home/HomeScreen.tsx +++ b/src/screens/home/HomeScreen.tsx @@ -26,7 +26,7 @@ import { MaterialCommunityIcons } from '@expo/vector-icons'; import { Gesture, GestureDetector } from 'react-native-gesture-handler'; import { useAppColors, useResolvedColorScheme, spacing, borderRadius, shadows, type AppColors } from '../../theme'; import { Post } from '../../types'; -import { useUserStore, useHomeTabBarVisibilityStore } from '../../stores'; +import { useUserStore, useHomeTabBarVisibilityStore, useHomeTabPressStore } from '../../stores'; import { useCurrentUser } from '../../stores/authStore'; import { channelService, postService } from '../../services'; import { PostCard, TabBar, SearchBar } from '../../components/business'; @@ -212,9 +212,14 @@ export const HomeScreen: React.FC = () => { const setBottomTabBarHiddenByScroll = useHomeTabBarVisibilityStore((s) => s.setBottomTabBarHiddenByScroll); const bottomTabBarHiddenByScroll = useHomeTabBarVisibilityStore((s) => s.bottomTabBarHiddenByScroll); + const homeTabPressCount = useHomeTabPressStore((s) => s.pressCount); const homeListScrollYRef = useRef(0); const tabBarIdleRestoreTimerRef = useRef | null>(null); + // FlatList 和 ScrollView 的 ref,用于滚动到顶部 + const flatListRef = useRef | null>(null); + const scrollViewRef = useRef(null); + const clearTabBarIdleRestoreTimer = useCallback(() => { if (tabBarIdleRestoreTimerRef.current != null) { clearTimeout(tabBarIdleRestoreTimerRef.current); @@ -269,6 +274,19 @@ export const HomeScreen: React.FC = () => { } }, [showSearch, clearTabBarIdleRestoreTimer, setBottomTabBarHiddenByScroll]); + // 监听首页 Tab 点击事件,滚动到顶部 + useEffect(() => { + if (homeTabPressCount > 0) { + // 滚动 FlatList 到顶部 + flatListRef.current?.scrollToOffset({ offset: 0, animated: true }); + // 滚动 ScrollView 到顶部(网格模式) + scrollViewRef.current?.scrollTo({ y: 0, animated: true }); + // 重置底部 Tab 栏状态 + setBottomTabBarHiddenByScroll(false); + homeListScrollYRef.current = 0; + } + }, [homeTabPressCount, setBottomTabBarHiddenByScroll]); + /** 横向胶囊条滚动位置:切换频道刷新列表时不重置 */ const capsuleHScrollRef = useRef(null); const capsuleScrollXRef = useRef(0); @@ -819,6 +837,7 @@ export const HomeScreen: React.FC = () => { // 移动端使用瀑布流布局(2列) return ( { // 移动端和宽屏都使用单列 FlatList,宽屏下居中显示 return ( = ({ > @@ -418,7 +418,7 @@ export const MessageBubble: React.FC = ({ > @@ -452,7 +452,7 @@ export const MessageBubble: React.FC = ({ diff --git a/src/screens/message/components/ChatScreen/SegmentRenderer.tsx b/src/screens/message/components/ChatScreen/SegmentRenderer.tsx index a80b937..0f52db0 100644 --- a/src/screens/message/components/ChatScreen/SegmentRenderer.tsx +++ b/src/screens/message/components/ChatScreen/SegmentRenderer.tsx @@ -847,41 +847,36 @@ function createSegmentStyles(colors: AppColors) { marginTop: 2, }, - // 文本 - Telegram风格:统一深色字体 + // 文本 - 柔和风格:深灰色文字 + 正常字重 textContent: { - fontSize: 16, - lineHeight: 23, - letterSpacing: 0.2, + fontSize: 17.5, + lineHeight: 25, + letterSpacing: 0.1, fontWeight: '400', }, textMe: { - color: colors.chat.textPrimary, + color: '#2A2A2A', }, textOther: { - color: colors.chat.textPrimary, + color: '#2A2A2A', }, - // @提及 - Telegram风格:蓝色高亮 + // @提及 - 微信/QQ 风格:更精致的高亮 atText: { fontSize: 16, - lineHeight: 23, - fontWeight: '600', - paddingHorizontal: 2, + lineHeight: 22, + fontWeight: '500', }, atTextMe: { - color: '#4A88C7', - backgroundColor: 'rgba(74, 136, 199, 0.12)', - borderRadius: 4, + color: '#1A5F9E', // 深蓝色,在绿色背景上更清晰 }, atTextOther: { color: colors.chat.link, - backgroundColor: 'rgba(74, 136, 199, 0.12)', - borderRadius: 4, }, atHighlight: { - backgroundColor: 'rgba(74, 136, 199, 0.2)', + backgroundColor: 'rgba(74, 136, 199, 0.15)', borderRadius: 4, - paddingHorizontal: 4, + paddingHorizontal: 3, }, // 图片 - QQ风格:保持原始宽高比 diff --git a/src/screens/message/components/ChatScreen/styles.ts b/src/screens/message/components/ChatScreen/styles.ts index a8264c7..b3e87b6 100644 --- a/src/screens/message/components/ChatScreen/styles.ts +++ b/src/screens/message/components/ChatScreen/styles.ts @@ -201,7 +201,7 @@ export function createChatScreenStyles(colors: AppColors) { alignItems: 'flex-start', }, - // 头像 + // 头像 - 更大一点 avatarWrapper: { marginHorizontal: 2, shadowColor: colors.chat.shadow, @@ -226,43 +226,43 @@ export function createChatScreenStyles(colors: AppColors) { alignItems: 'flex-start', }, senderName: { - fontSize: 13, - color: colors.chat.link, + fontSize: 14, + color: '#555555', // 柔和的深灰色 marginBottom: 4, marginLeft: 4, fontWeight: '600', }, - // 消息气泡 - 外层极轻阴影(纯文字气泡更接近扁平 IM) + // 消息气泡 - 微信风格:更小的圆角 + 更紧凑的内边距 messageBubbleOuter: { - borderRadius: 16, - minWidth: 60, + borderRadius: 12, + minWidth: 44, shadowColor: colors.chat.shadow, - shadowOffset: { width: 0, height: 0.5 }, - shadowOpacity: 0.04, - shadowRadius: 1, + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.06, + shadowRadius: 2, elevation: 1, }, myBubbleOuter: { - borderBottomRightRadius: 4, + borderBottomRightRadius: 4, // 微信的小尾巴更尖 }, theirBubbleOuter: { borderTopLeftRadius: 4, }, - // 内层:背景 + 裁剪,防止回复条/@ 高亮在圆角外露出颜色 + // 内层:更紧凑的微信风格内边距 messageBubbleInner: { - borderRadius: 16, + borderRadius: 12, overflow: 'hidden', - paddingHorizontal: spacing.md, - paddingVertical: spacing.sm + 4, - minWidth: 60, + paddingHorizontal: 10, // 微信:更窄的左右边距 + paddingVertical: 7, // 微信:更紧的上下边距 + minWidth: 44, }, myBubbleInner: { - backgroundColor: colors.chat.replyTint, + backgroundColor: colors.chat.bubbleOutgoing, borderBottomRightRadius: 4, }, theirBubbleInner: { - backgroundColor: colors.chat.card, + backgroundColor: colors.chat.bubbleIncoming, borderTopLeftRadius: 4, }, recalledBubble: { @@ -272,17 +272,17 @@ export function createChatScreenStyles(colors: AppColors) { borderStyle: 'dashed', }, myBubbleText: { - color: colors.chat.textPrimary, // 主文案 - fontSize: 16, - lineHeight: 23, - letterSpacing: 0.2, + color: '#2A2A2A', + fontSize: 18, // 更大字体 + lineHeight: 25, // 相应调大行高 + letterSpacing: 0.1, fontWeight: '400', }, theirBubbleText: { - color: colors.chat.textPrimary, // 主文案 - fontSize: 16, - lineHeight: 23, - letterSpacing: 0.2, + color: '#2A2A2A', + fontSize: 18, + lineHeight: 25, + letterSpacing: 0.1, fontWeight: '400', }, // 选中状态 @@ -312,21 +312,21 @@ export function createChatScreenStyles(colors: AppColors) { fontStyle: 'italic', }, - // 图片消息 - QQ风格:更大的图片,更明显的圆角 + // 图片消息 - QQ/微信风格:更大的圆角,更柔和的阴影 imageBubble: { borderRadius: 18, overflow: 'hidden', shadowColor: colors.chat.shadow, shadowOffset: { width: 0, height: 3 }, - shadowOpacity: 0.15, + shadowOpacity: 0.12, shadowRadius: 6, - elevation: 5, + elevation: 4, }, myImageBubble: { - borderBottomRightRadius: 6, // 右下角尖,箭头在右下 + borderBottomRightRadius: 8, }, theirImageBubble: { - borderTopLeftRadius: 6, // 左上角尖,箭头在左上 + borderTopLeftRadius: 8, }, messageImage: { width: 220, diff --git a/src/services/apkUpdateService.ts b/src/services/apkUpdateService.ts new file mode 100644 index 0000000..6d2de6d --- /dev/null +++ b/src/services/apkUpdateService.ts @@ -0,0 +1,277 @@ +/** + * APK 更新检查服务 + * 用于检查最新版本并提示用户下载更新 + */ + +import { Platform, Linking, Alert } from 'react-native'; +import Constants from 'expo-constants'; +import * as FileSystem from 'expo-file-system/legacy'; +import * as IntentLauncher from 'expo-intent-launcher'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { showConfirm } from './dialogService'; + +// 更新服务器地址 +const UPDATES_SERVER_URL = Constants.expoConfig?.extra?.updatesUrl + ? Constants.expoConfig.extra.updatesUrl.replace('/api/manifest', '') + : 'https://updates.littlelan.cn'; + +// 检查间隔(毫秒):24小时 +const CHECK_INTERVAL = 24 * 60 * 60 * 1000; + +// 最后检查时间存储键 +const LAST_CHECK_KEY = 'apk_update_last_check'; + +// APK 版本信息 +export interface APKVersionInfo { + runtimeVersion: string; + versionName: string; + size: number; + downloadUrl: string; + createdAt: string; +} + +// 版本比较结果 +export interface VersionCheckResult { + hasUpdate: boolean; + currentVersion: string; + latestVersion: string; + downloadUrl: string; + size: number; +} + +/** + * 比较版本号 + * 返回: 1 表示 v1 > v2, -1 表示 v1 < v2, 0 表示相等 + */ +function compareVersions(v1: string, v2: string): number { + const parts1 = v1.split('.').map(Number); + const parts2 = v2.split('.').map(Number); + + for (let i = 0; i < Math.max(parts1.length, parts2.length); i++) { + const p1 = parts1[i] || 0; + const p2 = parts2[i] || 0; + if (p1 > p2) return 1; + if (p1 < p2) return -1; + } + return 0; +} + +/** + * 获取最新 APK 版本信息 + */ +async function fetchLatestAPKVersion(): Promise { + try { + const response = await fetch(`${UPDATES_SERVER_URL}/api/apk/latest`, { + method: 'GET', + headers: { + 'Accept': 'application/json', + }, + }); + + if (!response.ok) { + console.warn('Failed to fetch latest APK version:', response.status); + return null; + } + + const data: APKVersionInfo = await response.json(); + return data; + } catch (error) { + console.error('Error fetching latest APK version:', error); + return null; + } +} + +/** + * 获取当前应用版本 + */ +function getCurrentVersion(): string { + return Constants.expoConfig?.version || '1.0.0'; +} + +/** + * 格式化文件大小 + */ +function formatFileSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`; +} + +/** + * 检查是否应该执行检查(避免频繁检查) + */ +async function shouldCheck(): Promise { + try { + const lastCheck = await AsyncStorage.getItem(LAST_CHECK_KEY); + if (!lastCheck) return true; + + const lastCheckTime = parseInt(lastCheck, 10); + return Date.now() - lastCheckTime > CHECK_INTERVAL; + } catch { + return true; + } +} + +/** + * 记录检查时间 + */ +async function recordCheckTime(): Promise { + try { + await AsyncStorage.setItem(LAST_CHECK_KEY, Date.now().toString()); + } catch (error) { + console.error('Failed to record check time:', error); + } +} + +/** + * 下载并安装 APK + */ +async function downloadAndInstallAPK(downloadUrl: string, version: string): Promise { + if (Platform.OS !== 'android') { + Alert.alert('提示', '此功能仅支持 Android 设备'); + return; + } + + try { + const downloadPath = `${FileSystem.documentDirectory}carrot-bbs-${version}.apk`; + + // 显示下载进度 + showConfirm({ + title: '正在下载', + message: `正在下载新版本 ${version},请稍候...`, + confirmText: '后台下载', + cancelText: '取消', + onConfirm: () => { + // 用户选择后台下载,继续 + }, + onCancel: () => { + // 用户取消 + }, + }); + + // 下载 APK + const downloadResult = await FileSystem.downloadAsync(downloadUrl, downloadPath); + + if (!downloadResult.uri) { + Alert.alert('下载失败', '无法下载安装包,请稍后重试'); + return; + } + + // 安装 APK + await installAPK(downloadResult.uri); + } catch (error) { + console.error('Download/Install error:', error); + Alert.alert('更新失败', '下载或安装过程中出错,请稍后重试'); + } +} + +/** + * 安装 APK + */ +async function installAPK(apkUri: string): Promise { + try { + // 获取文件的 content URI + const contentUri = await FileSystem.getContentUriAsync(apkUri); + + // 使用 Intent 安装 APK + await IntentLauncher.startActivityAsync('android.intent.action.INSTALL_PACKAGE', { + data: contentUri, + flags: 1, // FLAG_GRANT_READ_URI_PERMISSION + }); + } catch (error) { + console.error('Install APK error:', error); + // 尝试使用浏览器下载 + const webUrl = apkUri.replace('file://', ''); + Linking.openURL(webUrl); + } +} + +/** + * 显示更新提示对话框 + */ +function showUpdateDialog(result: VersionCheckResult): void { + const sizeText = formatFileSize(result.size); + + showConfirm({ + title: '发现新版本', + message: `当前版本: ${result.currentVersion}\n最新版本: ${result.latestVersion}\n安装包大小: ${sizeText}\n\n是否立即下载更新?`, + confirmText: '立即更新', + cancelText: '稍后提醒', + onConfirm: () => { + downloadAndInstallAPK(result.downloadUrl, result.latestVersion); + }, + }); +} + +/** + * 检查 APK 更新(主入口) + * @param force 是否强制检查(忽略时间间隔) + */ +export async function checkForAPKUpdate(force: boolean = false): Promise { + // 仅在 Android 平台检查 + if (Platform.OS !== 'android') { + return null; + } + + // 检查是否应该执行检查 + if (!force) { + const should = await shouldCheck(); + if (!should) { + console.log('APK update check skipped (too frequent)'); + return null; + } + } + + // 记录检查时间 + await recordCheckTime(); + + // 获取最新版本信息 + const latestAPK = await fetchLatestAPKVersion(); + if (!latestAPK) { + console.log('No APK version info available'); + return null; + } + + const currentVersion = getCurrentVersion(); + const latestVersion = latestAPK.versionName || latestAPK.runtimeVersion; + + // 比较版本 + const comparison = compareVersions(currentVersion, latestVersion); + + const result: VersionCheckResult = { + hasUpdate: comparison < 0, + currentVersion, + latestVersion, + downloadUrl: latestAPK.downloadUrl, + size: latestAPK.size, + }; + + // 如果有更新,显示对话框 + if (result.hasUpdate) { + showUpdateDialog(result); + } + + return result; +} + +/** + * 手动检查更新(用户主动触发) + */ +export async function manualCheckForUpdate(): Promise { + const result = await checkForAPKUpdate(true); + + if (!result) { + Alert.alert('检查失败', '无法获取版本信息,请检查网络连接'); + return; + } + + if (!result.hasUpdate) { + Alert.alert('已是最新', `当前版本 ${result.currentVersion} 已是最新版本`); + } +} + +export const apkUpdateService = { + checkForAPKUpdate, + manualCheckForUpdate, +}; diff --git a/src/services/index.ts b/src/services/index.ts index f7478d4..51a2a01 100644 --- a/src/services/index.ts +++ b/src/services/index.ts @@ -110,3 +110,7 @@ export { // 统一提示/弹窗服务 export { showPrompt } from './promptService'; export { showConfirm } from './dialogService'; + +// APK 更新检查服务 +export { apkUpdateService, checkForAPKUpdate, manualCheckForUpdate } from './apkUpdateService'; +export type { APKVersionInfo, VersionCheckResult } from './apkUpdateService'; diff --git a/src/stores/homeTabPressStore.ts b/src/stores/homeTabPressStore.ts new file mode 100644 index 0000000..a81ce96 --- /dev/null +++ b/src/stores/homeTabPressStore.ts @@ -0,0 +1,13 @@ +import { create } from 'zustand'; + +interface HomeTabPressState { + /** 用于触发首页回到顶部的事件计数器 */ + pressCount: number; + /** 触发首页 Tab 点击事件 */ + triggerPress: () => void; +} + +export const useHomeTabPressStore = create((set) => ({ + pressCount: 0, + triggerPress: () => set((state) => ({ pressCount: state.pressCount + 1 })), +})); diff --git a/src/stores/index.ts b/src/stores/index.ts index 8779159..b7d21a6 100644 --- a/src/stores/index.ts +++ b/src/stores/index.ts @@ -44,6 +44,9 @@ export { userManager } from './userManager'; export { useHomeTabBarVisibilityStore, } from './homeTabBarVisibilityStore'; +export { + useHomeTabPressStore, +} from './homeTabPressStore'; export { useAppColors, useThemePreference, diff --git a/src/theme/palettes.ts b/src/theme/palettes.ts index e845f0b..c9f68b9 100644 --- a/src/theme/palettes.ts +++ b/src/theme/palettes.ts @@ -76,8 +76,8 @@ export const lightColors = { link: '#4A88C7', success: '#34C759', danger: '#FF3B30', - bubbleOutgoing: '#E8E8E8', - bubbleIncoming: '#FFFFFF', + bubbleOutgoing: '#7CB9FF', // 柔和的淡蓝色 + bubbleIncoming: '#FFFFFF', // 对方发的消息 - 纯白 replyTint: '#DFF2FF', replyTintActive: '#CFEAFF', replyBorder: '#7FB6E6',