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:
@@ -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'),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -212,7 +212,7 @@ const WaterDetail: React.FC<WaterDetailProps> = () => {
|
||||
>
|
||||
<View style={styles.headerBlock}>
|
||||
<Text style={styles.pageTitle}>
|
||||
{selectedDate ? dayjs(selectedDate).format('MM月DD日') : 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',
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user