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

@@ -2,7 +2,7 @@
"expo": {
"name": "Out Live",
"slug": "digital-pilates",
"version": "1.0.20",
"version": "1.1.3",
"orientation": "portrait",
"scheme": "digitalpilates",
"userInterfaceStyle": "light",

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);

View File

@@ -0,0 +1,343 @@
import type { VersionInfo } from '@/services/version';
import { Ionicons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient';
import React, { useMemo } from 'react';
import {
Modal,
Pressable,
StyleSheet,
Text,
TouchableOpacity,
View,
} from 'react-native';
type VersionUpdateModalProps = {
visible: boolean;
info: VersionInfo | null;
currentVersion: string;
onClose: () => void;
onUpdate: () => void;
strings: {
title: string;
tag: string;
currentVersionLabel: string;
latestVersionLabel: string;
updatesTitle: string;
fallbackNote: string;
remindLater: string;
updateCta: string;
};
};
export function VersionUpdateModal({
visible,
info,
currentVersion,
onClose,
onUpdate,
strings,
}: VersionUpdateModalProps) {
const notes = useMemo(() => {
if (!info) return [];
if (info.releaseNotes && info.releaseNotes.trim().length > 0) {
return info.releaseNotes
.split(/\r?\n+/)
.map((line) => line.trim())
.filter(Boolean);
}
if (info.updateMessage && info.updateMessage.trim().length > 0) {
return [info.updateMessage.trim()];
}
return [];
}, [info]);
if (!info) return null;
return (
<Modal
animationType="fade"
transparent
visible={visible}
onRequestClose={onClose}
>
<View style={styles.overlay}>
<Pressable style={StyleSheet.absoluteFill} onPress={onClose} />
<View style={styles.cardShadow}>
<LinearGradient
colors={['#0F1B61', '#0F274A', '#0A1A3A']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.card}
>
<LinearGradient
colors={['rgba(255,255,255,0.18)', 'rgba(255,255,255,0.03)']}
style={styles.glowOrb}
/>
<LinearGradient
colors={['rgba(255,255,255,0.08)', 'transparent']}
style={styles.ribbon}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
/>
<View style={styles.headerRow}>
<View style={styles.tag}>
<Ionicons name="sparkles" size={14} color="#0F1B61" />
<Text style={styles.tagText}>{strings.tag}</Text>
</View>
<TouchableOpacity onPress={onClose} style={styles.closeButton}>
<Ionicons name="close" size={18} color="#E5E7EB" />
</TouchableOpacity>
</View>
<View style={styles.titleBlock}>
<Text style={styles.title}>{strings.title}</Text>
<Text style={styles.subtitle}>
{info.latestVersion ? `v${info.latestVersion}` : ''}
</Text>
</View>
<View style={styles.metaRow}>
<View style={styles.metaChip}>
<Ionicons name="time-outline" size={14} color="#C7D2FE" />
<Text style={styles.metaText}>
{strings.currentVersionLabel} v{currentVersion}
</Text>
</View>
<View style={styles.metaChip}>
<Ionicons name="arrow-up-circle-outline" size={14} color="#C7D2FE" />
<Text style={styles.metaText}>
{strings.latestVersionLabel} v{info.latestVersion}
</Text>
</View>
</View>
<View style={styles.noteCard}>
<Text style={styles.noteTitle}>{strings.updatesTitle}</Text>
{notes.length > 0 ? (
notes.map((line, idx) => (
<View key={`${idx}-${line}`} style={styles.noteItem}>
<View style={styles.bullet}>
<Ionicons name="ellipse" size={6} color="#6EE7B7" />
</View>
<Text style={styles.noteText}>{line}</Text>
</View>
))
) : (
<Text style={styles.noteText}>{strings.fallbackNote}</Text>
)}
</View>
<View style={styles.actions}>
<TouchableOpacity
activeOpacity={0.85}
onPress={onClose}
style={styles.secondaryButton}
>
<Text style={styles.secondaryText}>{strings.remindLater}</Text>
</TouchableOpacity>
<TouchableOpacity
activeOpacity={0.9}
onPress={onUpdate}
style={styles.primaryButtonShadow}
>
<LinearGradient
colors={['#6EE7B7', '#3B82F6']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.primaryButton}
>
<Ionicons name="cloud-download-outline" size={18} color="#0B1236" />
<Text style={styles.primaryText}>{strings.updateCta}</Text>
</LinearGradient>
</TouchableOpacity>
</View>
</LinearGradient>
</View>
</View>
</Modal>
);
}
const styles = StyleSheet.create({
overlay: {
flex: 1,
backgroundColor: 'rgba(7, 11, 34, 0.65)',
alignItems: 'center',
justifyContent: 'center',
padding: 20,
},
cardShadow: {
width: '100%',
maxWidth: 420,
shadowColor: '#0B1236',
shadowOpacity: 0.35,
shadowOffset: { width: 0, height: 16 },
shadowRadius: 30,
elevation: 8,
},
card: {
borderRadius: 24,
padding: 20,
overflow: 'hidden',
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.08)',
},
glowOrb: {
position: 'absolute',
width: 220,
height: 220,
borderRadius: 110,
right: -60,
top: -80,
opacity: 0.8,
},
ribbon: {
position: 'absolute',
left: -120,
bottom: -120,
width: 260,
height: 260,
transform: [{ rotate: '-8deg' }],
opacity: 0.6,
},
headerRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
tag: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 10,
paddingVertical: 6,
borderRadius: 14,
backgroundColor: '#A5B4FC',
},
tagText: {
color: '#0F1B61',
fontWeight: '700',
marginLeft: 6,
fontSize: 12,
letterSpacing: 0.3,
},
closeButton: {
width: 32,
height: 32,
borderRadius: 16,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(255,255,255,0.06)',
},
titleBlock: {
marginTop: 14,
marginBottom: 8,
},
title: {
fontSize: 24,
fontWeight: '800',
color: '#F9FAFB',
letterSpacing: 0.2,
},
subtitle: {
color: '#C7D2FE',
marginTop: 6,
fontSize: 15,
},
metaRow: {
flexDirection: 'row',
marginTop: 10,
gap: 8,
flexWrap: 'wrap',
},
metaChip: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: 'rgba(255,255,255,0.08)',
borderRadius: 12,
paddingHorizontal: 10,
paddingVertical: 8,
},
metaText: {
color: '#E5E7EB',
marginLeft: 6,
fontSize: 12,
},
noteCard: {
marginTop: 16,
borderRadius: 16,
padding: 14,
backgroundColor: 'rgba(255,255,255,0.06)',
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.04)',
},
noteTitle: {
color: '#F9FAFB',
fontWeight: '700',
fontSize: 15,
marginBottom: 8,
},
noteItem: {
flexDirection: 'row',
alignItems: 'flex-start',
marginTop: 8,
},
bullet: {
width: 18,
alignItems: 'center',
marginTop: 6,
},
noteText: {
flex: 1,
color: '#E5E7EB',
fontSize: 14,
lineHeight: 20,
},
actions: {
marginTop: 18,
flexDirection: 'row',
gap: 10,
},
secondaryButton: {
flex: 1,
height: 48,
borderRadius: 14,
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.16)',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(255,255,255,0.08)',
},
secondaryText: {
color: '#E5E7EB',
fontWeight: '600',
fontSize: 14,
},
primaryButtonShadow: {
flex: 1,
height: 48,
borderRadius: 14,
overflow: 'hidden',
shadowColor: '#1E40AF',
shadowOpacity: 0.4,
shadowOffset: { width: 0, height: 12 },
shadowRadius: 14,
elevation: 6,
},
primaryButton: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
},
primaryText: {
color: '#0B1236',
fontWeight: '800',
fontSize: 15,
},
});
export default VersionUpdateModal;

View File

@@ -1,10 +1,11 @@
import { MaterialCommunityIcons } from '@expo/vector-icons';
import dayjs from 'dayjs';
import 'dayjs/locale/en';
import 'dayjs/locale/zh-cn';
import { LinearGradient } from 'expo-linear-gradient';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import React, { useEffect, useMemo, useState } from 'react';
import {
ActivityIndicator,
Animated,
Dimensions,
Modal,
ScrollView,
@@ -60,63 +61,49 @@ export function WorkoutDetailModal({
onRetry,
errorMessage,
}: WorkoutDetailModalProps) {
const { t } = useI18n();
const animation = useRef(new Animated.Value(visible ? 1 : 0)).current;
const { t, i18n } = useI18n();
const [isMounted, setIsMounted] = useState(visible);
const [shouldRenderChart, setShouldRenderChart] = useState(visible);
const [showIntensityInfo, setShowIntensityInfo] = useState(false);
const locale = useMemo(() => (i18n.language?.startsWith('en') ? 'en' : 'zh-cn'), [i18n.language]);
useEffect(() => {
if (visible) {
setIsMounted(true);
Animated.timing(animation, {
toValue: 1,
duration: 280,
useNativeDriver: true,
}).start();
setShouldRenderChart(true);
} else {
Animated.timing(animation, {
toValue: 0,
duration: 240,
useNativeDriver: true,
}).start(({ finished }) => {
if (finished) {
setIsMounted(false);
}
});
setShouldRenderChart(false);
setIsMounted(false);
setShowIntensityInfo(false);
}
}, [visible, animation]);
const translateY = animation.interpolate({
inputRange: [0, 1],
outputRange: [SHEET_MAX_HEIGHT, 0],
});
const backdropOpacity = animation.interpolate({
inputRange: [0, 1],
outputRange: [0, 1],
});
}, [visible]);
const activityName = workout
? getWorkoutTypeDisplayName(workout.workoutActivityType as WorkoutActivityType)
: '';
const chartWidth = useMemo(
() => Math.max(Dimensions.get('window').width - 96, 240),
[]
);
const dateInfo = useMemo(() => {
if (!workout) {
return { title: '', subtitle: '' };
}
const date = dayjs(workout.startDate || workout.endDate);
const date = dayjs(workout.startDate || workout.endDate).locale(locale);
if (!date.isValid()) {
return { title: '', subtitle: '' };
}
return {
title: date.format('M月D日'),
subtitle: date.format('YYYY年M月D日 dddd HH:mm'),
title: locale === 'en' ? date.format('MMM D') : date.format('M月D日'),
subtitle: locale === 'en'
? date.format('dddd, MMM D, YYYY HH:mm')
: date.format('YYYY年M月D日 dddd HH:mm'),
};
}, [workout]);
}, [locale, workout]);
const heartRateChart = useMemo(() => {
if (!metrics?.heartRateSeries?.length) {
@@ -158,23 +145,16 @@ export function WorkoutDetailModal({
return (
<Modal
transparent
visible={isMounted}
animationType="none"
visible={visible}
animationType='slide'
onRequestClose={onClose}
>
<View style={styles.modalContainer}>
<TouchableWithoutFeedback onPress={handleBackdropPress}>
<Animated.View style={[styles.backdrop, { opacity: backdropOpacity }]} />
<View style={styles.backdrop} />
</TouchableWithoutFeedback>
<Animated.View
style={[
styles.sheetContainer,
{
transform: [{ translateY }],
},
]}
>
<View style={styles.sheetContainer}>
<LinearGradient
colors={['#FFFFFF', '#F3F5FF']}
start={{ x: 0, y: 0 }}
@@ -208,7 +188,7 @@ export function WorkoutDetailModal({
showsVerticalScrollIndicator={false}
contentContainerStyle={styles.contentContainer}
>
<View style={styles.summaryCard}>
<View style={[styles.summaryCard, loading ? styles.summaryCardLoading : null]}>
<View style={styles.summaryHeader}>
<Text style={styles.activityName}>{activityName}</Text>
{intensityBadge ? (
@@ -225,7 +205,9 @@ export function WorkoutDetailModal({
) : null}
</View>
<Text style={styles.summarySubtitle}>
{dayjs(workout?.startDate || workout?.endDate).format('YYYY年M月D日 dddd HH:mm')}
{dayjs(workout?.startDate || workout?.endDate)
.locale(locale)
.format(locale === 'en' ? 'dddd, MMM D, YYYY HH:mm' : 'YYYY年M月D日 dddd HH:mm')}
</Text>
{loading ? (
@@ -288,7 +270,7 @@ export function WorkoutDetailModal({
)}
</View>
<View style={styles.section}>
<View style={[styles.section, loading ? styles.sectionHeartRateLoading : null]}>
<View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}>{t('workoutDetail.sections.heartRateRange')}</Text>
</View>
@@ -323,41 +305,49 @@ export function WorkoutDetailModal({
{heartRateChart ? (
LineChart ? (
<View style={styles.chartWrapper}>
{/* @ts-ignore - react-native-chart-kit types are outdated */}
<LineChart
data={{
labels: heartRateChart.labels,
datasets: [
{
data: heartRateChart.data,
color: () => '#5C55FF',
strokeWidth: 2,
{shouldRenderChart ? (
/* @ts-ignore - react-native-chart-kit types are outdated */
<LineChart
data={{
labels: heartRateChart.labels,
datasets: [
{
data: heartRateChart.data,
color: () => '#5C55FF',
strokeWidth: 2,
},
],
}}
width={chartWidth}
height={220}
fromZero={false}
yAxisSuffix={t('workoutDetail.sections.heartRateUnit')}
withInnerLines={false}
bezier
paddingRight={48}
chartConfig={{
backgroundColor: '#FFFFFF',
backgroundGradientFrom: '#FFFFFF',
backgroundGradientTo: '#FFFFFF',
decimalPlaces: 0,
color: (opacity = 1) => `rgba(92, 85, 255, ${opacity})`,
labelColor: (opacity = 1) => `rgba(98, 105, 138, ${opacity})`,
propsForDots: {
r: '3',
strokeWidth: '2',
stroke: '#FFFFFF',
},
],
}}
width={Dimensions.get('window').width - 72}
height={220}
fromZero={false}
yAxisSuffix={t('workoutDetail.sections.heartRateUnit')}
withInnerLines={false}
bezier
chartConfig={{
backgroundColor: '#FFFFFF',
backgroundGradientFrom: '#FFFFFF',
backgroundGradientTo: '#FFFFFF',
decimalPlaces: 0,
color: (opacity = 1) => `rgba(92, 85, 255, ${opacity})`,
labelColor: (opacity = 1) => `rgba(98, 105, 138, ${opacity})`,
propsForDots: {
r: '3',
strokeWidth: '2',
stroke: '#FFFFFF',
},
fillShadowGradientFromOpacity: 0.1,
fillShadowGradientToOpacity: 0.02,
}}
style={styles.chartStyle}
/>
fillShadowGradientFromOpacity: 0.1,
fillShadowGradientToOpacity: 0.02,
}}
style={styles.chartStyle}
/>
) : (
<View style={[styles.chartLoading, { width: chartWidth }]}>
<ActivityIndicator color="#5C55FF" />
<Text style={styles.chartLoadingText}>{t('workoutDetail.loading')}</Text>
</View>
)}
</View>
) : (
<View style={styles.chartEmpty}>
@@ -381,7 +371,7 @@ export function WorkoutDetailModal({
)}
</View>
<View style={styles.section}>
<View style={[styles.section, loading ? styles.sectionZonesLoading : null]}>
<View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}>{t('workoutDetail.sections.heartRateZones')}</Text>
</View>
@@ -391,7 +381,7 @@ export function WorkoutDetailModal({
<ActivityIndicator color="#5C55FF" />
</View>
) : metrics ? (
metrics.heartRateZones.map(renderHeartRateZone)
metrics.heartRateZones.map((zone) => renderHeartRateZone(zone, t))
) : (
<Text style={styles.errorTextSmall}>{t('workoutDetail.errors.noZoneStats')}</Text>
)}
@@ -399,7 +389,7 @@ export function WorkoutDetailModal({
<View style={styles.homeIndicatorSpacer} />
</ScrollView>
</Animated.View>
</View>
{showIntensityInfo ? (
<Modal
transparent
@@ -513,6 +503,7 @@ function trimHeartRateSeries(series: WorkoutDetailMetrics['heartRateSeries']) {
// 遍历所有点,选择重要点
let minDistance = Math.max(1, Math.floor(n / HEART_RATE_CHART_MAX_POINTS));
let lastSelectedIndex = 0;
for (let i = 1; i < n - 1; i++) {
const shouldKeep =
@@ -525,11 +516,9 @@ function trimHeartRateSeries(series: WorkoutDetailMetrics['heartRateSeries']) {
if (shouldKeep) {
// 检查与上一个选中点的距离,避免过于密集
const lastSelectedIndex = result.length > 0 ?
series.findIndex(p => p.timestamp === result[result.length - 1].timestamp) : 0;
if (i - lastSelectedIndex >= minDistance || isLocalExtremum(i)) {
result.push(series[i]);
lastSelectedIndex = i;
}
}
}
@@ -555,7 +544,21 @@ function trimHeartRateSeries(series: WorkoutDetailMetrics['heartRateSeries']) {
return result;
}
function renderHeartRateZone(zone: HeartRateZoneStat) {
function renderHeartRateZone(
zone: HeartRateZoneStat,
t: (key: string, options?: Record<string, any>) => string
) {
const label = t(`workoutDetail.zones.labels.${zone.key}`, {
defaultValue: zone.label,
});
const range = t(`workoutDetail.zones.ranges.${zone.key}`, {
defaultValue: zone.rangeText,
});
const meta = t('workoutDetail.zones.summary', {
minutes: zone.durationMinutes,
range,
});
return (
<View key={zone.key} style={styles.zoneRow}>
<View style={[styles.zoneBar, { backgroundColor: `${zone.color}33` }]}>
@@ -570,10 +573,8 @@ function renderHeartRateZone(zone: HeartRateZoneStat) {
/>
</View>
<View style={styles.zoneInfo}>
<Text style={styles.zoneLabel}>{zone.label}</Text>
<Text style={styles.zoneMeta}>
{zone.durationMinutes} · {zone.rangeText}
</Text>
<Text style={styles.zoneLabel}>{label}</Text>
<Text style={styles.zoneMeta}>{meta}</Text>
</View>
</View>
);
@@ -668,20 +669,28 @@ const styles = StyleSheet.create({
shadowRadius: 22,
elevation: 8,
},
summaryCardLoading: {
minHeight: 240,
},
summaryHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
alignItems: 'flex-start',
flexWrap: 'wrap',
gap: 10,
},
activityName: {
fontSize: 24,
fontWeight: '700',
color: '#1E2148',
flex: 1,
flexShrink: 1,
lineHeight: 30,
},
intensityPill: {
paddingHorizontal: 12,
paddingVertical: 4,
borderRadius: 999,
alignSelf: 'flex-start',
},
intensityPillText: {
fontSize: 12,
@@ -768,6 +777,12 @@ const styles = StyleSheet.create({
shadowRadius: 20,
elevation: 4,
},
sectionHeartRateLoading: {
minHeight: 360,
},
sectionZonesLoading: {
minHeight: 200,
},
sectionHeader: {
flexDirection: 'row',
alignItems: 'center',
@@ -811,11 +826,22 @@ const styles = StyleSheet.create({
color: '#1E2148',
},
chartWrapper: {
alignItems: 'flex-start',
overflow: 'visible',
},
chartLoading: {
height: 220,
alignItems: 'center',
justifyContent: 'center',
},
chartLoadingText: {
marginTop: 8,
fontSize: 12,
color: '#7E86A7',
},
chartStyle: {
marginLeft: -10,
marginRight: -10,
marginLeft: 0,
marginRight: 0,
},
chartEmpty: {
paddingVertical: 32,
@@ -949,4 +975,3 @@ const styles = StyleSheet.create({
height: 40,
},
});

View File

@@ -0,0 +1,135 @@
import VersionUpdateModal from '@/components/VersionUpdateModal';
import { useToast } from '@/contexts/ToastContext';
import { fetchVersionInfo, getCurrentAppVersion, type VersionInfo } from '@/services/version';
import { log } from '@/utils/logger';
import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
import { Linking } from 'react-native';
import { useTranslation } from 'react-i18next';
type VersionCheckContextValue = {
isChecking: boolean;
updateInfo: VersionInfo | null;
checkForUpdate: (options?: { manual?: boolean }) => Promise<VersionInfo | null>;
openStore: () => Promise<void>;
};
const VersionCheckContext = createContext<VersionCheckContextValue | undefined>(undefined);
export function VersionCheckProvider({ children }: { children: React.ReactNode }) {
const { showSuccess, showError } = useToast();
const { t } = useTranslation();
const [isChecking, setIsChecking] = useState(false);
const [updateInfo, setUpdateInfo] = useState<VersionInfo | null>(null);
const [modalVisible, setModalVisible] = useState(false);
const hasAutoCheckedRef = useRef(false);
const currentVersion = useMemo(() => getCurrentAppVersion(), []);
const openStore = useCallback(async () => {
if (!updateInfo?.appStoreUrl) {
showError(t('personal.versionCheck.missingUrl'));
return;
}
try {
const supported = await Linking.canOpenURL(updateInfo.appStoreUrl);
if (!supported) {
throw new Error('URL not supported');
}
await Linking.openURL(updateInfo.appStoreUrl);
log.info('version-update-open-store', { url: updateInfo.appStoreUrl });
} catch (error) {
log.error('version-update-open-store-failed', error);
showError(t('personal.versionCheck.openStoreFailed'));
}
}, [showError, t, updateInfo]);
const checkForUpdate = useCallback(
async ({ manual = false }: { manual?: boolean } = {}) => {
if (isChecking) {
if (manual) {
showSuccess(t('personal.versionCheck.checking'));
}
return updateInfo;
}
setIsChecking(true);
try {
const info = await fetchVersionInfo();
setUpdateInfo(info);
setModalVisible(Boolean(info?.needsUpdate));
if (info?.needsUpdate && manual) {
showSuccess(
t('personal.versionCheck.updateFound', {
version: info.latestVersion,
})
);
} else if (!info?.needsUpdate && manual) {
showSuccess(t('personal.versionCheck.upToDate'));
}
return info;
} catch (error) {
log.error('version-check-failed', error);
if (manual) {
showError(t('personal.versionCheck.failed'));
}
return null;
} finally {
setIsChecking(false);
}
},
[isChecking, showError, showSuccess, t, updateInfo]
);
useEffect(() => {
if (hasAutoCheckedRef.current) return;
hasAutoCheckedRef.current = true;
checkForUpdate({ manual: false }).catch((error) => {
log.error('auto-version-check-failed', error);
});
}, [checkForUpdate]);
const strings = useMemo(
() => ({
title: t('personal.versionCheck.modalTitle'),
tag: t('personal.versionCheck.modalTag'),
currentVersionLabel: t('personal.versionCheck.currentVersion'),
latestVersionLabel: t('personal.versionCheck.latestVersion'),
updatesTitle: t('personal.versionCheck.releaseNotesTitle'),
fallbackNote: t('personal.versionCheck.fallbackNotes'),
remindLater: t('personal.versionCheck.later'),
updateCta: t('personal.versionCheck.updateNow'),
}),
[t]
);
return (
<VersionCheckContext.Provider
value={{
isChecking,
updateInfo,
checkForUpdate,
openStore,
}}
>
{children}
<VersionUpdateModal
visible={modalVisible && Boolean(updateInfo?.needsUpdate)}
info={updateInfo}
currentVersion={currentVersion}
onClose={() => setModalVisible(false)}
onUpdate={openStore}
strings={strings}
/>
</VersionCheckContext.Provider>
);
}
export function useVersionCheck(): VersionCheckContextValue {
const context = useContext(VersionCheckContext);
if (!context) {
throw new Error('useVersionCheck must be used within VersionCheckProvider');
}
return context;
}

View File

@@ -338,6 +338,9 @@ export const fitnessRingsDetail = {
saturday: 'Sat',
sunday: 'Sun',
},
dateFormats: {
header: 'MMM D, YYYY',
},
cards: {
activeCalories: {
title: 'Active Calories',
@@ -474,6 +477,159 @@ export const basalMetabolismDetail = {
},
};
export const workoutTypes = {
americanfootball: 'American Football',
archery: 'Archery',
australianfootball: 'Australian Football',
badminton: 'Badminton',
baseball: 'Baseball',
basketball: 'Basketball',
bowling: 'Bowling',
boxing: 'Boxing',
climbing: 'Climbing',
cricket: 'Cricket',
crosstraining: 'Cross Training',
curling: 'Curling',
cycling: 'Cycling',
dance: 'Dance',
danceinspiredtraining: 'Dance Inspired Training',
elliptical: 'Elliptical',
equestriansports: 'Equestrian Sports',
fencing: 'Fencing',
fishing: 'Fishing',
functionalstrengthtraining: 'Functional Strength Training',
golf: 'Golf',
gymnastics: 'Gymnastics',
handball: 'Handball',
hiking: 'Hiking',
hockey: 'Hockey',
hunting: 'Hunting',
lacrosse: 'Lacrosse',
martialarts: 'Martial Arts',
mindandbody: 'Mind and Body',
mixedmetaboliccardiotraining: 'Mixed Metabolic Cardio Training',
paddlesports: 'Paddle Sports',
play: 'Play',
preparationandrecovery: 'Preparation & Recovery',
racquetball: 'Racquetball',
rowing: 'Rowing',
rugby: 'Rugby',
running: 'Running',
sailing: 'Sailing',
skatingsports: 'Skating Sports',
snowsports: 'Snow Sports',
soccer: 'Soccer',
softball: 'Softball',
squash: 'Squash',
stairclimbing: 'Stair Climbing',
surfingsports: 'Surfing Sports',
swimming: 'Swimming',
tabletennis: 'Table Tennis',
tennis: 'Tennis',
trackandfield: 'Track and Field',
traditionalstrengthtraining: 'Traditional Strength Training',
volleyball: 'Volleyball',
walking: 'Walking',
waterfitness: 'Water Fitness',
waterpolo: 'Water Polo',
watersports: 'Water Sports',
wrestling: 'Wrestling',
yoga: 'Yoga',
barre: 'Barre',
coretraining: 'Core Training',
crosscountryskiing: 'Cross-Country Skiing',
downhillskiing: 'Downhill Skiing',
flexibility: 'Flexibility',
highintensityintervaltraining: 'High-Intensity Interval Training',
jumprope: 'Jump Rope',
kickboxing: 'Kickboxing',
pilates: 'Pilates',
snowboarding: 'Snowboarding',
stairs: 'Stairs',
steptraining: 'Step Training',
wheelchairwalkpace: 'Wheelchair Walk Pace',
wheelchairrunpace: 'Wheelchair Run Pace',
taichi: 'Tai Chi',
mixedcardio: 'Mixed Cardio',
handcycling: 'Hand Cycling',
discsports: 'Disc Sports',
fitnessgaming: 'Fitness Gaming',
cardiodance: 'Cardio Dance',
socialdance: 'Social Dance',
pickleball: 'Pickleball',
cooldown: 'Cooldown',
swimbikerun: 'Swim Bike Run',
transition: 'Transition',
underwaterdiving: 'Underwater Diving',
other: 'Other',
};
export const workoutDetail = {
loading: 'Loading workout details...',
retry: 'Retry',
errors: {
loadFailed: 'Failed to load workout details',
noHeartRateData: 'No heart rate data available',
noZoneStats: 'No heart rate zone data',
},
metrics: {
duration: 'Duration',
calories: 'Calories',
caloriesUnit: 'kcal',
intensity: 'Intensity',
averageHeartRate: 'Average Heart Rate',
heartRateUnit: 'bpm',
},
sections: {
heartRateRange: 'Heart Rate Range',
averageHeartRate: 'Average',
maximumHeartRate: 'Maximum',
minimumHeartRate: 'Minimum',
heartRateUnit: 'bpm',
heartRateZones: 'Heart Rate Zones',
},
chart: {
unavailable: 'Chart unavailable',
noData: 'No heart rate chart data yet',
},
intensityInfo: {
title: 'About workout intensity (METs)',
description1: 'METs (metabolic equivalent) reflect energy cost; resting equals 1 MET.',
description2: '3-6 METs is moderate intensity, above 6 METs is high intensity.',
description3: 'Higher values mean more energy burned per minute—adjust to your fitness level.',
description4: 'Warm up and cool down before and after sustained high-intensity sessions.',
formula: {
title: 'Formula',
value: 'METs = Exercise VO₂ ÷ Resting VO₂',
},
legend: {
low: '2-3 METs',
lowLabel: 'Low intensity',
medium: '3-6 METs',
mediumLabel: 'Moderate',
high: '>6 METs',
highLabel: 'High intensity',
},
},
zones: {
summary: '{{minutes}} min · {{range}}',
labels: {
warmup: 'Warm-up',
fatburn: 'Fat burn',
aerobic: 'Aerobic',
anaerobic: 'Anaerobic',
max: 'Max effort',
},
ranges: {
warmup: '<100 bpm',
fatburn: '100-119 bpm',
aerobic: '120-149 bpm',
anaerobic: '150-169 bpm',
max: '≥170 bpm',
},
},
};
export const workoutHistory = {
title: 'Workout Summary',
loading: 'Loading workout records...',
@@ -504,4 +660,4 @@ export const workoutHistory = {
subtitle: 'Complete a workout to view detailed history here',
},
monthOccurrence: 'This is your {{index}} {{activity}} in {{month}}.',
};
};

View File

@@ -37,6 +37,25 @@ export const personal = {
medicalSources: 'Medical Advice Sources',
customization: 'Customization',
},
versionCheck: {
sectionTitle: 'Updates',
menuTitle: 'Check for updates',
checking: 'Checking for updates...',
upToDate: 'You are on the latest version',
updateBadge: 'v{{version}} available',
failed: 'Update check failed, please try again later',
updateFound: 'New version v{{version}}',
modalTitle: 'Update available',
modalTag: 'New',
currentVersion: 'Current',
latestVersion: 'Latest',
releaseNotesTitle: "What's new",
fallbackNotes: 'Performance improvements and fixes to keep things smooth.',
later: 'Remind me later',
updateNow: 'Update now',
missingUrl: 'Store link is not ready yet',
openStoreFailed: 'Could not open the store, please try again',
},
menu: {
notificationSettings: 'Notification settings',
developerOptions: 'Developer options',
@@ -405,4 +424,4 @@ export const notificationSettings = {
body: 'You will receive mood record reminders in the evening',
},
},
};
};

View File

@@ -338,6 +338,9 @@ export const fitnessRingsDetail = {
saturday: '周六',
sunday: '周日',
},
dateFormats: {
header: 'YYYY年MM月DD日',
},
cards: {
activeCalories: {
title: '活动热量',
@@ -474,6 +477,159 @@ export const basalMetabolismDetail = {
},
};
export const workoutTypes = {
americanfootball: '美式橄榄球',
archery: '射箭',
australianfootball: '澳式橄榄球',
badminton: '羽毛球',
baseball: '棒球',
basketball: '篮球',
bowling: '保龄球',
boxing: '拳击',
climbing: '攀岩',
cricket: '板球',
crosstraining: '交叉训练',
curling: '冰壶',
cycling: '骑行',
dance: '舞蹈',
danceinspiredtraining: '舞蹈灵感训练',
elliptical: '椭圆机',
equestriansports: '马术',
fencing: '击剑',
fishing: '钓鱼',
functionalstrengthtraining: '功能性力量训练',
golf: '高尔夫',
gymnastics: '体操',
handball: '手球',
hiking: '徒步',
hockey: '曲棍球',
hunting: '打猎',
lacrosse: '长曲棍球',
martialarts: '武术',
mindandbody: '身心训练',
mixedmetaboliccardiotraining: '混合代谢有氧训练',
paddlesports: '划桨运动',
play: '玩乐活动',
preparationandrecovery: '热身与恢复',
racquetball: '回力球',
rowing: '划船',
rugby: '橄榄球',
running: '跑步',
sailing: '帆船',
skatingsports: '滑冰运动',
snowsports: '冰雪运动',
soccer: '足球',
softball: '垒球',
squash: '壁球',
stairclimbing: '爬楼梯',
surfingsports: '冲浪',
swimming: '游泳',
tabletennis: '乒乓球',
tennis: '网球',
trackandfield: '田径',
traditionalstrengthtraining: '力量训练',
volleyball: '排球',
walking: '步行',
waterfitness: '水中健身',
waterpolo: '水球',
watersports: '水上运动',
wrestling: '摔跤',
yoga: '瑜伽',
barre: '芭蕾塑形',
coretraining: '核心训练',
crosscountryskiing: '越野滑雪',
downhillskiing: '高山滑雪',
flexibility: '柔韧训练',
highintensityintervaltraining: '高强度间歇训练',
jumprope: '跳绳',
kickboxing: '踢拳',
pilates: '普拉提',
snowboarding: '单板滑雪',
stairs: '楼梯',
steptraining: '踏步训练',
wheelchairwalkpace: '轮椅慢速',
wheelchairrunpace: '轮椅快速',
taichi: '太极',
mixedcardio: '混合有氧',
handcycling: '手摇车',
discsports: '飞盘',
fitnessgaming: '健身游戏',
cardiodance: '有氧舞蹈',
socialdance: '社交舞',
pickleball: '匹克球',
cooldown: '整理放松',
swimbikerun: '游泳+骑行+跑步',
transition: '过渡',
underwaterdiving: '潜水',
other: '其他',
};
export const workoutDetail = {
loading: '正在加载锻炼详情...',
retry: '重试',
errors: {
loadFailed: '加载锻炼详情失败',
noHeartRateData: '暂无心率数据',
noZoneStats: '暂无心率分区数据',
},
metrics: {
duration: '时长',
calories: '消耗',
caloriesUnit: '千卡',
intensity: '强度',
averageHeartRate: '平均心率',
heartRateUnit: '次/分',
},
sections: {
heartRateRange: '心率范围',
averageHeartRate: '平均',
maximumHeartRate: '最高',
minimumHeartRate: '最低',
heartRateUnit: '次/分',
heartRateZones: '心率区间',
},
chart: {
unavailable: '暂无法展示图表',
noData: '暂无心率曲线数据',
},
intensityInfo: {
title: '关于运动强度METs',
description1: 'METs代谢当量反映运动能量消耗静息时为 1 MET。',
description2: '3-6 METs 属于中等强度,高于 6 METs 为高强度。',
description3: '数值越高每分钟消耗越多,请结合个人体能选择强度。',
description4: '长时间高强度训练前后,请确保充分热身与放松。',
formula: {
title: '计算方式',
value: 'METs = 运动摄氧量 ÷ 静息摄氧量',
},
legend: {
low: '2-3 METs',
lowLabel: '低强度',
medium: '3-6 METs',
mediumLabel: '中等强度',
high: '>6 METs',
highLabel: '高强度',
},
},
zones: {
summary: '{{minutes}} 分钟 · {{range}}',
labels: {
warmup: '热身放松',
fatburn: '燃脂',
aerobic: '有氧运动',
anaerobic: '无氧冲刺',
max: '身体极限',
},
ranges: {
warmup: '<100次/分',
fatburn: '100-119次/分',
aerobic: '120-149次/分',
anaerobic: '150-169次/分',
max: '≥170次/分',
},
},
};
export const workoutHistory = {
title: '锻炼总结',
loading: '正在加载锻炼记录...',
@@ -504,4 +660,4 @@ export const workoutHistory = {
subtitle: '完成一次锻炼后即可在此查看详细历史',
},
monthOccurrence: '这是你{{month}}的第 {{index}} 次{{activity}}。',
};
};

View File

@@ -37,6 +37,25 @@ export const personal = {
medicalSources: '医学建议来源',
customization: '个性化',
},
versionCheck: {
sectionTitle: '版本与更新',
menuTitle: '检查更新',
checking: '正在检查更新...',
upToDate: '当前已是最新版本',
updateBadge: 'v{{version}} 可更新',
failed: '检查更新失败,请稍后再试',
updateFound: '发现新版本 v{{version}}',
modalTitle: '发现新版本',
modalTag: 'New',
currentVersion: '当前',
latestVersion: '最新',
releaseNotesTitle: '本次更新',
fallbackNotes: '体验优化与问题修复,保持更新获得更好体验。',
later: '稍后提醒',
updateNow: '立即更新',
missingUrl: '暂未获取到商店地址',
openStoreFailed: '跳转应用商店失败,请稍后再试',
},
menu: {
notificationSettings: '通知设置',
developerOptions: '开发者选项',
@@ -405,4 +424,4 @@ export const notificationSettings = {
body: '您将在晚间收到心情记录提醒',
},
},
};
};

View File

@@ -27,7 +27,7 @@
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>1.1.2</string>
<string>1.1.3</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>

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> = {};