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:
2
app.json
2
app.json
@@ -2,7 +2,7 @@
|
|||||||
"expo": {
|
"expo": {
|
||||||
"name": "Out Live",
|
"name": "Out Live",
|
||||||
"slug": "digital-pilates",
|
"slug": "digital-pilates",
|
||||||
"version": "1.0.20",
|
"version": "1.1.3",
|
||||||
"orientation": "portrait",
|
"orientation": "portrait",
|
||||||
"scheme": "digitalpilates",
|
"scheme": "digitalpilates",
|
||||||
"userInterfaceStyle": "light",
|
"userInterfaceStyle": "light",
|
||||||
|
|||||||
@@ -5,11 +5,13 @@ import { palette } from '@/constants/Colors';
|
|||||||
import { ROUTES } from '@/constants/Routes';
|
import { ROUTES } from '@/constants/Routes';
|
||||||
import { getTabBarBottomPadding } from '@/constants/TabBar';
|
import { getTabBarBottomPadding } from '@/constants/TabBar';
|
||||||
import { useMembershipModal } from '@/contexts/MembershipModalContext';
|
import { useMembershipModal } from '@/contexts/MembershipModalContext';
|
||||||
|
import { useVersionCheck } from '@/contexts/VersionCheckContext';
|
||||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||||
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||||
import type { BadgeDto } from '@/services/badges';
|
import type { BadgeDto } from '@/services/badges';
|
||||||
import { reportBadgeShowcaseDisplayed } from '@/services/badges';
|
import { reportBadgeShowcaseDisplayed } from '@/services/badges';
|
||||||
import { updateUser, type UserLanguage } from '@/services/users';
|
import { updateUser, type UserLanguage } from '@/services/users';
|
||||||
|
import { getCurrentAppVersion } from '@/services/version';
|
||||||
import { fetchAvailableBadges, selectBadgeCounts, selectBadgePreview, selectSortedBadges } from '@/store/badgesSlice';
|
import { fetchAvailableBadges, selectBadgeCounts, selectBadgePreview, selectSortedBadges } from '@/store/badgesSlice';
|
||||||
import { selectActiveMembershipPlanName } from '@/store/membershipSlice';
|
import { selectActiveMembershipPlanName } from '@/store/membershipSlice';
|
||||||
import { DEFAULT_MEMBER_NAME, fetchActivityHistory, fetchMyProfile } from '@/store/userSlice';
|
import { DEFAULT_MEMBER_NAME, fetchActivityHistory, fetchMyProfile } from '@/store/userSlice';
|
||||||
@@ -66,6 +68,7 @@ export default function PersonalScreen() {
|
|||||||
const [languageModalVisible, setLanguageModalVisible] = useState(false);
|
const [languageModalVisible, setLanguageModalVisible] = useState(false);
|
||||||
const [isSwitchingLanguage, setIsSwitchingLanguage] = useState(false);
|
const [isSwitchingLanguage, setIsSwitchingLanguage] = useState(false);
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
const { checkForUpdate, isChecking: isCheckingVersion, updateInfo } = useVersionCheck();
|
||||||
|
|
||||||
const languageOptions = useMemo<LanguageOption[]>(() => ([
|
const languageOptions = useMemo<LanguageOption[]>(() => ([
|
||||||
{
|
{
|
||||||
@@ -82,6 +85,16 @@ export default function PersonalScreen() {
|
|||||||
|
|
||||||
const activeLanguageCode = getNormalizedLanguage(i18n.language);
|
const activeLanguageCode = getNormalizedLanguage(i18n.language);
|
||||||
const activeLanguageLabel = languageOptions.find((option) => option.code === activeLanguageCode)?.label || '';
|
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) => {
|
const handleLanguageSelect = useCallback(async (language: AppLanguage) => {
|
||||||
setLanguageModalVisible(false);
|
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(需要连续点击三次用户名激活)
|
// 开发者section(需要连续点击三次用户名激活)
|
||||||
...(showDeveloperSection ? [{
|
...(showDeveloperSection ? [{
|
||||||
title: t('personal.sections.developer'),
|
title: t('personal.sections.developer'),
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import { AppState, AppStateStatus } from 'react-native';
|
|||||||
import { DialogProvider } from '@/components/ui/DialogProvider';
|
import { DialogProvider } from '@/components/ui/DialogProvider';
|
||||||
import { MembershipModalProvider } from '@/contexts/MembershipModalContext';
|
import { MembershipModalProvider } from '@/contexts/MembershipModalContext';
|
||||||
import { ToastProvider } from '@/contexts/ToastContext';
|
import { ToastProvider } from '@/contexts/ToastContext';
|
||||||
|
import { VersionCheckProvider } from '@/contexts/VersionCheckContext';
|
||||||
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||||
import { STORAGE_KEYS, setUnauthorizedHandler } from '@/services/api';
|
import { STORAGE_KEYS, setUnauthorizedHandler } from '@/services/api';
|
||||||
import { BackgroundTaskManager } from '@/services/backgroundTaskManagerV2';
|
import { BackgroundTaskManager } from '@/services/backgroundTaskManagerV2';
|
||||||
@@ -524,30 +525,32 @@ export default function RootLayout() {
|
|||||||
<Provider store={store}>
|
<Provider store={store}>
|
||||||
<Bootstrapper>
|
<Bootstrapper>
|
||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
<ThemeProvider value={DefaultTheme}>
|
<VersionCheckProvider>
|
||||||
<Stack screenOptions={{ headerShown: false }}>
|
<ThemeProvider value={DefaultTheme}>
|
||||||
<Stack.Screen name="onboarding" />
|
<Stack screenOptions={{ headerShown: false }}>
|
||||||
<Stack.Screen name="(tabs)" />
|
<Stack.Screen name="onboarding" />
|
||||||
<Stack.Screen name="profile/edit" />
|
<Stack.Screen name="(tabs)" />
|
||||||
<Stack.Screen name="fasting/[planId]" options={{ headerShown: false }} />
|
<Stack.Screen name="profile/edit" />
|
||||||
|
<Stack.Screen name="fasting/[planId]" options={{ headerShown: false }} />
|
||||||
|
|
||||||
<Stack.Screen name="auth/login" 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/user-agreement" options={{ headerShown: true, title: '用户协议' }} />
|
||||||
<Stack.Screen name="legal/privacy-policy" 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="article/[id]" options={{ headerShown: false }} />
|
||||||
<Stack.Screen name="workout/notification-settings" options={{ headerShown: false }} />
|
<Stack.Screen name="workout/notification-settings" options={{ headerShown: false }} />
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="health-data-permissions"
|
name="health-data-permissions"
|
||||||
options={{ headerShown: false }}
|
options={{ headerShown: false }}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen name="medications/ai-camera" options={{ headerShown: false }} />
|
<Stack.Screen name="medications/ai-camera" options={{ headerShown: false }} />
|
||||||
<Stack.Screen name="medications/ai-progress" options={{ headerShown: false }} />
|
<Stack.Screen name="medications/ai-progress" options={{ headerShown: false }} />
|
||||||
<Stack.Screen name="badges/index" options={{ headerShown: false }} />
|
<Stack.Screen name="badges/index" options={{ headerShown: false }} />
|
||||||
<Stack.Screen name="settings/tab-bar-config" options={{ headerShown: false }} />
|
<Stack.Screen name="settings/tab-bar-config" options={{ headerShown: false }} />
|
||||||
<Stack.Screen name="+not-found" />
|
<Stack.Screen name="+not-found" />
|
||||||
</Stack>
|
</Stack>
|
||||||
<StatusBar style="dark" />
|
<StatusBar style="dark" />
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
|
</VersionCheckProvider>
|
||||||
</ToastProvider>
|
</ToastProvider>
|
||||||
</Bootstrapper>
|
</Bootstrapper>
|
||||||
</Provider>
|
</Provider>
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ import {
|
|||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
View
|
View
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
|
import 'dayjs/locale/en';
|
||||||
|
import 'dayjs/locale/zh-cn';
|
||||||
|
|
||||||
// 配置 dayjs 插件
|
// 配置 dayjs 插件
|
||||||
dayjs.extend(utc);
|
dayjs.extend(utc);
|
||||||
@@ -52,8 +54,8 @@ type WeekData = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function FitnessRingsDetailScreen() {
|
export default function FitnessRingsDetailScreen() {
|
||||||
const { t } = useI18n();
|
const { t, i18n } = useI18n();
|
||||||
const safeAreaTop = useSafeAreaTop()
|
const safeAreaTop = useSafeAreaTop();
|
||||||
const colorScheme = useColorScheme();
|
const colorScheme = useColorScheme();
|
||||||
const [weekData, setWeekData] = useState<WeekData[]>([]);
|
const [weekData, setWeekData] = useState<WeekData[]>([]);
|
||||||
const [selectedDate, setSelectedDate] = useState<Date>(new Date());
|
const [selectedDate, setSelectedDate] = useState<Date>(new Date());
|
||||||
@@ -174,8 +176,9 @@ export default function FitnessRingsDetailScreen() {
|
|||||||
|
|
||||||
// 格式化头部显示的日期
|
// 格式化头部显示的日期
|
||||||
const formatHeaderDate = (date: Date) => {
|
const formatHeaderDate = (date: Date) => {
|
||||||
const dayJsDate = dayjs(date).tz('Asia/Shanghai');
|
const dayJsDate = dayjs(date).tz('Asia/Shanghai').locale(i18n.language === 'zh' ? 'zh-cn' : 'en');
|
||||||
return `${dayJsDate.format('YYYY年MM月DD日')}`;
|
const dateFormat = t('fitnessRingsDetail.dateFormats.header', { defaultValue: 'YYYY年MM月DD日' });
|
||||||
|
return dayJsDate.format(dateFormat);
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderWeekRingItem = (item: WeekData, index: number) => {
|
const renderWeekRingItem = (item: WeekData, index: number) => {
|
||||||
@@ -569,7 +572,7 @@ export default function FitnessRingsDetailScreen() {
|
|||||||
display={Platform.OS === 'ios' ? 'inline' : 'calendar'}
|
display={Platform.OS === 'ios' ? 'inline' : 'calendar'}
|
||||||
minimumDate={new Date(2020, 0, 1)}
|
minimumDate={new Date(2020, 0, 1)}
|
||||||
maximumDate={new Date()}
|
maximumDate={new Date()}
|
||||||
{...(Platform.OS === 'ios' ? { locale: 'zh-CN' } : {})}
|
{...(Platform.OS === 'ios' ? { locale: i18n.language === 'zh' ? 'zh-CN' : 'en-US' } : {})}
|
||||||
onChange={(event, date) => {
|
onChange={(event, date) => {
|
||||||
if (Platform.OS === 'ios') {
|
if (Platform.OS === 'ios') {
|
||||||
if (date) setPickerDate(date);
|
if (date) setPickerDate(date);
|
||||||
@@ -884,4 +887,4 @@ const styles = StyleSheet.create({
|
|||||||
color: '#FFFFFF',
|
color: '#FFFFFF',
|
||||||
fontWeight: '700',
|
fontWeight: '700',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -212,7 +212,7 @@ const WaterDetail: React.FC<WaterDetailProps> = () => {
|
|||||||
>
|
>
|
||||||
<View style={styles.headerBlock}>
|
<View style={styles.headerBlock}>
|
||||||
<Text style={styles.pageTitle}>
|
<Text style={styles.pageTitle}>
|
||||||
{selectedDate ? dayjs(selectedDate).format('MM月DD日') : t('waterDetail.today')}
|
{selectedDate ? dayjs(selectedDate).format('MM-DD') : t('waterDetail.today')}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={styles.pageSubtitle}>{t('waterDetail.waterRecord')}</Text>
|
<Text style={styles.pageSubtitle}>{t('waterDetail.waterRecord')}</Text>
|
||||||
</View>
|
</View>
|
||||||
@@ -333,14 +333,14 @@ const styles = StyleSheet.create({
|
|||||||
fontFamily: 'AliRegular',
|
fontFamily: 'AliRegular',
|
||||||
},
|
},
|
||||||
progressValue: {
|
progressValue: {
|
||||||
fontSize: 32,
|
fontSize: 28,
|
||||||
fontWeight: '800',
|
fontWeight: '800',
|
||||||
color: '#4F5BD5',
|
color: '#4F5BD5',
|
||||||
fontFamily: 'AliBold',
|
fontFamily: 'AliBold',
|
||||||
lineHeight: 32,
|
lineHeight: 32,
|
||||||
},
|
},
|
||||||
progressGoalValue: {
|
progressGoalValue: {
|
||||||
fontSize: 24,
|
fontSize: 20,
|
||||||
fontWeight: '700',
|
fontWeight: '700',
|
||||||
color: '#1c1f3a',
|
color: '#1c1f3a',
|
||||||
fontFamily: 'AliBold',
|
fontFamily: 'AliBold',
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useFocusEffect } from '@react-navigation/native';
|
|||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import isBetween from 'dayjs/plugin/isBetween';
|
import isBetween from 'dayjs/plugin/isBetween';
|
||||||
import { LinearGradient } from 'expo-linear-gradient';
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
|
import { useLocalSearchParams } from 'expo-router';
|
||||||
import React, { useCallback, useMemo, useState } from 'react';
|
import React, { useCallback, useMemo, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
@@ -274,6 +275,7 @@ function groupWorkouts(workouts: WorkoutData[]): WorkoutSection[] {
|
|||||||
|
|
||||||
export default function WorkoutHistoryScreen() {
|
export default function WorkoutHistoryScreen() {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
const { workoutId: workoutIdParam } = useLocalSearchParams<{ workoutId?: string | string[] }>();
|
||||||
const [sections, setSections] = useState<WorkoutSection[]>([]);
|
const [sections, setSections] = useState<WorkoutSection[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -285,9 +287,20 @@ export default function WorkoutHistoryScreen() {
|
|||||||
const [selectedIntensity, setSelectedIntensity] = useState<IntensityBadge | null>(null);
|
const [selectedIntensity, setSelectedIntensity] = useState<IntensityBadge | null>(null);
|
||||||
const [monthOccurrenceText, setMonthOccurrenceText] = useState<string | null>(null);
|
const [monthOccurrenceText, setMonthOccurrenceText] = useState<string | null>(null);
|
||||||
const [monthlyStats, setMonthlyStats] = useState<MonthlyStatsInfo | null>(null);
|
const [monthlyStats, setMonthlyStats] = useState<MonthlyStatsInfo | null>(null);
|
||||||
|
const [pendingWorkoutId, setPendingWorkoutId] = useState<string | null>(null);
|
||||||
|
|
||||||
const safeAreaTop = useSafeAreaTop();
|
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 () => {
|
const loadHistory = useCallback(async () => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -484,6 +497,22 @@ export default function WorkoutHistoryScreen() {
|
|||||||
loadWorkoutDetail(workout);
|
loadWorkoutDetail(workout);
|
||||||
}, [computeMonthlyOccurrenceText, loadWorkoutDetail]);
|
}, [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(() => {
|
const handleRetryDetail = useCallback(() => {
|
||||||
if (selectedWorkout) {
|
if (selectedWorkout) {
|
||||||
loadWorkoutDetail(selectedWorkout);
|
loadWorkoutDetail(selectedWorkout);
|
||||||
|
|||||||
343
components/VersionUpdateModal.tsx
Normal file
343
components/VersionUpdateModal.tsx
Normal 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;
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
|
import 'dayjs/locale/en';
|
||||||
|
import 'dayjs/locale/zh-cn';
|
||||||
import { LinearGradient } from 'expo-linear-gradient';
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
Animated,
|
|
||||||
Dimensions,
|
Dimensions,
|
||||||
Modal,
|
Modal,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
@@ -60,63 +61,49 @@ export function WorkoutDetailModal({
|
|||||||
onRetry,
|
onRetry,
|
||||||
errorMessage,
|
errorMessage,
|
||||||
}: WorkoutDetailModalProps) {
|
}: WorkoutDetailModalProps) {
|
||||||
const { t } = useI18n();
|
const { t, i18n } = useI18n();
|
||||||
const animation = useRef(new Animated.Value(visible ? 1 : 0)).current;
|
|
||||||
const [isMounted, setIsMounted] = useState(visible);
|
const [isMounted, setIsMounted] = useState(visible);
|
||||||
|
const [shouldRenderChart, setShouldRenderChart] = useState(visible);
|
||||||
const [showIntensityInfo, setShowIntensityInfo] = useState(false);
|
const [showIntensityInfo, setShowIntensityInfo] = useState(false);
|
||||||
|
|
||||||
|
const locale = useMemo(() => (i18n.language?.startsWith('en') ? 'en' : 'zh-cn'), [i18n.language]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (visible) {
|
if (visible) {
|
||||||
setIsMounted(true);
|
setIsMounted(true);
|
||||||
Animated.timing(animation, {
|
setShouldRenderChart(true);
|
||||||
toValue: 1,
|
|
||||||
duration: 280,
|
|
||||||
useNativeDriver: true,
|
|
||||||
}).start();
|
|
||||||
} else {
|
} else {
|
||||||
Animated.timing(animation, {
|
setShouldRenderChart(false);
|
||||||
toValue: 0,
|
setIsMounted(false);
|
||||||
duration: 240,
|
|
||||||
useNativeDriver: true,
|
|
||||||
}).start(({ finished }) => {
|
|
||||||
if (finished) {
|
|
||||||
setIsMounted(false);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
setShowIntensityInfo(false);
|
setShowIntensityInfo(false);
|
||||||
}
|
}
|
||||||
}, [visible, animation]);
|
}, [visible]);
|
||||||
|
|
||||||
const translateY = animation.interpolate({
|
|
||||||
inputRange: [0, 1],
|
|
||||||
outputRange: [SHEET_MAX_HEIGHT, 0],
|
|
||||||
});
|
|
||||||
|
|
||||||
const backdropOpacity = animation.interpolate({
|
|
||||||
inputRange: [0, 1],
|
|
||||||
outputRange: [0, 1],
|
|
||||||
});
|
|
||||||
|
|
||||||
const activityName = workout
|
const activityName = workout
|
||||||
? getWorkoutTypeDisplayName(workout.workoutActivityType as WorkoutActivityType)
|
? getWorkoutTypeDisplayName(workout.workoutActivityType as WorkoutActivityType)
|
||||||
: '';
|
: '';
|
||||||
|
const chartWidth = useMemo(
|
||||||
|
() => Math.max(Dimensions.get('window').width - 96, 240),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
const dateInfo = useMemo(() => {
|
const dateInfo = useMemo(() => {
|
||||||
if (!workout) {
|
if (!workout) {
|
||||||
return { title: '', subtitle: '' };
|
return { title: '', subtitle: '' };
|
||||||
}
|
}
|
||||||
|
|
||||||
const date = dayjs(workout.startDate || workout.endDate);
|
const date = dayjs(workout.startDate || workout.endDate).locale(locale);
|
||||||
if (!date.isValid()) {
|
if (!date.isValid()) {
|
||||||
return { title: '', subtitle: '' };
|
return { title: '', subtitle: '' };
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: date.format('M月D日'),
|
title: locale === 'en' ? date.format('MMM D') : date.format('M月D日'),
|
||||||
subtitle: date.format('YYYY年M月D日 dddd HH:mm'),
|
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(() => {
|
const heartRateChart = useMemo(() => {
|
||||||
if (!metrics?.heartRateSeries?.length) {
|
if (!metrics?.heartRateSeries?.length) {
|
||||||
@@ -158,23 +145,16 @@ export function WorkoutDetailModal({
|
|||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
transparent
|
transparent
|
||||||
visible={isMounted}
|
visible={visible}
|
||||||
animationType="none"
|
animationType='slide'
|
||||||
onRequestClose={onClose}
|
onRequestClose={onClose}
|
||||||
>
|
>
|
||||||
<View style={styles.modalContainer}>
|
<View style={styles.modalContainer}>
|
||||||
<TouchableWithoutFeedback onPress={handleBackdropPress}>
|
<TouchableWithoutFeedback onPress={handleBackdropPress}>
|
||||||
<Animated.View style={[styles.backdrop, { opacity: backdropOpacity }]} />
|
<View style={styles.backdrop} />
|
||||||
</TouchableWithoutFeedback>
|
</TouchableWithoutFeedback>
|
||||||
|
|
||||||
<Animated.View
|
<View style={styles.sheetContainer}>
|
||||||
style={[
|
|
||||||
styles.sheetContainer,
|
|
||||||
{
|
|
||||||
transform: [{ translateY }],
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<LinearGradient
|
<LinearGradient
|
||||||
colors={['#FFFFFF', '#F3F5FF']}
|
colors={['#FFFFFF', '#F3F5FF']}
|
||||||
start={{ x: 0, y: 0 }}
|
start={{ x: 0, y: 0 }}
|
||||||
@@ -208,7 +188,7 @@ export function WorkoutDetailModal({
|
|||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
contentContainerStyle={styles.contentContainer}
|
contentContainerStyle={styles.contentContainer}
|
||||||
>
|
>
|
||||||
<View style={styles.summaryCard}>
|
<View style={[styles.summaryCard, loading ? styles.summaryCardLoading : null]}>
|
||||||
<View style={styles.summaryHeader}>
|
<View style={styles.summaryHeader}>
|
||||||
<Text style={styles.activityName}>{activityName}</Text>
|
<Text style={styles.activityName}>{activityName}</Text>
|
||||||
{intensityBadge ? (
|
{intensityBadge ? (
|
||||||
@@ -225,7 +205,9 @@ export function WorkoutDetailModal({
|
|||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
<Text style={styles.summarySubtitle}>
|
<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>
|
</Text>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
@@ -288,7 +270,7 @@ export function WorkoutDetailModal({
|
|||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={styles.section}>
|
<View style={[styles.section, loading ? styles.sectionHeartRateLoading : null]}>
|
||||||
<View style={styles.sectionHeader}>
|
<View style={styles.sectionHeader}>
|
||||||
<Text style={styles.sectionTitle}>{t('workoutDetail.sections.heartRateRange')}</Text>
|
<Text style={styles.sectionTitle}>{t('workoutDetail.sections.heartRateRange')}</Text>
|
||||||
</View>
|
</View>
|
||||||
@@ -323,41 +305,49 @@ export function WorkoutDetailModal({
|
|||||||
{heartRateChart ? (
|
{heartRateChart ? (
|
||||||
LineChart ? (
|
LineChart ? (
|
||||||
<View style={styles.chartWrapper}>
|
<View style={styles.chartWrapper}>
|
||||||
{/* @ts-ignore - react-native-chart-kit types are outdated */}
|
{shouldRenderChart ? (
|
||||||
<LineChart
|
/* @ts-ignore - react-native-chart-kit types are outdated */
|
||||||
data={{
|
<LineChart
|
||||||
labels: heartRateChart.labels,
|
data={{
|
||||||
datasets: [
|
labels: heartRateChart.labels,
|
||||||
{
|
datasets: [
|
||||||
data: heartRateChart.data,
|
{
|
||||||
color: () => '#5C55FF',
|
data: heartRateChart.data,
|
||||||
strokeWidth: 2,
|
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',
|
||||||
},
|
},
|
||||||
],
|
fillShadowGradientFromOpacity: 0.1,
|
||||||
}}
|
fillShadowGradientToOpacity: 0.02,
|
||||||
width={Dimensions.get('window').width - 72}
|
}}
|
||||||
height={220}
|
style={styles.chartStyle}
|
||||||
fromZero={false}
|
/>
|
||||||
yAxisSuffix={t('workoutDetail.sections.heartRateUnit')}
|
) : (
|
||||||
withInnerLines={false}
|
<View style={[styles.chartLoading, { width: chartWidth }]}>
|
||||||
bezier
|
<ActivityIndicator color="#5C55FF" />
|
||||||
chartConfig={{
|
<Text style={styles.chartLoadingText}>{t('workoutDetail.loading')}</Text>
|
||||||
backgroundColor: '#FFFFFF',
|
</View>
|
||||||
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}
|
|
||||||
/>
|
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<View style={styles.chartEmpty}>
|
<View style={styles.chartEmpty}>
|
||||||
@@ -381,7 +371,7 @@ export function WorkoutDetailModal({
|
|||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={styles.section}>
|
<View style={[styles.section, loading ? styles.sectionZonesLoading : null]}>
|
||||||
<View style={styles.sectionHeader}>
|
<View style={styles.sectionHeader}>
|
||||||
<Text style={styles.sectionTitle}>{t('workoutDetail.sections.heartRateZones')}</Text>
|
<Text style={styles.sectionTitle}>{t('workoutDetail.sections.heartRateZones')}</Text>
|
||||||
</View>
|
</View>
|
||||||
@@ -391,7 +381,7 @@ export function WorkoutDetailModal({
|
|||||||
<ActivityIndicator color="#5C55FF" />
|
<ActivityIndicator color="#5C55FF" />
|
||||||
</View>
|
</View>
|
||||||
) : metrics ? (
|
) : metrics ? (
|
||||||
metrics.heartRateZones.map(renderHeartRateZone)
|
metrics.heartRateZones.map((zone) => renderHeartRateZone(zone, t))
|
||||||
) : (
|
) : (
|
||||||
<Text style={styles.errorTextSmall}>{t('workoutDetail.errors.noZoneStats')}</Text>
|
<Text style={styles.errorTextSmall}>{t('workoutDetail.errors.noZoneStats')}</Text>
|
||||||
)}
|
)}
|
||||||
@@ -399,7 +389,7 @@ export function WorkoutDetailModal({
|
|||||||
|
|
||||||
<View style={styles.homeIndicatorSpacer} />
|
<View style={styles.homeIndicatorSpacer} />
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</Animated.View>
|
</View>
|
||||||
{showIntensityInfo ? (
|
{showIntensityInfo ? (
|
||||||
<Modal
|
<Modal
|
||||||
transparent
|
transparent
|
||||||
@@ -513,6 +503,7 @@ function trimHeartRateSeries(series: WorkoutDetailMetrics['heartRateSeries']) {
|
|||||||
|
|
||||||
// 遍历所有点,选择重要点
|
// 遍历所有点,选择重要点
|
||||||
let minDistance = Math.max(1, Math.floor(n / HEART_RATE_CHART_MAX_POINTS));
|
let minDistance = Math.max(1, Math.floor(n / HEART_RATE_CHART_MAX_POINTS));
|
||||||
|
let lastSelectedIndex = 0;
|
||||||
|
|
||||||
for (let i = 1; i < n - 1; i++) {
|
for (let i = 1; i < n - 1; i++) {
|
||||||
const shouldKeep =
|
const shouldKeep =
|
||||||
@@ -525,11 +516,9 @@ function trimHeartRateSeries(series: WorkoutDetailMetrics['heartRateSeries']) {
|
|||||||
|
|
||||||
if (shouldKeep) {
|
if (shouldKeep) {
|
||||||
// 检查与上一个选中点的距离,避免过于密集
|
// 检查与上一个选中点的距离,避免过于密集
|
||||||
const lastSelectedIndex = result.length > 0 ?
|
|
||||||
series.findIndex(p => p.timestamp === result[result.length - 1].timestamp) : 0;
|
|
||||||
|
|
||||||
if (i - lastSelectedIndex >= minDistance || isLocalExtremum(i)) {
|
if (i - lastSelectedIndex >= minDistance || isLocalExtremum(i)) {
|
||||||
result.push(series[i]);
|
result.push(series[i]);
|
||||||
|
lastSelectedIndex = i;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -555,7 +544,21 @@ function trimHeartRateSeries(series: WorkoutDetailMetrics['heartRateSeries']) {
|
|||||||
return result;
|
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 (
|
return (
|
||||||
<View key={zone.key} style={styles.zoneRow}>
|
<View key={zone.key} style={styles.zoneRow}>
|
||||||
<View style={[styles.zoneBar, { backgroundColor: `${zone.color}33` }]}>
|
<View style={[styles.zoneBar, { backgroundColor: `${zone.color}33` }]}>
|
||||||
@@ -570,10 +573,8 @@ function renderHeartRateZone(zone: HeartRateZoneStat) {
|
|||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.zoneInfo}>
|
<View style={styles.zoneInfo}>
|
||||||
<Text style={styles.zoneLabel}>{zone.label}</Text>
|
<Text style={styles.zoneLabel}>{label}</Text>
|
||||||
<Text style={styles.zoneMeta}>
|
<Text style={styles.zoneMeta}>{meta}</Text>
|
||||||
{zone.durationMinutes} 分钟 · {zone.rangeText}
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
@@ -668,20 +669,28 @@ const styles = StyleSheet.create({
|
|||||||
shadowRadius: 22,
|
shadowRadius: 22,
|
||||||
elevation: 8,
|
elevation: 8,
|
||||||
},
|
},
|
||||||
|
summaryCardLoading: {
|
||||||
|
minHeight: 240,
|
||||||
|
},
|
||||||
summaryHeader: {
|
summaryHeader: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'flex-start',
|
||||||
justifyContent: 'space-between',
|
flexWrap: 'wrap',
|
||||||
|
gap: 10,
|
||||||
},
|
},
|
||||||
activityName: {
|
activityName: {
|
||||||
fontSize: 24,
|
fontSize: 24,
|
||||||
fontWeight: '700',
|
fontWeight: '700',
|
||||||
color: '#1E2148',
|
color: '#1E2148',
|
||||||
|
flex: 1,
|
||||||
|
flexShrink: 1,
|
||||||
|
lineHeight: 30,
|
||||||
},
|
},
|
||||||
intensityPill: {
|
intensityPill: {
|
||||||
paddingHorizontal: 12,
|
paddingHorizontal: 12,
|
||||||
paddingVertical: 4,
|
paddingVertical: 4,
|
||||||
borderRadius: 999,
|
borderRadius: 999,
|
||||||
|
alignSelf: 'flex-start',
|
||||||
},
|
},
|
||||||
intensityPillText: {
|
intensityPillText: {
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
@@ -768,6 +777,12 @@ const styles = StyleSheet.create({
|
|||||||
shadowRadius: 20,
|
shadowRadius: 20,
|
||||||
elevation: 4,
|
elevation: 4,
|
||||||
},
|
},
|
||||||
|
sectionHeartRateLoading: {
|
||||||
|
minHeight: 360,
|
||||||
|
},
|
||||||
|
sectionZonesLoading: {
|
||||||
|
minHeight: 200,
|
||||||
|
},
|
||||||
sectionHeader: {
|
sectionHeader: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
@@ -811,11 +826,22 @@ const styles = StyleSheet.create({
|
|||||||
color: '#1E2148',
|
color: '#1E2148',
|
||||||
},
|
},
|
||||||
chartWrapper: {
|
chartWrapper: {
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
overflow: 'visible',
|
||||||
|
},
|
||||||
|
chartLoading: {
|
||||||
|
height: 220,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
chartLoadingText: {
|
||||||
|
marginTop: 8,
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#7E86A7',
|
||||||
},
|
},
|
||||||
chartStyle: {
|
chartStyle: {
|
||||||
marginLeft: -10,
|
marginLeft: 0,
|
||||||
marginRight: -10,
|
marginRight: 0,
|
||||||
},
|
},
|
||||||
chartEmpty: {
|
chartEmpty: {
|
||||||
paddingVertical: 32,
|
paddingVertical: 32,
|
||||||
@@ -949,4 +975,3 @@ const styles = StyleSheet.create({
|
|||||||
height: 40,
|
height: 40,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
135
contexts/VersionCheckContext.tsx
Normal file
135
contexts/VersionCheckContext.tsx
Normal 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;
|
||||||
|
}
|
||||||
@@ -338,6 +338,9 @@ export const fitnessRingsDetail = {
|
|||||||
saturday: 'Sat',
|
saturday: 'Sat',
|
||||||
sunday: 'Sun',
|
sunday: 'Sun',
|
||||||
},
|
},
|
||||||
|
dateFormats: {
|
||||||
|
header: 'MMM D, YYYY',
|
||||||
|
},
|
||||||
cards: {
|
cards: {
|
||||||
activeCalories: {
|
activeCalories: {
|
||||||
title: 'Active Calories',
|
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 = {
|
export const workoutHistory = {
|
||||||
title: 'Workout Summary',
|
title: 'Workout Summary',
|
||||||
loading: 'Loading workout records...',
|
loading: 'Loading workout records...',
|
||||||
@@ -504,4 +660,4 @@ export const workoutHistory = {
|
|||||||
subtitle: 'Complete a workout to view detailed history here',
|
subtitle: 'Complete a workout to view detailed history here',
|
||||||
},
|
},
|
||||||
monthOccurrence: 'This is your {{index}} {{activity}} in {{month}}.',
|
monthOccurrence: 'This is your {{index}} {{activity}} in {{month}}.',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -37,6 +37,25 @@ export const personal = {
|
|||||||
medicalSources: 'Medical Advice Sources',
|
medicalSources: 'Medical Advice Sources',
|
||||||
customization: 'Customization',
|
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: {
|
menu: {
|
||||||
notificationSettings: 'Notification settings',
|
notificationSettings: 'Notification settings',
|
||||||
developerOptions: 'Developer options',
|
developerOptions: 'Developer options',
|
||||||
@@ -405,4 +424,4 @@ export const notificationSettings = {
|
|||||||
body: 'You will receive mood record reminders in the evening',
|
body: 'You will receive mood record reminders in the evening',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -338,6 +338,9 @@ export const fitnessRingsDetail = {
|
|||||||
saturday: '周六',
|
saturday: '周六',
|
||||||
sunday: '周日',
|
sunday: '周日',
|
||||||
},
|
},
|
||||||
|
dateFormats: {
|
||||||
|
header: 'YYYY年MM月DD日',
|
||||||
|
},
|
||||||
cards: {
|
cards: {
|
||||||
activeCalories: {
|
activeCalories: {
|
||||||
title: '活动热量',
|
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 = {
|
export const workoutHistory = {
|
||||||
title: '锻炼总结',
|
title: '锻炼总结',
|
||||||
loading: '正在加载锻炼记录...',
|
loading: '正在加载锻炼记录...',
|
||||||
@@ -504,4 +660,4 @@ export const workoutHistory = {
|
|||||||
subtitle: '完成一次锻炼后即可在此查看详细历史',
|
subtitle: '完成一次锻炼后即可在此查看详细历史',
|
||||||
},
|
},
|
||||||
monthOccurrence: '这是你{{month}}的第 {{index}} 次{{activity}}。',
|
monthOccurrence: '这是你{{month}}的第 {{index}} 次{{activity}}。',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -37,6 +37,25 @@ export const personal = {
|
|||||||
medicalSources: '医学建议来源',
|
medicalSources: '医学建议来源',
|
||||||
customization: '个性化',
|
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: {
|
menu: {
|
||||||
notificationSettings: '通知设置',
|
notificationSettings: '通知设置',
|
||||||
developerOptions: '开发者选项',
|
developerOptions: '开发者选项',
|
||||||
@@ -405,4 +424,4 @@ export const notificationSettings = {
|
|||||||
body: '您将在晚间收到心情记录提醒',
|
body: '您将在晚间收到心情记录提醒',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>1.1.2</string>
|
<string>1.1.3</string>
|
||||||
<key>CFBundleSignature</key>
|
<key>CFBundleSignature</key>
|
||||||
<string>????</string>
|
<string>????</string>
|
||||||
<key>CFBundleURLTypes</key>
|
<key>CFBundleURLTypes</key>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { buildApiUrl } from '@/constants/Api';
|
import { buildApiUrl } from '@/constants/Api';
|
||||||
import AsyncStorage from '@/utils/kvStore';
|
import AsyncStorage from '@/utils/kvStore';
|
||||||
|
import Constants from 'expo-constants';
|
||||||
import { Alert } from 'react-native';
|
import { Alert } from 'react-native';
|
||||||
|
|
||||||
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
|
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
|
||||||
@@ -128,6 +129,10 @@ export type ApiResponse<T> = {
|
|||||||
data: T;
|
data: T;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function getAppVersion(): string | undefined {
|
||||||
|
return Constants.expoConfig?.version || Constants.nativeAppVersion || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
async function doFetch<T>(path: string, options: ApiRequestOptions = {}): Promise<T> {
|
async function doFetch<T>(path: string, options: ApiRequestOptions = {}): Promise<T> {
|
||||||
const url = buildApiUrl(path);
|
const url = buildApiUrl(path);
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
@@ -142,6 +147,11 @@ async function doFetch<T>(path: string, options: ApiRequestOptions = {}): Promis
|
|||||||
if (token) {
|
if (token) {
|
||||||
headers['Authorization'] = `Bearer ${token}`;
|
headers['Authorization'] = `Bearer ${token}`;
|
||||||
}
|
}
|
||||||
|
const appVersion = getAppVersion();
|
||||||
|
|
||||||
|
if (appVersion) {
|
||||||
|
headers['X-App-Version'] = appVersion;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
@@ -224,6 +234,10 @@ export async function postTextStream(path: string, body: any, callbacks: TextStr
|
|||||||
if (token) {
|
if (token) {
|
||||||
requestHeaders['Authorization'] = `Bearer ${token}`;
|
requestHeaders['Authorization'] = `Bearer ${token}`;
|
||||||
}
|
}
|
||||||
|
const appVersion = getAppVersion();
|
||||||
|
if (appVersion) {
|
||||||
|
requestHeaders['X-App-Version'] = appVersion;
|
||||||
|
}
|
||||||
|
|
||||||
const xhr = new XMLHttpRequest();
|
const xhr = new XMLHttpRequest();
|
||||||
let lastReadIndex = 0;
|
let lastReadIndex = 0;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { ROUTES } from '@/constants/Routes';
|
import { ROUTES } from '@/constants/Routes';
|
||||||
|
import { logger } from '@/utils/logger';
|
||||||
import { getNotificationEnabled } from '@/utils/userPreferences';
|
import { getNotificationEnabled } from '@/utils/userPreferences';
|
||||||
import * as Notifications from 'expo-notifications';
|
import * as Notifications from 'expo-notifications';
|
||||||
import { router } from 'expo-router';
|
import { router } from 'expo-router';
|
||||||
@@ -231,9 +232,23 @@ export class NotificationService {
|
|||||||
router.push(ROUTES.TAB_FASTING as any);
|
router.push(ROUTES.TAB_FASTING as any);
|
||||||
} else if (data?.type === NotificationTypes.WORKOUT_COMPLETION) {
|
} else if (data?.type === NotificationTypes.WORKOUT_COMPLETION) {
|
||||||
// 处理锻炼完成通知
|
// 处理锻炼完成通知
|
||||||
console.log('用户点击了锻炼完成通知', data);
|
logger.info('用户点击了锻炼完成通知', data);
|
||||||
// 跳转到锻炼历史页面
|
const workoutId =
|
||||||
router.push('/workout/history' as any);
|
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) {
|
} else if (data?.type === NotificationTypes.HRV_STRESS_ALERT) {
|
||||||
console.log('用户点击了 HRV 压力通知', data);
|
console.log('用户点击了 HRV 压力通知', data);
|
||||||
const targetUrl = (data?.url as string) || '/(tabs)/statistics';
|
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);
|
return notificationService.sendImmediateNotification(notification);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
29
services/version.ts
Normal file
29
services/version.ts
Normal 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}`);
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { fetchRecentWorkouts, WorkoutData } from '@/utils/health';
|
import { fetchRecentWorkouts, WorkoutData } from '@/utils/health';
|
||||||
import AsyncStorage from '@/utils/kvStore';
|
import AsyncStorage from '@/utils/kvStore';
|
||||||
|
import { logger } from '@/utils/logger';
|
||||||
import { NativeEventEmitter, NativeModules } from 'react-native';
|
import { NativeEventEmitter, NativeModules } from 'react-native';
|
||||||
import { analyzeWorkoutAndSendNotification } from './workoutNotificationService';
|
import { analyzeWorkoutAndSendNotification } from './workoutNotificationService';
|
||||||
|
|
||||||
@@ -94,7 +95,7 @@ class WorkoutMonitorService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async handleWorkoutUpdate(event: any): Promise<void> {
|
private async handleWorkoutUpdate(event: any): Promise<void> {
|
||||||
console.log('收到锻炼更新事件:', event);
|
logger.info('收到锻炼更新事件:', event);
|
||||||
|
|
||||||
// 防抖处理,避免短时间内重复处理
|
// 防抖处理,避免短时间内重复处理
|
||||||
if (this.processingTimeout) {
|
if (this.processingTimeout) {
|
||||||
@@ -105,14 +106,14 @@ class WorkoutMonitorService {
|
|||||||
try {
|
try {
|
||||||
await this.checkForNewWorkouts();
|
await this.checkForNewWorkouts();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('检查新锻炼失败:', error);
|
logger.error('检查新锻炼失败:', error);
|
||||||
}
|
}
|
||||||
}, 5000); // 5秒延迟,确保 HealthKit 数据已完全更新
|
}, 5000); // 5秒延迟,确保 HealthKit 数据已完全更新
|
||||||
}
|
}
|
||||||
|
|
||||||
private async checkForNewWorkouts(): Promise<void> {
|
private async checkForNewWorkouts(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
console.log('检查新的锻炼记录...');
|
logger.info('检查新的锻炼记录...');
|
||||||
|
|
||||||
const lookbackWindowMs = this.lastProcessedWorkoutId
|
const lookbackWindowMs = this.lastProcessedWorkoutId
|
||||||
? DEFAULT_LOOKBACK_WINDOW_MS
|
? DEFAULT_LOOKBACK_WINDOW_MS
|
||||||
@@ -120,7 +121,7 @@ class WorkoutMonitorService {
|
|||||||
const startDate = new Date(Date.now() - lookbackWindowMs);
|
const startDate = new Date(Date.now() - lookbackWindowMs);
|
||||||
const endDate = new Date();
|
const endDate = new Date();
|
||||||
|
|
||||||
console.log(
|
logger.info(
|
||||||
`锻炼查询窗口: ${Math.round(lookbackWindowMs / (1000 * 60 * 60))} 小时 (${startDate.toISOString()} - ${endDate.toISOString()})`
|
`锻炼查询窗口: ${Math.round(lookbackWindowMs / (1000 * 60 * 60))} 小时 (${startDate.toISOString()} - ${endDate.toISOString()})`
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -130,7 +131,7 @@ class WorkoutMonitorService {
|
|||||||
limit: 10
|
limit: 10
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(`找到 ${recentWorkouts.length} 条最近的锻炼记录`);
|
logger.info(`找到 ${recentWorkouts.length} 条最近的锻炼记录`);
|
||||||
|
|
||||||
if (this.lastProcessedWorkoutId && !recentWorkouts.some(workout => workout.id === this.lastProcessedWorkoutId)) {
|
if (this.lastProcessedWorkoutId && !recentWorkouts.some(workout => workout.id === this.lastProcessedWorkoutId)) {
|
||||||
console.warn('上次处理的锻炼记录不在当前查询窗口内,可能存在漏报风险');
|
console.warn('上次处理的锻炼记录不在当前查询窗口内,可能存在漏报风险');
|
||||||
@@ -145,15 +146,15 @@ class WorkoutMonitorService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (newWorkouts.length === 0) {
|
if (newWorkouts.length === 0) {
|
||||||
console.log('没有检测到新的锻炼记录');
|
logger.info('没有检测到新的锻炼记录');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`检测到 ${newWorkouts.length} 条新的锻炼记录,将按时间顺序处理`);
|
logger.info(`检测到 ${newWorkouts.length} 条新的锻炼记录,将按时间顺序处理`);
|
||||||
|
|
||||||
// 先处理最旧的锻炼,确保通知顺序正确
|
// 先处理最旧的锻炼,确保通知顺序正确
|
||||||
for (const workout of newWorkouts.reverse()) {
|
for (const workout of newWorkouts.reverse()) {
|
||||||
console.log('处理新锻炼:', {
|
logger.info('处理新锻炼:', {
|
||||||
id: workout.id,
|
id: workout.id,
|
||||||
type: workout.workoutActivityTypeString,
|
type: workout.workoutActivityTypeString,
|
||||||
duration: workout.duration,
|
duration: workout.duration,
|
||||||
@@ -164,22 +165,22 @@ class WorkoutMonitorService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await this.saveLastProcessedWorkoutId(newWorkouts[0].id);
|
await this.saveLastProcessedWorkoutId(newWorkouts[0].id);
|
||||||
console.log('锻炼处理完成,最新处理的锻炼ID:', newWorkouts[0].id);
|
logger.info('锻炼处理完成,最新处理的锻炼ID:', newWorkouts[0].id);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('检查新锻炼失败:', error);
|
logger.error('检查新锻炼失败:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async processNewWorkout(workout: WorkoutData): Promise<void> {
|
private async processNewWorkout(workout: WorkoutData): Promise<void> {
|
||||||
try {
|
try {
|
||||||
console.log('开始处理新锻炼:', workout.id);
|
logger.info('开始处理新锻炼:', workout.id);
|
||||||
|
|
||||||
// 分析锻炼并发送通知
|
// 分析锻炼并发送通知
|
||||||
await analyzeWorkoutAndSendNotification(workout);
|
await analyzeWorkoutAndSendNotification(workout);
|
||||||
|
|
||||||
console.log('新锻炼处理完成:', workout.id);
|
logger.info('新锻炼处理完成:', workout.id);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('处理新锻炼失败:', error);
|
logger.error('处理新锻炼失败:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { getWorkoutTypeDisplayName, WorkoutData } from '@/utils/health';
|
import { getWorkoutTypeDisplayName, WorkoutData } from '@/utils/health';
|
||||||
|
import { logger } from '@/utils/logger';
|
||||||
import { getNotificationEnabled } from '@/utils/userPreferences';
|
import { getNotificationEnabled } from '@/utils/userPreferences';
|
||||||
import {
|
import {
|
||||||
getWorkoutNotificationEnabled,
|
getWorkoutNotificationEnabled,
|
||||||
@@ -19,28 +20,28 @@ export async function analyzeWorkoutAndSendNotification(workout: WorkoutData): P
|
|||||||
// 检查用户是否启用了通用通知
|
// 检查用户是否启用了通用通知
|
||||||
const notificationsEnabled = await getNotificationEnabled();
|
const notificationsEnabled = await getNotificationEnabled();
|
||||||
if (!notificationsEnabled) {
|
if (!notificationsEnabled) {
|
||||||
console.log('用户已禁用通知,跳过锻炼结束通知');
|
logger.info('用户已禁用通知,跳过锻炼结束通知');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查用户是否启用了锻炼通知
|
// 检查用户是否启用了锻炼通知
|
||||||
const workoutNotificationsEnabled = await getWorkoutNotificationEnabled();
|
const workoutNotificationsEnabled = await getWorkoutNotificationEnabled();
|
||||||
if (!workoutNotificationsEnabled) {
|
if (!workoutNotificationsEnabled) {
|
||||||
console.log('用户已禁用锻炼通知,跳过锻炼结束通知');
|
logger.info('用户已禁用锻炼通知,跳过锻炼结束通知');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查时间限制(避免深夜打扰)
|
// 检查时间限制(避免深夜打扰)
|
||||||
const timeAllowed = await isNotificationTimeAllowed();
|
const timeAllowed = await isNotificationTimeAllowed();
|
||||||
if (!timeAllowed) {
|
if (!timeAllowed) {
|
||||||
console.log('当前时间不适合发送通知,跳过锻炼结束通知');
|
logger.info('当前时间不适合发送通知,跳过锻炼结束通知');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查特定锻炼类型是否启用了通知
|
// 检查特定锻炼类型是否启用了通知
|
||||||
const workoutTypeEnabled = await isWorkoutTypeEnabled(workout.workoutActivityTypeString || '');
|
const workoutTypeEnabled = await isWorkoutTypeEnabled(workout.workoutActivityTypeString || '');
|
||||||
if (!workoutTypeEnabled) {
|
if (!workoutTypeEnabled) {
|
||||||
console.log('该锻炼类型已禁用通知,跳过锻炼结束通知:', workout.workoutActivityTypeString);
|
logger.info('该锻炼类型已禁用通知,跳过锻炼结束通知:', workout.workoutActivityTypeString);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,7 +67,7 @@ export async function analyzeWorkoutAndSendNotification(workout: WorkoutData): P
|
|||||||
priority: 'high'
|
priority: 'high'
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('锻炼结束通知已发送:', message.title);
|
logger.info('锻炼结束通知已发送:', message.title);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('发送锻炼结束通知失败:', error);
|
console.error('发送锻炼结束通知失败:', error);
|
||||||
}
|
}
|
||||||
@@ -221,7 +222,7 @@ function generateEncouragementMessage(
|
|||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const messageConfig = getWorkoutMessage(workout.workoutActivityTypeString);
|
const messageConfig = getWorkoutMessage(workout.workoutActivityTypeString);
|
||||||
let title = '🎯 锻炼完成!';
|
let title = '锻炼完成!';
|
||||||
let body = '';
|
let body = '';
|
||||||
const data: Record<string, any> = {};
|
const data: Record<string, any> = {};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user