From a309123b35ce64391c3c5a33793518228c839a24 Mon Sep 17 00:00:00 2001 From: richarjiang Date: Sat, 29 Nov 2025 20:47:16 +0800 Subject: [PATCH] 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. --- app.json | 2 +- app/(tabs)/personal.tsx | 26 ++ app/_layout.tsx | 49 ++-- app/fitness-rings-detail.tsx | 15 +- app/water/detail.tsx | 6 +- app/workout/history.tsx | 29 ++ components/VersionUpdateModal.tsx | 343 ++++++++++++++++++++++ components/workout/WorkoutDetailModal.tsx | 221 +++++++------- contexts/VersionCheckContext.tsx | 135 +++++++++ i18n/en/health.ts | 158 +++++++++- i18n/en/personal.ts | 21 +- i18n/zh/health.ts | 158 +++++++++- i18n/zh/personal.ts | 21 +- ios/OutLive/Info.plist | 2 +- services/api.ts | 14 + services/notifications.ts | 22 +- services/version.ts | 29 ++ services/workoutMonitor.ts | 27 +- services/workoutNotificationService.ts | 13 +- 19 files changed, 1132 insertions(+), 159 deletions(-) create mode 100644 components/VersionUpdateModal.tsx create mode 100644 contexts/VersionCheckContext.tsx create mode 100644 services/version.ts diff --git a/app.json b/app.json index 98c31d1..7ff7456 100644 --- a/app.json +++ b/app.json @@ -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", diff --git a/app/(tabs)/personal.tsx b/app/(tabs)/personal.tsx index 8d9227e..af61a3e 100644 --- a/app/(tabs)/personal.tsx +++ b/app/(tabs)/personal.tsx @@ -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(() => ([ { @@ -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['name'], + title: t('personal.versionCheck.menuTitle'), + onPress: () => { + void checkForUpdate({ manual: true }); + }, + rightText: versionRightText, + }, + ], + }, // 开发者section(需要连续点击三次用户名激活) ...(showDeveloperSection ? [{ title: t('personal.sections.developer'), diff --git a/app/_layout.tsx b/app/_layout.tsx index cf11e81..84c68eb 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -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() { - - - - - - + + + + + + + - - - - - - - - - - - - - - + + + + + + + + + + + + + + + diff --git a/app/fitness-rings-detail.tsx b/app/fitness-rings-detail.tsx index 6c213ea..1b77d67 100644 --- a/app/fitness-rings-detail.tsx +++ b/app/fitness-rings-detail.tsx @@ -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([]); const [selectedDate, setSelectedDate] = useState(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', }, -}); \ No newline at end of file +}); diff --git a/app/water/detail.tsx b/app/water/detail.tsx index 9230ae2..403ad08 100644 --- a/app/water/detail.tsx +++ b/app/water/detail.tsx @@ -212,7 +212,7 @@ const WaterDetail: React.FC = () => { > - {selectedDate ? dayjs(selectedDate).format('MM月DD日') : t('waterDetail.today')} + {selectedDate ? dayjs(selectedDate).format('MM-DD') : t('waterDetail.today')} {t('waterDetail.waterRecord')} @@ -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', diff --git a/app/workout/history.tsx b/app/workout/history.tsx index 40b6752..05c3760 100644 --- a/app/workout/history.tsx +++ b/app/workout/history.tsx @@ -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([]); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); @@ -285,9 +287,20 @@ export default function WorkoutHistoryScreen() { const [selectedIntensity, setSelectedIntensity] = useState(null); const [monthOccurrenceText, setMonthOccurrenceText] = useState(null); const [monthlyStats, setMonthlyStats] = useState(null); + const [pendingWorkoutId, setPendingWorkoutId] = useState(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); diff --git a/components/VersionUpdateModal.tsx b/components/VersionUpdateModal.tsx new file mode 100644 index 0000000..91b8ea0 --- /dev/null +++ b/components/VersionUpdateModal.tsx @@ -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 ( + + + + + + + + + + + {strings.tag} + + + + + + + + {strings.title} + + {info.latestVersion ? `v${info.latestVersion}` : ''} + + + + + + + + {strings.currentVersionLabel} v{currentVersion} + + + + + + {strings.latestVersionLabel} v{info.latestVersion} + + + + + + {strings.updatesTitle} + {notes.length > 0 ? ( + notes.map((line, idx) => ( + + + + + {line} + + )) + ) : ( + {strings.fallbackNote} + )} + + + + + {strings.remindLater} + + + + + {strings.updateCta} + + + + + + + + ); +} + +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; diff --git a/components/workout/WorkoutDetailModal.tsx b/components/workout/WorkoutDetailModal.tsx index 32f6565..7887d05 100644 --- a/components/workout/WorkoutDetailModal.tsx +++ b/components/workout/WorkoutDetailModal.tsx @@ -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 ( - + - + - + {activityName} {intensityBadge ? ( @@ -225,7 +205,9 @@ export function WorkoutDetailModal({ ) : null} - {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')} {loading ? ( @@ -288,7 +270,7 @@ export function WorkoutDetailModal({ )} - + {t('workoutDetail.sections.heartRateRange')} @@ -323,41 +305,49 @@ export function WorkoutDetailModal({ {heartRateChart ? ( LineChart ? ( - {/* @ts-ignore - react-native-chart-kit types are outdated */} - '#5C55FF', - strokeWidth: 2, + {shouldRenderChart ? ( + /* @ts-ignore - react-native-chart-kit types are outdated */ + '#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} + /> + ) : ( + + + {t('workoutDetail.loading')} + + )} ) : ( @@ -381,7 +371,7 @@ export function WorkoutDetailModal({ )} - + {t('workoutDetail.sections.heartRateZones')} @@ -391,7 +381,7 @@ export function WorkoutDetailModal({ ) : metrics ? ( - metrics.heartRateZones.map(renderHeartRateZone) + metrics.heartRateZones.map((zone) => renderHeartRateZone(zone, t)) ) : ( {t('workoutDetail.errors.noZoneStats')} )} @@ -399,7 +389,7 @@ export function WorkoutDetailModal({ - + {showIntensityInfo ? ( 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 +) { + 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 ( @@ -570,10 +573,8 @@ function renderHeartRateZone(zone: HeartRateZoneStat) { /> - {zone.label} - - {zone.durationMinutes} 分钟 · {zone.rangeText} - + {label} + {meta} ); @@ -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, }, }); - diff --git a/contexts/VersionCheckContext.tsx b/contexts/VersionCheckContext.tsx new file mode 100644 index 0000000..21a3323 --- /dev/null +++ b/contexts/VersionCheckContext.tsx @@ -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; + openStore: () => Promise; +}; + +const VersionCheckContext = createContext(undefined); + +export function VersionCheckProvider({ children }: { children: React.ReactNode }) { + const { showSuccess, showError } = useToast(); + const { t } = useTranslation(); + const [isChecking, setIsChecking] = useState(false); + const [updateInfo, setUpdateInfo] = useState(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 ( + + {children} + setModalVisible(false)} + onUpdate={openStore} + strings={strings} + /> + + ); +} + +export function useVersionCheck(): VersionCheckContextValue { + const context = useContext(VersionCheckContext); + if (!context) { + throw new Error('useVersionCheck must be used within VersionCheckProvider'); + } + return context; +} diff --git a/i18n/en/health.ts b/i18n/en/health.ts index 95e54e9..ea6a8a0 100644 --- a/i18n/en/health.ts +++ b/i18n/en/health.ts @@ -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}}.', -}; \ No newline at end of file +}; diff --git a/i18n/en/personal.ts b/i18n/en/personal.ts index 18e1bfd..c3a1783 100644 --- a/i18n/en/personal.ts +++ b/i18n/en/personal.ts @@ -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', }, }, -}; \ No newline at end of file +}; diff --git a/i18n/zh/health.ts b/i18n/zh/health.ts index 97c9257..2178edd 100644 --- a/i18n/zh/health.ts +++ b/i18n/zh/health.ts @@ -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}}。', -}; \ No newline at end of file +}; diff --git a/i18n/zh/personal.ts b/i18n/zh/personal.ts index a4d3edd..1312479 100644 --- a/i18n/zh/personal.ts +++ b/i18n/zh/personal.ts @@ -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: '您将在晚间收到心情记录提醒', }, }, -}; \ No newline at end of file +}; diff --git a/ios/OutLive/Info.plist b/ios/OutLive/Info.plist index 1d3d9ec..bdb8f7e 100644 --- a/ios/OutLive/Info.plist +++ b/ios/OutLive/Info.plist @@ -27,7 +27,7 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 1.1.2 + 1.1.3 CFBundleSignature ???? CFBundleURLTypes diff --git a/services/api.ts b/services/api.ts index c96d857..d22919a 100644 --- a/services/api.ts +++ b/services/api.ts @@ -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 = { data: T; }; +function getAppVersion(): string | undefined { + return Constants.expoConfig?.version || Constants.nativeAppVersion || undefined; +} + async function doFetch(path: string, options: ApiRequestOptions = {}): Promise { const url = buildApiUrl(path); const headers: Record = { @@ -142,6 +147,11 @@ async function doFetch(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; diff --git a/services/notifications.ts b/services/notifications.ts index 9e6281a..da7b55f 100644 --- a/services/notifications.ts +++ b/services/notifications.ts @@ -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); } }; - diff --git a/services/version.ts b/services/version.ts new file mode 100644 index 0000000..127d1d5 --- /dev/null +++ b/services/version.ts @@ -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 { + const platform = platformOverride || getPlatformParam(); + const query = platform ? `?platform=${platform}` : ''; + return await api.get(`/users/version-check${query}`); +} diff --git a/services/workoutMonitor.ts b/services/workoutMonitor.ts index 59d7660..319098b 100644 --- a/services/workoutMonitor.ts +++ b/services/workoutMonitor.ts @@ -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 { - 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 { 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 { 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); } } diff --git a/services/workoutNotificationService.ts b/services/workoutNotificationService.ts index 2ff9eed..93e1bdd 100644 --- a/services/workoutNotificationService.ts +++ b/services/workoutNotificationService.ts @@ -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 = {};