feat(app): add version check system and enhance internationalization support

Add comprehensive app update checking functionality with:
- New VersionCheckContext for managing update detection and notifications
- VersionUpdateModal UI component for presenting update information
- Version service API integration with platform-specific update URLs
- Version check menu item in personal settings with manual/automatic checking

Enhance internationalization across workout features:
- Complete workout type translations for English and Chinese
- Localized workout detail modal with proper date/time formatting
- Locale-aware date formatting in fitness rings detail
- Workout notification improvements with deep linking to specific workout details

Improve UI/UX with better chart rendering, sizing fixes, and enhanced navigation flow. Update app version to 1.1.3 and include app version in API headers for better tracking.
This commit is contained in:
2025-11-29 20:47:16 +08:00
parent 83b77615cf
commit a309123b35
19 changed files with 1132 additions and 159 deletions

View File

@@ -1,5 +1,6 @@
import { buildApiUrl } from '@/constants/Api';
import AsyncStorage from '@/utils/kvStore';
import Constants from 'expo-constants';
import { Alert } from 'react-native';
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
@@ -128,6 +129,10 @@ export type ApiResponse<T> = {
data: T;
};
function getAppVersion(): string | undefined {
return Constants.expoConfig?.version || Constants.nativeAppVersion || undefined;
}
async function doFetch<T>(path: string, options: ApiRequestOptions = {}): Promise<T> {
const url = buildApiUrl(path);
const headers: Record<string, string> = {
@@ -142,6 +147,11 @@ async function doFetch<T>(path: string, options: ApiRequestOptions = {}): Promis
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const appVersion = getAppVersion();
if (appVersion) {
headers['X-App-Version'] = appVersion;
}
const response = await fetch(url, {
@@ -224,6 +234,10 @@ export async function postTextStream(path: string, body: any, callbacks: TextStr
if (token) {
requestHeaders['Authorization'] = `Bearer ${token}`;
}
const appVersion = getAppVersion();
if (appVersion) {
requestHeaders['X-App-Version'] = appVersion;
}
const xhr = new XMLHttpRequest();
let lastReadIndex = 0;

View File

@@ -1,4 +1,5 @@
import { ROUTES } from '@/constants/Routes';
import { logger } from '@/utils/logger';
import { getNotificationEnabled } from '@/utils/userPreferences';
import * as Notifications from 'expo-notifications';
import { router } from 'expo-router';
@@ -231,9 +232,23 @@ export class NotificationService {
router.push(ROUTES.TAB_FASTING as any);
} else if (data?.type === NotificationTypes.WORKOUT_COMPLETION) {
// 处理锻炼完成通知
console.log('用户点击了锻炼完成通知', data);
// 跳转到锻炼历史页面
router.push('/workout/history' as any);
logger.info('用户点击了锻炼完成通知', data);
const workoutId =
typeof data?.workoutId === 'string'
? data.workoutId
: data?.workoutId != null
? String(data.workoutId)
: null;
// 跳转到锻炼历史页面并在有锻炼ID时自动打开详情
if (workoutId) {
router.push({
pathname: '/workout/history',
params: { workoutId },
} as any);
} else {
router.push('/workout/history' as any);
}
} else if (data?.type === NotificationTypes.HRV_STRESS_ALERT) {
console.log('用户点击了 HRV 压力通知', data);
const targetUrl = (data?.url as string) || '/(tabs)/statistics';
@@ -616,4 +631,3 @@ export const sendMoodCheckinReminder = (title: string, body: string, date?: Date
return notificationService.sendImmediateNotification(notification);
}
};

29
services/version.ts Normal file
View File

@@ -0,0 +1,29 @@
import { api } from '@/services/api';
import Constants from 'expo-constants';
import { Platform } from 'react-native';
export type VersionInfo = {
latestVersion: string;
appStoreUrl: string;
needsUpdate: boolean;
updateMessage?: string;
releaseNotes?: string;
};
export function getCurrentAppVersion(): string {
return Constants.expoConfig?.version || Constants.nativeAppVersion || '0.0.0';
}
function getPlatformParam(): 'ios' | 'android' | undefined {
if (Platform.OS === 'ios') return 'ios';
if (Platform.OS === 'android') return 'android';
return undefined;
}
export async function fetchVersionInfo(
platformOverride?: 'ios' | 'android'
): Promise<VersionInfo> {
const platform = platformOverride || getPlatformParam();
const query = platform ? `?platform=${platform}` : '';
return await api.get<VersionInfo>(`/users/version-check${query}`);
}

View File

@@ -1,5 +1,6 @@
import { fetchRecentWorkouts, WorkoutData } from '@/utils/health';
import AsyncStorage from '@/utils/kvStore';
import { logger } from '@/utils/logger';
import { NativeEventEmitter, NativeModules } from 'react-native';
import { analyzeWorkoutAndSendNotification } from './workoutNotificationService';
@@ -94,7 +95,7 @@ class WorkoutMonitorService {
}
private async handleWorkoutUpdate(event: any): Promise<void> {
console.log('收到锻炼更新事件:', event);
logger.info('收到锻炼更新事件:', event);
// 防抖处理,避免短时间内重复处理
if (this.processingTimeout) {
@@ -105,14 +106,14 @@ class WorkoutMonitorService {
try {
await this.checkForNewWorkouts();
} catch (error) {
console.error('检查新锻炼失败:', error);
logger.error('检查新锻炼失败:', error);
}
}, 5000); // 5秒延迟确保 HealthKit 数据已完全更新
}
private async checkForNewWorkouts(): Promise<void> {
try {
console.log('检查新的锻炼记录...');
logger.info('检查新的锻炼记录...');
const lookbackWindowMs = this.lastProcessedWorkoutId
? DEFAULT_LOOKBACK_WINDOW_MS
@@ -120,7 +121,7 @@ class WorkoutMonitorService {
const startDate = new Date(Date.now() - lookbackWindowMs);
const endDate = new Date();
console.log(
logger.info(
`锻炼查询窗口: ${Math.round(lookbackWindowMs / (1000 * 60 * 60))} 小时 (${startDate.toISOString()} - ${endDate.toISOString()})`
);
@@ -130,7 +131,7 @@ class WorkoutMonitorService {
limit: 10
});
console.log(`找到 ${recentWorkouts.length} 条最近的锻炼记录`);
logger.info(`找到 ${recentWorkouts.length} 条最近的锻炼记录`);
if (this.lastProcessedWorkoutId && !recentWorkouts.some(workout => workout.id === this.lastProcessedWorkoutId)) {
console.warn('上次处理的锻炼记录不在当前查询窗口内,可能存在漏报风险');
@@ -145,15 +146,15 @@ class WorkoutMonitorService {
}
if (newWorkouts.length === 0) {
console.log('没有检测到新的锻炼记录');
logger.info('没有检测到新的锻炼记录');
return;
}
console.log(`检测到 ${newWorkouts.length} 条新的锻炼记录,将按时间顺序处理`);
logger.info(`检测到 ${newWorkouts.length} 条新的锻炼记录,将按时间顺序处理`);
// 先处理最旧的锻炼,确保通知顺序正确
for (const workout of newWorkouts.reverse()) {
console.log('处理新锻炼:', {
logger.info('处理新锻炼:', {
id: workout.id,
type: workout.workoutActivityTypeString,
duration: workout.duration,
@@ -164,22 +165,22 @@ class WorkoutMonitorService {
}
await this.saveLastProcessedWorkoutId(newWorkouts[0].id);
console.log('锻炼处理完成最新处理的锻炼ID:', newWorkouts[0].id);
logger.info('锻炼处理完成最新处理的锻炼ID:', newWorkouts[0].id);
} catch (error) {
console.error('检查新锻炼失败:', error);
logger.error('检查新锻炼失败:', error);
}
}
private async processNewWorkout(workout: WorkoutData): Promise<void> {
try {
console.log('开始处理新锻炼:', workout.id);
logger.info('开始处理新锻炼:', workout.id);
// 分析锻炼并发送通知
await analyzeWorkoutAndSendNotification(workout);
console.log('新锻炼处理完成:', workout.id);
logger.info('新锻炼处理完成:', workout.id);
} catch (error) {
console.error('处理新锻炼失败:', error);
logger.error('处理新锻炼失败:', error);
}
}

View File

@@ -1,4 +1,5 @@
import { getWorkoutTypeDisplayName, WorkoutData } from '@/utils/health';
import { logger } from '@/utils/logger';
import { getNotificationEnabled } from '@/utils/userPreferences';
import {
getWorkoutNotificationEnabled,
@@ -19,28 +20,28 @@ export async function analyzeWorkoutAndSendNotification(workout: WorkoutData): P
// 检查用户是否启用了通用通知
const notificationsEnabled = await getNotificationEnabled();
if (!notificationsEnabled) {
console.log('用户已禁用通知,跳过锻炼结束通知');
logger.info('用户已禁用通知,跳过锻炼结束通知');
return;
}
// 检查用户是否启用了锻炼通知
const workoutNotificationsEnabled = await getWorkoutNotificationEnabled();
if (!workoutNotificationsEnabled) {
console.log('用户已禁用锻炼通知,跳过锻炼结束通知');
logger.info('用户已禁用锻炼通知,跳过锻炼结束通知');
return;
}
// 检查时间限制(避免深夜打扰)
const timeAllowed = await isNotificationTimeAllowed();
if (!timeAllowed) {
console.log('当前时间不适合发送通知,跳过锻炼结束通知');
logger.info('当前时间不适合发送通知,跳过锻炼结束通知');
return;
}
// 检查特定锻炼类型是否启用了通知
const workoutTypeEnabled = await isWorkoutTypeEnabled(workout.workoutActivityTypeString || '');
if (!workoutTypeEnabled) {
console.log('该锻炼类型已禁用通知,跳过锻炼结束通知:', workout.workoutActivityTypeString);
logger.info('该锻炼类型已禁用通知,跳过锻炼结束通知:', workout.workoutActivityTypeString);
return;
}
@@ -66,7 +67,7 @@ export async function analyzeWorkoutAndSendNotification(workout: WorkoutData): P
priority: 'high'
});
console.log('锻炼结束通知已发送:', message.title);
logger.info('锻炼结束通知已发送:', message.title);
} catch (error) {
console.error('发送锻炼结束通知失败:', error);
}
@@ -221,7 +222,7 @@ function generateEncouragementMessage(
: undefined;
const messageConfig = getWorkoutMessage(workout.workoutActivityTypeString);
let title = '🎯 锻炼完成!';
let title = '锻炼完成!';
let body = '';
const data: Record<string, any> = {};