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

@@ -5,11 +5,13 @@ import { palette } from '@/constants/Colors';
import { ROUTES } from '@/constants/Routes';
import { getTabBarBottomPadding } from '@/constants/TabBar';
import { useMembershipModal } from '@/contexts/MembershipModalContext';
import { useVersionCheck } from '@/contexts/VersionCheckContext';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useAuthGuard } from '@/hooks/useAuthGuard';
import type { BadgeDto } from '@/services/badges';
import { reportBadgeShowcaseDisplayed } from '@/services/badges';
import { updateUser, type UserLanguage } from '@/services/users';
import { getCurrentAppVersion } from '@/services/version';
import { fetchAvailableBadges, selectBadgeCounts, selectBadgePreview, selectSortedBadges } from '@/store/badgesSlice';
import { selectActiveMembershipPlanName } from '@/store/membershipSlice';
import { DEFAULT_MEMBER_NAME, fetchActivityHistory, fetchMyProfile } from '@/store/userSlice';
@@ -66,6 +68,7 @@ export default function PersonalScreen() {
const [languageModalVisible, setLanguageModalVisible] = useState(false);
const [isSwitchingLanguage, setIsSwitchingLanguage] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const { checkForUpdate, isChecking: isCheckingVersion, updateInfo } = useVersionCheck();
const languageOptions = useMemo<LanguageOption[]>(() => ([
{
@@ -82,6 +85,16 @@ export default function PersonalScreen() {
const activeLanguageCode = getNormalizedLanguage(i18n.language);
const activeLanguageLabel = languageOptions.find((option) => option.code === activeLanguageCode)?.label || '';
const currentAppVersion = useMemo(() => getCurrentAppVersion(), []);
const versionRightText = useMemo(() => {
if (isCheckingVersion) {
return t('personal.versionCheck.checking');
}
if (updateInfo?.needsUpdate) {
return t('personal.versionCheck.updateBadge', { version: updateInfo.latestVersion });
}
return `v${currentAppVersion}`;
}, [currentAppVersion, isCheckingVersion, t, updateInfo?.latestVersion, updateInfo?.needsUpdate]);
const handleLanguageSelect = useCallback(async (language: AppLanguage) => {
setLanguageModalVisible(false);
@@ -656,6 +669,19 @@ export default function PersonalScreen() {
},
],
},
{
title: t('personal.versionCheck.sectionTitle'),
items: [
{
icon: 'cloud-download-outline' as React.ComponentProps<typeof Ionicons>['name'],
title: t('personal.versionCheck.menuTitle'),
onPress: () => {
void checkForUpdate({ manual: true });
},
rightText: versionRightText,
},
],
},
// 开发者section需要连续点击三次用户名激活
...(showDeveloperSection ? [{
title: t('personal.sections.developer'),

View File

@@ -32,6 +32,7 @@ import { AppState, AppStateStatus } from 'react-native';
import { DialogProvider } from '@/components/ui/DialogProvider';
import { MembershipModalProvider } from '@/contexts/MembershipModalContext';
import { ToastProvider } from '@/contexts/ToastContext';
import { VersionCheckProvider } from '@/contexts/VersionCheckContext';
import { useAuthGuard } from '@/hooks/useAuthGuard';
import { STORAGE_KEYS, setUnauthorizedHandler } from '@/services/api';
import { BackgroundTaskManager } from '@/services/backgroundTaskManagerV2';
@@ -524,30 +525,32 @@ export default function RootLayout() {
<Provider store={store}>
<Bootstrapper>
<ToastProvider>
<ThemeProvider value={DefaultTheme}>
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="onboarding" />
<Stack.Screen name="(tabs)" />
<Stack.Screen name="profile/edit" />
<Stack.Screen name="fasting/[planId]" options={{ headerShown: false }} />
<VersionCheckProvider>
<ThemeProvider value={DefaultTheme}>
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="onboarding" />
<Stack.Screen name="(tabs)" />
<Stack.Screen name="profile/edit" />
<Stack.Screen name="fasting/[planId]" options={{ headerShown: false }} />
<Stack.Screen name="auth/login" options={{ headerShown: false }} />
<Stack.Screen name="legal/user-agreement" options={{ headerShown: true, title: '用户协议' }} />
<Stack.Screen name="legal/privacy-policy" options={{ headerShown: true, title: '隐私政策' }} />
<Stack.Screen name="article/[id]" options={{ headerShown: false }} />
<Stack.Screen name="workout/notification-settings" options={{ headerShown: false }} />
<Stack.Screen
name="health-data-permissions"
options={{ headerShown: false }}
/>
<Stack.Screen name="medications/ai-camera" options={{ headerShown: false }} />
<Stack.Screen name="medications/ai-progress" options={{ headerShown: false }} />
<Stack.Screen name="badges/index" options={{ headerShown: false }} />
<Stack.Screen name="settings/tab-bar-config" options={{ headerShown: false }} />
<Stack.Screen name="+not-found" />
</Stack>
<StatusBar style="dark" />
</ThemeProvider>
<Stack.Screen name="auth/login" options={{ headerShown: false }} />
<Stack.Screen name="legal/user-agreement" options={{ headerShown: true, title: '用户协议' }} />
<Stack.Screen name="legal/privacy-policy" options={{ headerShown: true, title: '隐私政策' }} />
<Stack.Screen name="article/[id]" options={{ headerShown: false }} />
<Stack.Screen name="workout/notification-settings" options={{ headerShown: false }} />
<Stack.Screen
name="health-data-permissions"
options={{ headerShown: false }}
/>
<Stack.Screen name="medications/ai-camera" options={{ headerShown: false }} />
<Stack.Screen name="medications/ai-progress" options={{ headerShown: false }} />
<Stack.Screen name="badges/index" options={{ headerShown: false }} />
<Stack.Screen name="settings/tab-bar-config" options={{ headerShown: false }} />
<Stack.Screen name="+not-found" />
</Stack>
<StatusBar style="dark" />
</ThemeProvider>
</VersionCheckProvider>
</ToastProvider>
</Bootstrapper>
</Provider>

View File

@@ -35,6 +35,8 @@ import {
TouchableOpacity,
View
} from 'react-native';
import 'dayjs/locale/en';
import 'dayjs/locale/zh-cn';
// 配置 dayjs 插件
dayjs.extend(utc);
@@ -52,8 +54,8 @@ type WeekData = {
};
export default function FitnessRingsDetailScreen() {
const { t } = useI18n();
const safeAreaTop = useSafeAreaTop()
const { t, i18n } = useI18n();
const safeAreaTop = useSafeAreaTop();
const colorScheme = useColorScheme();
const [weekData, setWeekData] = useState<WeekData[]>([]);
const [selectedDate, setSelectedDate] = useState<Date>(new Date());
@@ -174,8 +176,9 @@ export default function FitnessRingsDetailScreen() {
// 格式化头部显示的日期
const formatHeaderDate = (date: Date) => {
const dayJsDate = dayjs(date).tz('Asia/Shanghai');
return `${dayJsDate.format('YYYY年MM月DD日')}`;
const dayJsDate = dayjs(date).tz('Asia/Shanghai').locale(i18n.language === 'zh' ? 'zh-cn' : 'en');
const dateFormat = t('fitnessRingsDetail.dateFormats.header', { defaultValue: 'YYYY年MM月DD日' });
return dayJsDate.format(dateFormat);
};
const renderWeekRingItem = (item: WeekData, index: number) => {
@@ -569,7 +572,7 @@ export default function FitnessRingsDetailScreen() {
display={Platform.OS === 'ios' ? 'inline' : 'calendar'}
minimumDate={new Date(2020, 0, 1)}
maximumDate={new Date()}
{...(Platform.OS === 'ios' ? { locale: 'zh-CN' } : {})}
{...(Platform.OS === 'ios' ? { locale: i18n.language === 'zh' ? 'zh-CN' : 'en-US' } : {})}
onChange={(event, date) => {
if (Platform.OS === 'ios') {
if (date) setPickerDate(date);
@@ -884,4 +887,4 @@ const styles = StyleSheet.create({
color: '#FFFFFF',
fontWeight: '700',
},
});
});

View File

@@ -212,7 +212,7 @@ const WaterDetail: React.FC<WaterDetailProps> = () => {
>
<View style={styles.headerBlock}>
<Text style={styles.pageTitle}>
{selectedDate ? dayjs(selectedDate).format('MMDD') : t('waterDetail.today')}
{selectedDate ? dayjs(selectedDate).format('MM-DD') : t('waterDetail.today')}
</Text>
<Text style={styles.pageSubtitle}>{t('waterDetail.waterRecord')}</Text>
</View>
@@ -333,14 +333,14 @@ const styles = StyleSheet.create({
fontFamily: 'AliRegular',
},
progressValue: {
fontSize: 32,
fontSize: 28,
fontWeight: '800',
color: '#4F5BD5',
fontFamily: 'AliBold',
lineHeight: 32,
},
progressGoalValue: {
fontSize: 24,
fontSize: 20,
fontWeight: '700',
color: '#1c1f3a',
fontFamily: 'AliBold',

View File

@@ -3,6 +3,7 @@ import { useFocusEffect } from '@react-navigation/native';
import dayjs from 'dayjs';
import isBetween from 'dayjs/plugin/isBetween';
import { LinearGradient } from 'expo-linear-gradient';
import { useLocalSearchParams } from 'expo-router';
import React, { useCallback, useMemo, useState } from 'react';
import {
ActivityIndicator,
@@ -274,6 +275,7 @@ function groupWorkouts(workouts: WorkoutData[]): WorkoutSection[] {
export default function WorkoutHistoryScreen() {
const { t } = useI18n();
const { workoutId: workoutIdParam } = useLocalSearchParams<{ workoutId?: string | string[] }>();
const [sections, setSections] = useState<WorkoutSection[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@@ -285,9 +287,20 @@ export default function WorkoutHistoryScreen() {
const [selectedIntensity, setSelectedIntensity] = useState<IntensityBadge | null>(null);
const [monthOccurrenceText, setMonthOccurrenceText] = useState<string | null>(null);
const [monthlyStats, setMonthlyStats] = useState<MonthlyStatsInfo | null>(null);
const [pendingWorkoutId, setPendingWorkoutId] = useState<string | null>(null);
const safeAreaTop = useSafeAreaTop();
React.useEffect(() => {
if (!workoutIdParam) {
return;
}
const idParam = Array.isArray(workoutIdParam) ? workoutIdParam[0] : workoutIdParam;
if (idParam) {
setPendingWorkoutId(idParam);
}
}, [workoutIdParam]);
const loadHistory = useCallback(async () => {
setIsLoading(true);
setError(null);
@@ -484,6 +497,22 @@ export default function WorkoutHistoryScreen() {
loadWorkoutDetail(workout);
}, [computeMonthlyOccurrenceText, loadWorkoutDetail]);
React.useEffect(() => {
if (!pendingWorkoutId || isLoading) {
return;
}
const allWorkouts = sections.flatMap((section) => section.data);
const targetWorkout = allWorkouts.find((workout) => workout.id === pendingWorkoutId);
if (targetWorkout) {
handleWorkoutPress(targetWorkout);
}
// 清理待处理状态,避免重复触发
setPendingWorkoutId(null);
}, [pendingWorkoutId, isLoading, sections, handleWorkoutPress]);
const handleRetryDetail = useCallback(() => {
if (selectedWorkout) {
loadWorkoutDetail(selectedWorkout);