feat(workout): 重构锻炼模块并新增详细数据展示
- 移除旧的锻炼会话页面和布局文件 - 新增锻炼详情模态框组件,支持心率区间、运动强度等详细数据展示 - 优化锻炼历史页面,增加月度统计卡片和交互式详情查看 - 新增锻炼详情服务,提供心率分析、METs计算等功能 - 更新应用版本至1.0.17并调整iOS后台任务配置 - 添加项目规则文档,明确React Native开发规范
This commit is contained in:
7
.kilocode/rules/kilo-rule.md
Normal file
7
.kilocode/rules/kilo-rule.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
# kilo-rule.md
|
||||||
|
永远记得你是一名专业的 reac native 工程师,并且当前项目是一个 prebuild 之后的 expo react native 项目,应用场景永远是 ios,不要考虑 android
|
||||||
|
|
||||||
|
## 指导原则
|
||||||
|
|
||||||
|
- 遇到比较复杂的页面,尽量使用可以复用的组件
|
||||||
|
- 不要尝试使用 `npm run ios` 命令
|
||||||
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.16",
|
"version": "1.0.17",
|
||||||
"orientation": "portrait",
|
"orientation": "portrait",
|
||||||
"scheme": "digitalpilates",
|
"scheme": "digitalpilates",
|
||||||
"userInterfaceStyle": "light",
|
"userInterfaceStyle": "light",
|
||||||
|
|||||||
@@ -330,9 +330,11 @@ export default function FitnessRingsDetailScreen() {
|
|||||||
style={[
|
style={[
|
||||||
styles.chartBar,
|
styles.chartBar,
|
||||||
{
|
{
|
||||||
|
flex: 1,
|
||||||
height: value > 0 ? height : 2, // 没有数据时显示最小高度的灰色条
|
height: value > 0 ? height : 2, // 没有数据时显示最小高度的灰色条
|
||||||
backgroundColor: value > 0 ? color : '#E5E5EA',
|
backgroundColor: value > 0 ? color : '#E5E5EA',
|
||||||
opacity: value > 0 ? 1 : 0.5
|
opacity: value > 0 ? 1 : 0.5,
|
||||||
|
marginHorizontal: 0.5
|
||||||
}
|
}
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
@@ -340,10 +342,19 @@ export default function FitnessRingsDetailScreen() {
|
|||||||
})}
|
})}
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.chartLabels}>
|
<View style={styles.chartLabels}>
|
||||||
<Text style={styles.chartLabel}>00:00</Text>
|
{chartData.map((_, index) => {
|
||||||
<Text style={styles.chartLabel}>06:00</Text>
|
// 只在关键时间点显示标签:0点、6点、12点、18点
|
||||||
<Text style={styles.chartLabel}>12:00</Text>
|
if (index === 0 || index === 6 || index === 12 || index === 18) {
|
||||||
<Text style={styles.chartLabel}>18:00</Text>
|
const hour = index;
|
||||||
|
return (
|
||||||
|
<Text key={index} style={styles.chartLabel}>
|
||||||
|
{hour.toString().padStart(2, '0')}:00
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// 对于不显示标签的小时,返回一个占位的View
|
||||||
|
return <View key={index} style={styles.chartLabelSpacer} />;
|
||||||
|
})}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
@@ -731,23 +742,25 @@ const styles = StyleSheet.create({
|
|||||||
alignItems: 'flex-end',
|
alignItems: 'flex-end',
|
||||||
height: 60,
|
height: 60,
|
||||||
marginBottom: 8,
|
marginBottom: 8,
|
||||||
paddingHorizontal: 4,
|
paddingHorizontal: 2,
|
||||||
justifyContent: 'space-between',
|
|
||||||
},
|
},
|
||||||
chartBar: {
|
chartBar: {
|
||||||
width: 3,
|
|
||||||
borderRadius: 1.5,
|
borderRadius: 1.5,
|
||||||
marginHorizontal: 0.5,
|
|
||||||
},
|
},
|
||||||
chartLabels: {
|
chartLabels: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
|
paddingHorizontal: 2,
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
paddingHorizontal: 4,
|
|
||||||
},
|
},
|
||||||
chartLabel: {
|
chartLabel: {
|
||||||
fontSize: 12,
|
fontSize: 10,
|
||||||
color: '#8E8E93',
|
color: '#8E8E93',
|
||||||
fontWeight: '500',
|
fontWeight: '500',
|
||||||
|
textAlign: 'center',
|
||||||
|
flex: 6, // 给显示标签的元素更多空间
|
||||||
|
},
|
||||||
|
chartLabelSpacer: {
|
||||||
|
flex: 1, // 占位元素使用较少空间
|
||||||
},
|
},
|
||||||
// 锻炼信息样式
|
// 锻炼信息样式
|
||||||
exerciseInfo: {
|
exerciseInfo: {
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
import { Stack } from 'expo-router';
|
|
||||||
|
|
||||||
export default function WorkoutLayout() {
|
|
||||||
return (
|
|
||||||
<Stack>
|
|
||||||
<Stack.Screen
|
|
||||||
name="today"
|
|
||||||
options={{
|
|
||||||
headerShown: false,
|
|
||||||
presentation: 'card',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Stack.Screen
|
|
||||||
name="create-session"
|
|
||||||
options={{
|
|
||||||
headerShown: false,
|
|
||||||
presentation: 'modal',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Stack.Screen
|
|
||||||
name="history"
|
|
||||||
options={{
|
|
||||||
headerShown: false,
|
|
||||||
presentation: 'card',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Stack.Screen
|
|
||||||
name="session/[id]"
|
|
||||||
options={{
|
|
||||||
headerShown: false,
|
|
||||||
presentation: 'card',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||||
import { useFocusEffect } from '@react-navigation/native';
|
import { useFocusEffect } from '@react-navigation/native';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
|
import isBetween from 'dayjs/plugin/isBetween';
|
||||||
import { LinearGradient } from 'expo-linear-gradient';
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
import React, { useCallback, useMemo, useState } from 'react';
|
import React, { useCallback, useMemo, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
@@ -13,7 +14,9 @@ import {
|
|||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
|
|
||||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||||
|
import { IntensityBadge, WorkoutDetailModal } from '@/components/workout/WorkoutDetailModal';
|
||||||
|
|
||||||
|
import { getWorkoutDetailMetrics, WorkoutDetailMetrics } from '@/services/workoutDetail';
|
||||||
import {
|
import {
|
||||||
addHealthPermissionListener,
|
addHealthPermissionListener,
|
||||||
checkHealthPermissionStatus,
|
checkHealthPermissionStatus,
|
||||||
@@ -138,6 +141,97 @@ const ICON_MAP: Partial<Record<WorkoutActivityType, keyof typeof MaterialCommuni
|
|||||||
[WorkoutActivityType.Curling]: 'target',
|
[WorkoutActivityType.Curling]: 'target',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ActivitySummary = {
|
||||||
|
type: WorkoutActivityType;
|
||||||
|
duration: number;
|
||||||
|
count: number;
|
||||||
|
displayName: string;
|
||||||
|
iconName: keyof typeof MaterialCommunityIcons.glyphMap;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MonthlyStatsInfo = {
|
||||||
|
items: ActivitySummary[];
|
||||||
|
totalDuration: number;
|
||||||
|
totalCount: number;
|
||||||
|
monthStart: string;
|
||||||
|
monthEnd: string;
|
||||||
|
snapshotDate: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MONTHLY_STAT_COLORS = [
|
||||||
|
{ background: '#FDE9F4', pill: '#F8CDE2', bar: '#F9CFE3', icon: '#2F2965', label: '#5A648C' },
|
||||||
|
{ background: '#FFF3D6', pill: '#FFE3A4', bar: '#FFE0A6', icon: '#2F2965', label: '#5A648C' },
|
||||||
|
{ background: '#E3F5F3', pill: '#CBEAE4', bar: '#D7EEE8', icon: '#2F2965', label: '#5A648C' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function formatDurationShort(durationInSeconds: number): string {
|
||||||
|
const totalMinutes = Math.max(Math.round(durationInSeconds / 60), 1);
|
||||||
|
const hours = Math.floor(totalMinutes / 60);
|
||||||
|
const minutes = totalMinutes % 60;
|
||||||
|
|
||||||
|
if (hours > 0) {
|
||||||
|
return minutes > 0 ? `${hours}h${minutes}m` : `${hours}h`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${totalMinutes}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 扩展 dayjs 以支持 isBetween 插件
|
||||||
|
dayjs.extend(isBetween);
|
||||||
|
|
||||||
|
function computeMonthlyStats(workouts: WorkoutData[]): MonthlyStatsInfo | null {
|
||||||
|
const now = dayjs();
|
||||||
|
const monthStart = now.startOf('month');
|
||||||
|
const monthEnd = now.endOf('month');
|
||||||
|
|
||||||
|
const monthlyEntries = workouts.filter((workout) => {
|
||||||
|
const workoutDate = dayjs(workout.startDate || workout.endDate);
|
||||||
|
if (!workoutDate.isValid()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return workoutDate.isBetween(monthStart, monthEnd, 'day', '[]');
|
||||||
|
});
|
||||||
|
|
||||||
|
if (monthlyEntries.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const summaryMap = monthlyEntries.reduce<Record<string, ActivitySummary>>((acc, workout) => {
|
||||||
|
const type = workout.workoutActivityType;
|
||||||
|
if (type === undefined || type === null) {
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapKey = String(type);
|
||||||
|
|
||||||
|
if (!acc[mapKey]) {
|
||||||
|
acc[mapKey] = {
|
||||||
|
type,
|
||||||
|
duration: 0,
|
||||||
|
count: 0,
|
||||||
|
displayName: getWorkoutTypeDisplayName(type),
|
||||||
|
iconName: ICON_MAP[type as WorkoutActivityType] || 'run',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
acc[mapKey].duration += workout.duration || 0;
|
||||||
|
acc[mapKey].count += 1;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
const items = Object.values(summaryMap).sort((a, b) => b.duration - a.duration);
|
||||||
|
const totalDuration = monthlyEntries.reduce((sum, workout) => sum + (workout.duration || 0), 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
totalDuration,
|
||||||
|
totalCount: monthlyEntries.length,
|
||||||
|
monthStart: monthStart.format('YYYY-MM-DD'),
|
||||||
|
monthEnd: monthEnd.format('YYYY-MM-DD'),
|
||||||
|
snapshotDate: now.format('YYYY-MM-DD'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function getIntensityBadge(totalCalories?: number, durationInSeconds?: number) {
|
function getIntensityBadge(totalCalories?: number, durationInSeconds?: number) {
|
||||||
if (!totalCalories || !durationInSeconds) {
|
if (!totalCalories || !durationInSeconds) {
|
||||||
return { label: '低强度', color: '#7C85A3', background: '#E4E7F2' };
|
return { label: '低强度', color: '#7C85A3', background: '#E4E7F2' };
|
||||||
@@ -180,6 +274,14 @@ export default function WorkoutHistoryScreen() {
|
|||||||
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);
|
||||||
|
const [selectedWorkout, setSelectedWorkout] = useState<WorkoutData | null>(null);
|
||||||
|
const [isDetailVisible, setIsDetailVisible] = useState(false);
|
||||||
|
const [detailMetrics, setDetailMetrics] = useState<WorkoutDetailMetrics | null>(null);
|
||||||
|
const [detailLoading, setDetailLoading] = useState(false);
|
||||||
|
const [detailError, setDetailError] = useState<string | null>(null);
|
||||||
|
const [selectedIntensity, setSelectedIntensity] = useState<IntensityBadge | null>(null);
|
||||||
|
const [monthOccurrenceText, setMonthOccurrenceText] = useState<string | null>(null);
|
||||||
|
const [monthlyStats, setMonthlyStats] = useState<MonthlyStatsInfo | null>(null);
|
||||||
|
|
||||||
const loadHistory = useCallback(async () => {
|
const loadHistory = useCallback(async () => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
@@ -198,6 +300,7 @@ export default function WorkoutHistoryScreen() {
|
|||||||
if (!hasPermission) {
|
if (!hasPermission) {
|
||||||
setSections([]);
|
setSections([]);
|
||||||
setError('尚未授予健康数据权限');
|
setError('尚未授予健康数据权限');
|
||||||
|
setMonthlyStats(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,11 +309,13 @@ export default function WorkoutHistoryScreen() {
|
|||||||
const workouts = await fetchWorkoutsForDateRange(start.toDate(), end.toDate(), 200);
|
const workouts = await fetchWorkoutsForDateRange(start.toDate(), end.toDate(), 200);
|
||||||
const filteredWorkouts = workouts.filter((workout) => workout.duration && workout.duration > 0);
|
const filteredWorkouts = workouts.filter((workout) => workout.duration && workout.duration > 0);
|
||||||
|
|
||||||
|
setMonthlyStats(computeMonthlyStats(filteredWorkouts));
|
||||||
setSections(groupWorkouts(filteredWorkouts));
|
setSections(groupWorkouts(filteredWorkouts));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('加载锻炼历史失败:', err);
|
console.error('加载锻炼历史失败:', err);
|
||||||
setError('加载锻炼记录失败,请稍后再试');
|
setError('加载锻炼记录失败,请稍后再试');
|
||||||
setSections([]);
|
setSections([]);
|
||||||
|
setMonthlyStats(null);
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
@@ -233,12 +338,77 @@ export default function WorkoutHistoryScreen() {
|
|||||||
};
|
};
|
||||||
}, [loadHistory]);
|
}, [loadHistory]);
|
||||||
|
|
||||||
const headerComponent = useMemo(() => (
|
const headerComponent = useMemo(() => {
|
||||||
|
const statsItems = monthlyStats?.items.slice(0, 3) ?? [];
|
||||||
|
const monthEndDay = monthlyStats
|
||||||
|
? dayjs(monthlyStats.monthEnd).date()
|
||||||
|
: dayjs().endOf('month').date();
|
||||||
|
const snapshotLabel = monthlyStats
|
||||||
|
? dayjs(monthlyStats.snapshotDate).format('M月D日')
|
||||||
|
: dayjs().format('M月D日');
|
||||||
|
const overviewText = monthlyStats
|
||||||
|
? `截至${snapshotLabel},你已完成${monthlyStats.totalCount}次锻炼,累计${formatDurationShort(monthlyStats.totalDuration)}。`
|
||||||
|
: '本月还没有锻炼记录,动起来收集第一条吧!';
|
||||||
|
const periodText = `统计周期:1日 - ${monthEndDay}日(本月)`;
|
||||||
|
const maxDuration = statsItems[0]?.duration || 1;
|
||||||
|
|
||||||
|
return (
|
||||||
<View style={styles.headerContainer}>
|
<View style={styles.headerContainer}>
|
||||||
<Text style={styles.headerTitle}>历史</Text>
|
{/* <Text style={styles.headerTitle}>历史</Text>
|
||||||
<Text style={styles.headerSubtitle}>最近一个月的锻炼记录</Text>
|
<Text style={styles.headerSubtitle}>最近一个月的锻炼记录</Text> */}
|
||||||
|
|
||||||
|
<View style={styles.monthlyStatsWrapper}>
|
||||||
|
{/* <Text style={styles.monthlyStatsTitle}>统计</Text> */}
|
||||||
|
<View style={styles.monthlyStatsCardShell}>
|
||||||
|
<LinearGradient
|
||||||
|
colors={['#FFFFFF', '#F5F6FF']}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 1 }}
|
||||||
|
style={styles.monthlyStatsCard}
|
||||||
|
>
|
||||||
|
<Text style={styles.statSectionLabel}>锻炼时间</Text>
|
||||||
|
<Text style={styles.statPeriodText}>{periodText}</Text>
|
||||||
|
<Text style={styles.statDescription}>{overviewText}</Text>
|
||||||
|
|
||||||
|
{statsItems.length > 0 ? (
|
||||||
|
statsItems.map((item, index) => {
|
||||||
|
const palette = MONTHLY_STAT_COLORS[index % MONTHLY_STAT_COLORS.length];
|
||||||
|
const ratio = Math.max(Math.min(item.duration / maxDuration, 1), 0.18);
|
||||||
|
return (
|
||||||
|
<View key={String(item.type)} style={styles.summaryRowWrapper}>
|
||||||
|
<View style={[styles.summaryRowBackground, { backgroundColor: palette.background }]}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.summaryRowFill,
|
||||||
|
{ width: `${ratio * 100}%`, backgroundColor: palette.bar },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<View style={styles.summaryRowInner}>
|
||||||
|
<View style={[styles.summaryBadge, { backgroundColor: palette.pill }]}>
|
||||||
|
<MaterialCommunityIcons name={item.iconName} size={20} color={palette.icon} />
|
||||||
|
<Text style={[styles.summaryCount, { color: palette.icon }]}>X{item.count}</Text>
|
||||||
</View>
|
</View>
|
||||||
), []);
|
<View style={styles.summaryRowContent}>
|
||||||
|
<Text style={[styles.summaryDuration, { color: palette.icon }]}>{formatDurationShort(item.duration)}</Text>
|
||||||
|
<Text style={[styles.summaryActivity, { color: palette.label }]}>{item.displayName}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<View style={styles.statEmptyState}>
|
||||||
|
<MaterialCommunityIcons name="calendar-blank" size={20} color="#7C85A3" />
|
||||||
|
<Text style={styles.statEmptyText}>本月还没有锻炼数据</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</LinearGradient>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}, [monthlyStats]);
|
||||||
|
|
||||||
const emptyComponent = useMemo(() => (
|
const emptyComponent = useMemo(() => (
|
||||||
<View style={styles.emptyContainer}>
|
<View style={styles.emptyContainer}>
|
||||||
@@ -248,6 +418,77 @@ export default function WorkoutHistoryScreen() {
|
|||||||
</View>
|
</View>
|
||||||
), []);
|
), []);
|
||||||
|
|
||||||
|
const computeMonthlyOccurrenceText = useCallback((workout: WorkoutData): string | null => {
|
||||||
|
const workoutDate = dayjs(workout.startDate || workout.endDate);
|
||||||
|
if (!workoutDate.isValid() || sections.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sameMonthWorkouts = sections
|
||||||
|
.flatMap((section) => section.data)
|
||||||
|
.filter((entry) => {
|
||||||
|
const entryDate = dayjs(entry.startDate || entry.endDate);
|
||||||
|
return (
|
||||||
|
entryDate.isValid() &&
|
||||||
|
entryDate.isSame(workoutDate, 'month') &&
|
||||||
|
entry.workoutActivityType === workout.workoutActivityType
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const ascending = sameMonthWorkouts.some((entry) => entry.id === workout.id)
|
||||||
|
? sameMonthWorkouts
|
||||||
|
: [...sameMonthWorkouts, workout];
|
||||||
|
|
||||||
|
ascending.sort(
|
||||||
|
(a, b) =>
|
||||||
|
dayjs(a.startDate || a.endDate).valueOf() - dayjs(b.startDate || b.endDate).valueOf()
|
||||||
|
);
|
||||||
|
|
||||||
|
const index = ascending.findIndex((entry) => entry.id === workout.id);
|
||||||
|
if (index === -1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const activityLabel = getWorkoutTypeDisplayName(workout.workoutActivityType);
|
||||||
|
return `这是你${workoutDate.format('M月')}的第 ${index + 1} 次${activityLabel}。`;
|
||||||
|
}, [sections]);
|
||||||
|
|
||||||
|
const loadWorkoutDetail = useCallback(async (workout: WorkoutData) => {
|
||||||
|
setDetailLoading(true);
|
||||||
|
setDetailError(null);
|
||||||
|
try {
|
||||||
|
const metrics = await getWorkoutDetailMetrics(workout);
|
||||||
|
setDetailMetrics(metrics);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('加载锻炼详情失败:', err);
|
||||||
|
setDetailMetrics(null);
|
||||||
|
setDetailError('加载锻炼详情失败,请稍后再试');
|
||||||
|
} finally {
|
||||||
|
setDetailLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleWorkoutPress = useCallback((workout: WorkoutData) => {
|
||||||
|
const intensity = getIntensityBadge(workout.totalEnergyBurned, workout.duration || 0);
|
||||||
|
setSelectedIntensity(intensity);
|
||||||
|
setSelectedWorkout(workout);
|
||||||
|
setDetailMetrics(null);
|
||||||
|
setDetailError(null);
|
||||||
|
setMonthOccurrenceText(computeMonthlyOccurrenceText(workout));
|
||||||
|
setIsDetailVisible(true);
|
||||||
|
loadWorkoutDetail(workout);
|
||||||
|
}, [computeMonthlyOccurrenceText, loadWorkoutDetail]);
|
||||||
|
|
||||||
|
const handleRetryDetail = useCallback(() => {
|
||||||
|
if (selectedWorkout) {
|
||||||
|
loadWorkoutDetail(selectedWorkout);
|
||||||
|
}
|
||||||
|
}, [selectedWorkout, loadWorkoutDetail]);
|
||||||
|
|
||||||
|
const handleCloseDetail = useCallback(() => {
|
||||||
|
setIsDetailVisible(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const renderItem = useCallback(({ item }: { item: WorkoutData }) => {
|
const renderItem = useCallback(({ item }: { item: WorkoutData }) => {
|
||||||
const calories = Math.round(item.totalEnergyBurned || 0);
|
const calories = Math.round(item.totalEnergyBurned || 0);
|
||||||
const minutes = Math.max(Math.round((item.duration || 0) / 60), 1);
|
const minutes = Math.max(Math.round((item.duration || 0) / 60), 1);
|
||||||
@@ -257,7 +498,11 @@ export default function WorkoutHistoryScreen() {
|
|||||||
const activityLabel = getWorkoutTypeDisplayName(item.workoutActivityType);
|
const activityLabel = getWorkoutTypeDisplayName(item.workoutActivityType);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity style={styles.historyCard} activeOpacity={0.85} onPress={() => { }}>
|
<TouchableOpacity
|
||||||
|
style={styles.historyCard}
|
||||||
|
activeOpacity={0.85}
|
||||||
|
onPress={() => handleWorkoutPress(item)}
|
||||||
|
>
|
||||||
<View style={styles.cardIconWrapper}>
|
<View style={styles.cardIconWrapper}>
|
||||||
<MaterialCommunityIcons name={iconName} size={28} color="#5C55FF" />
|
<MaterialCommunityIcons name={iconName} size={28} color="#5C55FF" />
|
||||||
</View>
|
</View>
|
||||||
@@ -275,7 +520,7 @@ export default function WorkoutHistoryScreen() {
|
|||||||
{/* <Ionicons name="chevron-forward" size={20} color="#9AA4C4" /> */}
|
{/* <Ionicons name="chevron-forward" size={20} color="#9AA4C4" /> */}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
);
|
);
|
||||||
}, []);
|
}, [handleWorkoutPress]);
|
||||||
|
|
||||||
const renderSectionHeader = useCallback(({ section }: { section: WorkoutSection }) => (
|
const renderSectionHeader = useCallback(({ section }: { section: WorkoutSection }) => (
|
||||||
<Text style={styles.sectionHeader}>{section.title}</Text>
|
<Text style={styles.sectionHeader}>{section.title}</Text>
|
||||||
@@ -315,6 +560,17 @@ export default function WorkoutHistoryScreen() {
|
|||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
<WorkoutDetailModal
|
||||||
|
visible={isDetailVisible}
|
||||||
|
onClose={handleCloseDetail}
|
||||||
|
workout={selectedWorkout}
|
||||||
|
metrics={detailMetrics}
|
||||||
|
loading={detailLoading}
|
||||||
|
intensityBadge={selectedIntensity || undefined}
|
||||||
|
monthOccurrenceText={monthOccurrenceText || undefined}
|
||||||
|
onRetry={detailError ? handleRetryDetail : undefined}
|
||||||
|
errorMessage={detailError}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -342,6 +598,103 @@ const styles = StyleSheet.create({
|
|||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
color: '#677086',
|
color: '#677086',
|
||||||
},
|
},
|
||||||
|
monthlyStatsWrapper: {
|
||||||
|
},
|
||||||
|
monthlyStatsTitle: {
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#1F2355',
|
||||||
|
marginBottom: 14,
|
||||||
|
},
|
||||||
|
monthlyStatsCardShell: {
|
||||||
|
borderRadius: 28,
|
||||||
|
shadowColor: '#5460E54D',
|
||||||
|
shadowOffset: { width: 0, height: 8 },
|
||||||
|
shadowOpacity: 0.12,
|
||||||
|
shadowRadius: 18,
|
||||||
|
elevation: 8,
|
||||||
|
},
|
||||||
|
monthlyStatsCard: {
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
paddingVertical: 22,
|
||||||
|
borderRadius: 28,
|
||||||
|
overflow: 'hidden',
|
||||||
|
gap: 12,
|
||||||
|
},
|
||||||
|
statSectionLabel: {
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#8289A9',
|
||||||
|
},
|
||||||
|
statPeriodText: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#8C95B0',
|
||||||
|
},
|
||||||
|
statDescription: {
|
||||||
|
marginTop: 2,
|
||||||
|
fontSize: 13,
|
||||||
|
color: '#525A7A',
|
||||||
|
lineHeight: 18,
|
||||||
|
},
|
||||||
|
summaryRowWrapper: {
|
||||||
|
marginTop: 12,
|
||||||
|
},
|
||||||
|
summaryRowBackground: {
|
||||||
|
borderRadius: 24,
|
||||||
|
overflow: 'hidden',
|
||||||
|
position: 'relative',
|
||||||
|
},
|
||||||
|
summaryRowFill: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
},
|
||||||
|
summaryRowInner: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingVertical: 16,
|
||||||
|
paddingHorizontal: 18,
|
||||||
|
gap: 14,
|
||||||
|
},
|
||||||
|
summaryBadge: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
paddingVertical: 9,
|
||||||
|
borderRadius: 999,
|
||||||
|
gap: 6,
|
||||||
|
},
|
||||||
|
summaryRowContent: {
|
||||||
|
flex: 1,
|
||||||
|
gap: 4,
|
||||||
|
},
|
||||||
|
summaryCount: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#2F2965',
|
||||||
|
},
|
||||||
|
summaryDuration: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#2F2965',
|
||||||
|
},
|
||||||
|
summaryActivity: {
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: '500',
|
||||||
|
color: '#565F7F',
|
||||||
|
},
|
||||||
|
statEmptyState: {
|
||||||
|
marginTop: 14,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
statEmptyText: {
|
||||||
|
fontSize: 13,
|
||||||
|
color: '#7C85A3',
|
||||||
|
},
|
||||||
listContent: {
|
listContent: {
|
||||||
paddingBottom: 40,
|
paddingBottom: 40,
|
||||||
},
|
},
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
881
components/workout/WorkoutDetailModal.tsx
Normal file
881
components/workout/WorkoutDetailModal.tsx
Normal file
@@ -0,0 +1,881 @@
|
|||||||
|
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
Animated,
|
||||||
|
Dimensions,
|
||||||
|
Modal,
|
||||||
|
ScrollView,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
TouchableOpacity,
|
||||||
|
TouchableWithoutFeedback,
|
||||||
|
View,
|
||||||
|
} from 'react-native';
|
||||||
|
|
||||||
|
import {
|
||||||
|
HeartRateZoneStat,
|
||||||
|
WorkoutDetailMetrics,
|
||||||
|
} from '@/services/workoutDetail';
|
||||||
|
import {
|
||||||
|
getWorkoutTypeDisplayName,
|
||||||
|
WorkoutActivityType,
|
||||||
|
WorkoutData,
|
||||||
|
} from '@/utils/health';
|
||||||
|
|
||||||
|
export interface IntensityBadge {
|
||||||
|
label: string;
|
||||||
|
color: string;
|
||||||
|
background: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WorkoutDetailModalProps {
|
||||||
|
visible: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
workout: WorkoutData | null;
|
||||||
|
metrics: WorkoutDetailMetrics | null;
|
||||||
|
loading: boolean;
|
||||||
|
intensityBadge?: IntensityBadge;
|
||||||
|
monthOccurrenceText?: string;
|
||||||
|
onRetry?: () => void;
|
||||||
|
errorMessage?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SCREEN_HEIGHT = Dimensions.get('window').height;
|
||||||
|
const SHEET_MAX_HEIGHT = SCREEN_HEIGHT * 0.9;
|
||||||
|
|
||||||
|
const HEART_RATE_CHART_MAX_POINTS = 120;
|
||||||
|
|
||||||
|
export function WorkoutDetailModal({
|
||||||
|
visible,
|
||||||
|
onClose,
|
||||||
|
workout,
|
||||||
|
metrics,
|
||||||
|
loading,
|
||||||
|
intensityBadge,
|
||||||
|
monthOccurrenceText,
|
||||||
|
onRetry,
|
||||||
|
errorMessage,
|
||||||
|
}: WorkoutDetailModalProps) {
|
||||||
|
const animation = useRef(new Animated.Value(visible ? 1 : 0)).current;
|
||||||
|
const [isMounted, setIsMounted] = useState(visible);
|
||||||
|
const [showIntensityInfo, setShowIntensityInfo] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible) {
|
||||||
|
setIsMounted(true);
|
||||||
|
Animated.timing(animation, {
|
||||||
|
toValue: 1,
|
||||||
|
duration: 280,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}).start();
|
||||||
|
} else {
|
||||||
|
Animated.timing(animation, {
|
||||||
|
toValue: 0,
|
||||||
|
duration: 240,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}).start(({ finished }) => {
|
||||||
|
if (finished) {
|
||||||
|
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],
|
||||||
|
});
|
||||||
|
|
||||||
|
const activityName = workout
|
||||||
|
? getWorkoutTypeDisplayName(workout.workoutActivityType as WorkoutActivityType)
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const dateInfo = useMemo(() => {
|
||||||
|
if (!workout) {
|
||||||
|
return { title: '', subtitle: '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const date = dayjs(workout.startDate || workout.endDate);
|
||||||
|
if (!date.isValid()) {
|
||||||
|
return { title: '', subtitle: '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: date.format('M月D日'),
|
||||||
|
subtitle: date.format('YYYY年M月D日 dddd HH:mm'),
|
||||||
|
};
|
||||||
|
}, [workout]);
|
||||||
|
|
||||||
|
const heartRateChart = useMemo(() => {
|
||||||
|
if (!metrics?.heartRateSeries?.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortedSeries = metrics.heartRateSeries;
|
||||||
|
const trimmed = trimHeartRateSeries(sortedSeries);
|
||||||
|
|
||||||
|
const labels = trimmed.map((point, index) => {
|
||||||
|
if (
|
||||||
|
index === 0 ||
|
||||||
|
index === trimmed.length - 1 ||
|
||||||
|
index === Math.floor(trimmed.length / 2)
|
||||||
|
) {
|
||||||
|
return dayjs(point.timestamp).format('HH:mm');
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = trimmed.map((point) => Math.round(point.value));
|
||||||
|
|
||||||
|
return {
|
||||||
|
labels,
|
||||||
|
data,
|
||||||
|
};
|
||||||
|
}, [metrics?.heartRateSeries]);
|
||||||
|
|
||||||
|
const handleBackdropPress = () => {
|
||||||
|
if (!loading) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isMounted) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
transparent
|
||||||
|
visible={isMounted}
|
||||||
|
animationType="none"
|
||||||
|
onRequestClose={onClose}
|
||||||
|
>
|
||||||
|
<View style={styles.modalContainer}>
|
||||||
|
<TouchableWithoutFeedback onPress={handleBackdropPress}>
|
||||||
|
<Animated.View style={[styles.backdrop, { opacity: backdropOpacity }]} />
|
||||||
|
</TouchableWithoutFeedback>
|
||||||
|
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
styles.sheetContainer,
|
||||||
|
{
|
||||||
|
transform: [{ translateY }],
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<LinearGradient
|
||||||
|
colors={['#FFFFFF', '#F3F5FF']}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 0, y: 1 }}
|
||||||
|
style={styles.gradientBackground}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<View style={styles.handleWrapper}>
|
||||||
|
<View style={styles.handle} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.headerRow}>
|
||||||
|
<TouchableOpacity onPress={onClose} style={styles.headerIconButton} disabled={loading}>
|
||||||
|
<MaterialCommunityIcons name="chevron-down" size={26} color="#262A5D" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<View style={styles.headerTitleWrapper}>
|
||||||
|
<Text style={styles.headerTitle}>{dateInfo.title}</Text>
|
||||||
|
<Text style={styles.headerSubtitle}>{dateInfo.subtitle}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.headerSpacer} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.heroIconWrapper}>
|
||||||
|
<MaterialCommunityIcons name="run" size={160} color="#E8EAFE" />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<ScrollView
|
||||||
|
bounces={false}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
contentContainerStyle={styles.contentContainer}
|
||||||
|
>
|
||||||
|
<View style={styles.summaryCard}>
|
||||||
|
<View style={styles.summaryHeader}>
|
||||||
|
<Text style={styles.activityName}>{activityName}</Text>
|
||||||
|
{intensityBadge ? (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.intensityPill,
|
||||||
|
{ backgroundColor: intensityBadge.background },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text style={[styles.intensityPillText, { color: intensityBadge.color }]}>
|
||||||
|
{intensityBadge.label}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
<Text style={styles.summarySubtitle}>
|
||||||
|
{dayjs(workout?.startDate || workout?.endDate).format('YYYY年M月D日 dddd HH:mm')}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<View style={styles.loadingBlock}>
|
||||||
|
<ActivityIndicator color="#5C55FF" />
|
||||||
|
<Text style={styles.loadingLabel}>正在加载锻炼详情...</Text>
|
||||||
|
</View>
|
||||||
|
) : metrics ? (
|
||||||
|
<>
|
||||||
|
<View style={styles.metricsRow}>
|
||||||
|
<View style={styles.metricItem}>
|
||||||
|
<Text style={styles.metricTitle}>体能训练时间</Text>
|
||||||
|
<Text style={styles.metricValue}>{metrics.durationLabel}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.metricItem}>
|
||||||
|
<Text style={styles.metricTitle}>运动热量</Text>
|
||||||
|
<Text style={styles.metricValue}>
|
||||||
|
{metrics.calories != null ? `${metrics.calories} 千卡` : '--'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View style={styles.metricsRow}>
|
||||||
|
<View style={styles.metricItem}>
|
||||||
|
<View style={styles.metricTitleRow}>
|
||||||
|
<Text style={styles.metricTitle}>运动强度</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => setShowIntensityInfo(true)}
|
||||||
|
style={styles.metricInfoButton}
|
||||||
|
hitSlop={{ top: 6, bottom: 6, left: 6, right: 6 }}
|
||||||
|
>
|
||||||
|
<MaterialCommunityIcons name="information-outline" size={16} color="#7780AA" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.metricValue}>
|
||||||
|
{formatMetsValue(metrics.mets)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.metricItem}>
|
||||||
|
<Text style={styles.metricTitle}>平均心率</Text>
|
||||||
|
<Text style={styles.metricValue}>
|
||||||
|
{metrics.averageHeartRate != null ? `${metrics.averageHeartRate} 次/分` : '--'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
{monthOccurrenceText ? (
|
||||||
|
<Text style={styles.monthOccurrenceText}>{monthOccurrenceText}</Text>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<View style={styles.errorBlock}>
|
||||||
|
<Text style={styles.errorText}>
|
||||||
|
{errorMessage || '未能获取到完整的锻炼详情'}
|
||||||
|
</Text>
|
||||||
|
{onRetry ? (
|
||||||
|
<TouchableOpacity style={styles.retryButton} onPress={onRetry}>
|
||||||
|
<Text style={styles.retryButtonText}>重新加载</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.section}>
|
||||||
|
<View style={styles.sectionHeader}>
|
||||||
|
<Text style={styles.sectionTitle}>心率范围</Text>
|
||||||
|
<MaterialCommunityIcons name="help-circle-outline" size={16} color="#A0A8C8" />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<View style={styles.sectionLoading}>
|
||||||
|
<ActivityIndicator color="#5C55FF" />
|
||||||
|
</View>
|
||||||
|
) : metrics ? (
|
||||||
|
<>
|
||||||
|
<View style={styles.heartRateSummaryRow}>
|
||||||
|
<View style={styles.heartRateStat}>
|
||||||
|
<Text style={styles.statLabel}>平均心率</Text>
|
||||||
|
<Text style={styles.statValue}>
|
||||||
|
{metrics.averageHeartRate != null ? `${metrics.averageHeartRate}次/分` : '--'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.heartRateStat}>
|
||||||
|
<Text style={styles.statLabel}>最高心率</Text>
|
||||||
|
<Text style={styles.statValue}>
|
||||||
|
{metrics.maximumHeartRate != null ? `${metrics.maximumHeartRate}次/分` : '--'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.heartRateStat}>
|
||||||
|
<Text style={styles.statLabel}>最低心率</Text>
|
||||||
|
<Text style={styles.statValue}>
|
||||||
|
{metrics.minimumHeartRate != null ? `${metrics.minimumHeartRate}次/分` : '--'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{heartRateChart ? (
|
||||||
|
LineChart ? (
|
||||||
|
<View style={styles.chartWrapper}>
|
||||||
|
{/* @ts-ignore - react-native-chart-kit types are outdated */}
|
||||||
|
<LineChart
|
||||||
|
data={{
|
||||||
|
labels: heartRateChart.labels,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
data: heartRateChart.data,
|
||||||
|
color: () => '#5C55FF',
|
||||||
|
strokeWidth: 2,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}}
|
||||||
|
width={Dimensions.get('window').width - 72}
|
||||||
|
height={220}
|
||||||
|
fromZero={false}
|
||||||
|
yAxisSuffix="次/分"
|
||||||
|
withInnerLines
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
propsForBackgroundLines: {
|
||||||
|
strokeDasharray: '3,3',
|
||||||
|
stroke: '#E3E6F4',
|
||||||
|
strokeWidth: 1,
|
||||||
|
},
|
||||||
|
fillShadowGradientFromOpacity: 0.1,
|
||||||
|
fillShadowGradientToOpacity: 0.02,
|
||||||
|
}}
|
||||||
|
style={styles.chartStyle}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View style={styles.chartEmpty}>
|
||||||
|
<MaterialCommunityIcons name="chart-line-variant" size={32} color="#C5CBE2" />
|
||||||
|
<Text style={styles.chartEmptyText}>图表组件不可用,无法展示心率曲线</Text>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<View style={styles.chartEmpty}>
|
||||||
|
<MaterialCommunityIcons name="heart-off-outline" size={32} color="#C5CBE2" />
|
||||||
|
<Text style={styles.chartEmptyText}>暂无心率采样数据</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<View style={styles.sectionError}>
|
||||||
|
<Text style={styles.errorTextSmall}>
|
||||||
|
{errorMessage || '未获取到心率数据'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.section}>
|
||||||
|
<View style={styles.sectionHeader}>
|
||||||
|
<Text style={styles.sectionTitle}>心率训练区间</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<View style={styles.sectionLoading}>
|
||||||
|
<ActivityIndicator color="#5C55FF" />
|
||||||
|
</View>
|
||||||
|
) : metrics ? (
|
||||||
|
metrics.heartRateZones.map(renderHeartRateZone)
|
||||||
|
) : (
|
||||||
|
<Text style={styles.errorTextSmall}>暂无区间统计</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.homeIndicatorSpacer} />
|
||||||
|
</ScrollView>
|
||||||
|
</Animated.View>
|
||||||
|
{showIntensityInfo ? (
|
||||||
|
<Modal
|
||||||
|
transparent
|
||||||
|
visible={showIntensityInfo}
|
||||||
|
animationType="fade"
|
||||||
|
onRequestClose={() => setShowIntensityInfo(false)}
|
||||||
|
>
|
||||||
|
<TouchableWithoutFeedback onPress={() => setShowIntensityInfo(false)}>
|
||||||
|
<View style={styles.infoBackdrop}>
|
||||||
|
<TouchableWithoutFeedback onPress={() => { }}>
|
||||||
|
<View style={styles.intensityInfoSheet}>
|
||||||
|
<View style={styles.intensityHandle} />
|
||||||
|
<Text style={styles.intensityInfoTitle}>什么是运动强度?</Text>
|
||||||
|
<Text style={styles.intensityInfoText}>
|
||||||
|
运动强度是你完成一项任务所用的能量估算,是衡量锻炼和其他日常活动能耗强度的指标,单位为 MET(千卡/(千克·小时))。
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.intensityInfoText}>
|
||||||
|
因为每个人的代谢状况不同,MET 以身体的静息能耗作为参考,便于衡量不同活动的强度。
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.intensityInfoText}>
|
||||||
|
例如:散步(约 3 km/h)相当于 2 METs,意味着它需要消耗静息状态 2 倍的能量。
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.intensityInfoText}>
|
||||||
|
注:当设备未提供 METs 值时,系统会根据您的卡路里消耗和锻炼时长自动计算(使用70公斤估算体重)。
|
||||||
|
</Text>
|
||||||
|
<View style={styles.intensityFormula}>
|
||||||
|
<Text style={styles.intensityFormulaLabel}>运动强度计算公式</Text>
|
||||||
|
<Text style={styles.intensityFormulaValue}>METs = 活动能耗(千卡/小时) ÷ 静息能耗(1 千卡/小时)</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.intensityLegend}>
|
||||||
|
<View style={styles.intensityLegendRow}>
|
||||||
|
<Text style={styles.intensityLegendRange}>{'< 3'}</Text>
|
||||||
|
<Text style={[styles.intensityLegendLabel, styles.intensityLow]}>低强度活动</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.intensityLegendRow}>
|
||||||
|
<Text style={styles.intensityLegendRange}>3 - 6</Text>
|
||||||
|
<Text style={[styles.intensityLegendLabel, styles.intensityMedium]}>中强度活动</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.intensityLegendRow}>
|
||||||
|
<Text style={styles.intensityLegendRange}>{'≥ 6'}</Text>
|
||||||
|
<Text style={[styles.intensityLegendLabel, styles.intensityHigh]}>高强度活动</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</TouchableWithoutFeedback>
|
||||||
|
</View>
|
||||||
|
</TouchableWithoutFeedback>
|
||||||
|
</Modal>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式化 METs 值显示
|
||||||
|
function formatMetsValue(mets: number | null): string {
|
||||||
|
if (mets == null) {
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保留一位小数
|
||||||
|
const formattedMets = mets.toFixed(1);
|
||||||
|
return `${formattedMets} METs`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function trimHeartRateSeries(series: WorkoutDetailMetrics['heartRateSeries']) {
|
||||||
|
if (series.length <= HEART_RATE_CHART_MAX_POINTS) {
|
||||||
|
return series;
|
||||||
|
}
|
||||||
|
|
||||||
|
const step = Math.ceil(series.length / HEART_RATE_CHART_MAX_POINTS);
|
||||||
|
const reduced = series.filter((_, index) => index % step === 0);
|
||||||
|
|
||||||
|
if (reduced[reduced.length - 1] !== series[series.length - 1]) {
|
||||||
|
reduced.push(series[series.length - 1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return reduced;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderHeartRateZone(zone: HeartRateZoneStat) {
|
||||||
|
return (
|
||||||
|
<View key={zone.key} style={styles.zoneRow}>
|
||||||
|
<View style={[styles.zoneBar, { backgroundColor: `${zone.color}33` }]}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.zoneBarFill,
|
||||||
|
{
|
||||||
|
width: `${Math.min(zone.percentage, 100)}%`,
|
||||||
|
backgroundColor: zone.color,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View style={styles.zoneInfo}>
|
||||||
|
<Text style={styles.zoneLabel}>{zone.label}</Text>
|
||||||
|
<Text style={styles.zoneMeta}>
|
||||||
|
{zone.durationMinutes} 分钟 · {zone.rangeText}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lazy import to avoid circular dependency
|
||||||
|
let LineChart: any;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// eslint-disable-next-line global-require, @typescript-eslint/no-var-requires
|
||||||
|
LineChart = require('react-native-chart-kit').LineChart;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('未安装 react-native-chart-kit,心率图表将不会显示:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
modalContainer: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
},
|
||||||
|
backdrop: {
|
||||||
|
...StyleSheet.absoluteFillObject,
|
||||||
|
backgroundColor: '#10122599',
|
||||||
|
},
|
||||||
|
sheetContainer: {
|
||||||
|
maxHeight: SHEET_MAX_HEIGHT,
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
borderTopLeftRadius: 32,
|
||||||
|
borderTopRightRadius: 32,
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
gradientBackground: {
|
||||||
|
...StyleSheet.absoluteFillObject,
|
||||||
|
},
|
||||||
|
handleWrapper: {
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingTop: 16,
|
||||||
|
paddingBottom: 8,
|
||||||
|
},
|
||||||
|
handle: {
|
||||||
|
width: 42,
|
||||||
|
height: 5,
|
||||||
|
borderRadius: 3,
|
||||||
|
backgroundColor: '#D5D9EB',
|
||||||
|
},
|
||||||
|
headerRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
paddingBottom: 12,
|
||||||
|
},
|
||||||
|
headerIconButton: {
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
borderRadius: 20,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
headerTitleWrapper: {
|
||||||
|
flex: 1,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
headerTitle: {
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#1E2148',
|
||||||
|
},
|
||||||
|
headerSubtitle: {
|
||||||
|
marginTop: 4,
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#7E86A7',
|
||||||
|
},
|
||||||
|
heroIconWrapper: {
|
||||||
|
position: 'absolute',
|
||||||
|
right: -20,
|
||||||
|
top: 60,
|
||||||
|
},
|
||||||
|
contentContainer: {
|
||||||
|
paddingBottom: 40,
|
||||||
|
paddingHorizontal: 24,
|
||||||
|
paddingTop: 8,
|
||||||
|
},
|
||||||
|
summaryCard: {
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
borderRadius: 28,
|
||||||
|
padding: 20,
|
||||||
|
marginBottom: 22,
|
||||||
|
shadowColor: '#646CFF33',
|
||||||
|
shadowOffset: { width: 0, height: 10 },
|
||||||
|
shadowOpacity: 0.18,
|
||||||
|
shadowRadius: 22,
|
||||||
|
elevation: 8,
|
||||||
|
},
|
||||||
|
summaryHeader: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
},
|
||||||
|
activityName: {
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#1E2148',
|
||||||
|
},
|
||||||
|
intensityPill: {
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 4,
|
||||||
|
borderRadius: 999,
|
||||||
|
},
|
||||||
|
intensityPillText: {
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
summarySubtitle: {
|
||||||
|
marginTop: 8,
|
||||||
|
fontSize: 13,
|
||||||
|
color: '#848BA9',
|
||||||
|
},
|
||||||
|
metricsRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
marginTop: 20,
|
||||||
|
gap: 12,
|
||||||
|
},
|
||||||
|
metricItem: {
|
||||||
|
flex: 1,
|
||||||
|
borderRadius: 18,
|
||||||
|
backgroundColor: '#F5F6FF',
|
||||||
|
paddingVertical: 14,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
},
|
||||||
|
metricTitle: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#7A81A3',
|
||||||
|
marginBottom: 6,
|
||||||
|
},
|
||||||
|
metricTitleRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 4,
|
||||||
|
marginBottom: 6,
|
||||||
|
},
|
||||||
|
metricInfoButton: {
|
||||||
|
padding: 2,
|
||||||
|
},
|
||||||
|
metricValue: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#1E2148',
|
||||||
|
},
|
||||||
|
monthOccurrenceText: {
|
||||||
|
marginTop: 16,
|
||||||
|
fontSize: 13,
|
||||||
|
color: '#4B4F75',
|
||||||
|
},
|
||||||
|
loadingBlock: {
|
||||||
|
marginTop: 32,
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 10,
|
||||||
|
},
|
||||||
|
loadingLabel: {
|
||||||
|
fontSize: 13,
|
||||||
|
color: '#7E86A7',
|
||||||
|
},
|
||||||
|
errorBlock: {
|
||||||
|
marginTop: 24,
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 12,
|
||||||
|
},
|
||||||
|
errorText: {
|
||||||
|
fontSize: 13,
|
||||||
|
color: '#F65858',
|
||||||
|
},
|
||||||
|
retryButton: {
|
||||||
|
paddingHorizontal: 18,
|
||||||
|
paddingVertical: 8,
|
||||||
|
backgroundColor: '#5C55FF',
|
||||||
|
borderRadius: 16,
|
||||||
|
},
|
||||||
|
retryButtonText: {
|
||||||
|
color: '#FFFFFF',
|
||||||
|
fontWeight: '600',
|
||||||
|
fontSize: 13,
|
||||||
|
},
|
||||||
|
section: {
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
borderRadius: 24,
|
||||||
|
padding: 20,
|
||||||
|
marginBottom: 20,
|
||||||
|
shadowColor: '#10122514',
|
||||||
|
shadowOffset: { width: 0, height: 12 },
|
||||||
|
shadowOpacity: 0.08,
|
||||||
|
shadowRadius: 20,
|
||||||
|
elevation: 4,
|
||||||
|
},
|
||||||
|
sectionHeader: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
sectionTitle: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#1E2148',
|
||||||
|
},
|
||||||
|
sectionLoading: {
|
||||||
|
paddingVertical: 40,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
sectionError: {
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingVertical: 18,
|
||||||
|
},
|
||||||
|
errorTextSmall: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#7E86A7',
|
||||||
|
},
|
||||||
|
heartRateSummaryRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
marginBottom: 18,
|
||||||
|
},
|
||||||
|
heartRateStat: {
|
||||||
|
flex: 1,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
statLabel: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#7E86A7',
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
statValue: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#1E2148',
|
||||||
|
},
|
||||||
|
chartWrapper: {
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
chartStyle: {
|
||||||
|
marginLeft: -10,
|
||||||
|
},
|
||||||
|
chartEmpty: {
|
||||||
|
paddingVertical: 32,
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
chartEmptyText: {
|
||||||
|
fontSize: 13,
|
||||||
|
color: '#9CA3C6',
|
||||||
|
},
|
||||||
|
zoneRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 14,
|
||||||
|
gap: 12,
|
||||||
|
},
|
||||||
|
zoneBar: {
|
||||||
|
width: 110,
|
||||||
|
height: 12,
|
||||||
|
borderRadius: 999,
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
zoneBarFill: {
|
||||||
|
height: '100%',
|
||||||
|
borderRadius: 999,
|
||||||
|
},
|
||||||
|
zoneInfo: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
zoneLabel: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#1E2148',
|
||||||
|
},
|
||||||
|
zoneMeta: {
|
||||||
|
marginTop: 4,
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#7E86A7',
|
||||||
|
},
|
||||||
|
homeIndicatorSpacer: {
|
||||||
|
height: 28,
|
||||||
|
},
|
||||||
|
infoBackdrop: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: '#0F122080',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
},
|
||||||
|
intensityInfoSheet: {
|
||||||
|
margin: 20,
|
||||||
|
marginBottom: 34,
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
borderRadius: 28,
|
||||||
|
paddingHorizontal: 24,
|
||||||
|
paddingTop: 20,
|
||||||
|
paddingBottom: 28,
|
||||||
|
shadowColor: '#1F265933',
|
||||||
|
shadowOffset: { width: 0, height: 16 },
|
||||||
|
shadowOpacity: 0.25,
|
||||||
|
shadowRadius: 24,
|
||||||
|
},
|
||||||
|
intensityHandle: {
|
||||||
|
alignSelf: 'center',
|
||||||
|
width: 44,
|
||||||
|
height: 4,
|
||||||
|
borderRadius: 999,
|
||||||
|
backgroundColor: '#E1E4F3',
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
intensityInfoTitle: {
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#1E2148',
|
||||||
|
marginBottom: 12,
|
||||||
|
},
|
||||||
|
intensityInfoText: {
|
||||||
|
fontSize: 13,
|
||||||
|
color: '#4C5074',
|
||||||
|
lineHeight: 20,
|
||||||
|
marginBottom: 10,
|
||||||
|
},
|
||||||
|
intensityFormula: {
|
||||||
|
marginTop: 12,
|
||||||
|
marginBottom: 18,
|
||||||
|
backgroundColor: '#F4F6FE',
|
||||||
|
borderRadius: 18,
|
||||||
|
paddingVertical: 14,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
},
|
||||||
|
intensityFormulaLabel: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#7E86A7',
|
||||||
|
marginBottom: 6,
|
||||||
|
},
|
||||||
|
intensityFormulaValue: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#1F2355',
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
|
intensityLegend: {
|
||||||
|
borderTopWidth: StyleSheet.hairlineWidth,
|
||||||
|
borderTopColor: '#E3E6F4',
|
||||||
|
paddingTop: 16,
|
||||||
|
gap: 14,
|
||||||
|
},
|
||||||
|
intensityLegendRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
intensityLegendRange: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#1E2148',
|
||||||
|
},
|
||||||
|
intensityLegendLabel: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
intensityLow: {
|
||||||
|
color: '#5C84FF',
|
||||||
|
},
|
||||||
|
intensityMedium: {
|
||||||
|
color: '#2CCAA0',
|
||||||
|
},
|
||||||
|
intensityHigh: {
|
||||||
|
color: '#FF6767',
|
||||||
|
},
|
||||||
|
headerSpacer: {
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
@@ -25,7 +25,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.0.16</string>
|
<string>1.0.17</string>
|
||||||
<key>CFBundleSignature</key>
|
<key>CFBundleSignature</key>
|
||||||
<string>????</string>
|
<string>????</string>
|
||||||
<key>CFBundleURLTypes</key>
|
<key>CFBundleURLTypes</key>
|
||||||
@@ -78,8 +78,6 @@
|
|||||||
<key>UIBackgroundModes</key>
|
<key>UIBackgroundModes</key>
|
||||||
<array>
|
<array>
|
||||||
<string>processing</string>
|
<string>processing</string>
|
||||||
<string>fetch</string>
|
|
||||||
<string>remote-notification</string>
|
|
||||||
</array>
|
</array>
|
||||||
<key>UILaunchStoryboardName</key>
|
<key>UILaunchStoryboardName</key>
|
||||||
<string>SplashScreen</string>
|
<string>SplashScreen</string>
|
||||||
|
|||||||
@@ -4,11 +4,8 @@
|
|||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "expo start",
|
"start": "expo start",
|
||||||
"reset-project": "node ./scripts/reset-project.js",
|
|
||||||
"android": "expo run:android",
|
|
||||||
"ios": "expo run:ios",
|
"ios": "expo run:ios",
|
||||||
"ios-device": "expo run:ios --device",
|
"ios-device": "expo run:ios --device",
|
||||||
"web": "expo start --web",
|
|
||||||
"lint": "expo lint"
|
"lint": "expo lint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -274,9 +274,7 @@ export class BackgroundTaskManager {
|
|||||||
|
|
||||||
log.info('[BackgroundTask] 任务未注册, 开始注册...');
|
log.info('[BackgroundTask] 任务未注册, 开始注册...');
|
||||||
// 注册后台任务
|
// 注册后台任务
|
||||||
await BackgroundTask.registerTaskAsync(BACKGROUND_TASK_IDENTIFIER, {
|
await BackgroundTask.registerTaskAsync(BACKGROUND_TASK_IDENTIFIER);
|
||||||
minimumInterval: 15 * 2,
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
this.isInitialized = true;
|
this.isInitialized = true;
|
||||||
|
|||||||
279
services/workoutDetail.ts
Normal file
279
services/workoutDetail.ts
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
|
import {
|
||||||
|
fetchHeartRateSamplesForRange,
|
||||||
|
HeartRateSample,
|
||||||
|
WorkoutData,
|
||||||
|
} from '@/utils/health';
|
||||||
|
|
||||||
|
export interface WorkoutHeartRatePoint {
|
||||||
|
timestamp: string;
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HeartRateZoneStat {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
color: string;
|
||||||
|
rangeText: string;
|
||||||
|
durationMinutes: number;
|
||||||
|
percentage: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkoutDetailMetrics {
|
||||||
|
durationLabel: string;
|
||||||
|
durationSeconds: number;
|
||||||
|
calories: number | null;
|
||||||
|
mets: number | null;
|
||||||
|
averageHeartRate: number | null;
|
||||||
|
maximumHeartRate: number | null;
|
||||||
|
minimumHeartRate: number | null;
|
||||||
|
heartRateSeries: WorkoutHeartRatePoint[];
|
||||||
|
heartRateZones: HeartRateZoneStat[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_SAMPLE_GAP_SECONDS = 5;
|
||||||
|
|
||||||
|
const HEART_RATE_ZONES = [
|
||||||
|
{
|
||||||
|
key: 'warmup',
|
||||||
|
label: '热身放松',
|
||||||
|
color: '#9FC7FF',
|
||||||
|
rangeText: '<100次/分',
|
||||||
|
min: 0,
|
||||||
|
max: 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'fatburn',
|
||||||
|
label: '燃烧脂肪',
|
||||||
|
color: '#5ED7B0',
|
||||||
|
rangeText: '100-119次/分',
|
||||||
|
min: 100,
|
||||||
|
max: 120,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'aerobic',
|
||||||
|
label: '有氧运动',
|
||||||
|
color: '#FFB74D',
|
||||||
|
rangeText: '120-149次/分',
|
||||||
|
min: 120,
|
||||||
|
max: 150,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'anaerobic',
|
||||||
|
label: '无氧运动',
|
||||||
|
color: '#FF826E',
|
||||||
|
rangeText: '150-169次/分',
|
||||||
|
min: 150,
|
||||||
|
max: 170,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'max',
|
||||||
|
label: '身体极限',
|
||||||
|
color: '#F2A4D8',
|
||||||
|
rangeText: '≥170次/分',
|
||||||
|
min: 170,
|
||||||
|
max: Infinity,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export async function getWorkoutDetailMetrics(
|
||||||
|
workout: WorkoutData
|
||||||
|
): Promise<WorkoutDetailMetrics> {
|
||||||
|
const start = dayjs(workout.startDate || workout.endDate);
|
||||||
|
const end = dayjs(workout.endDate || workout.startDate);
|
||||||
|
|
||||||
|
const safeStart = start.isValid() ? start : dayjs();
|
||||||
|
const safeEnd = end.isValid() ? end : safeStart.add(workout.duration || 0, 'second');
|
||||||
|
|
||||||
|
const heartRateSamples = await fetchHeartRateSamplesForRange(
|
||||||
|
safeStart.toDate(),
|
||||||
|
safeEnd.toDate()
|
||||||
|
);
|
||||||
|
|
||||||
|
const heartRateSeries = normalizeHeartRateSeries(heartRateSamples);
|
||||||
|
const {
|
||||||
|
average: averageHeartRate,
|
||||||
|
max: maximumHeartRate,
|
||||||
|
min: minimumHeartRate,
|
||||||
|
} = calculateHeartRateStats(heartRateSeries, workout);
|
||||||
|
|
||||||
|
const heartRateZones = calculateHeartRateZones(heartRateSeries);
|
||||||
|
const durationSeconds = Math.max(Math.round(workout.duration || 0), 0);
|
||||||
|
const durationLabel = formatDuration(durationSeconds);
|
||||||
|
const calories = workout.totalEnergyBurned != null
|
||||||
|
? Math.round(workout.totalEnergyBurned)
|
||||||
|
: null;
|
||||||
|
const mets = extractMetsFromMetadata(workout.metadata) || calculateMetsFromWorkoutData(workout);
|
||||||
|
|
||||||
|
return {
|
||||||
|
durationLabel,
|
||||||
|
durationSeconds,
|
||||||
|
calories,
|
||||||
|
mets,
|
||||||
|
averageHeartRate,
|
||||||
|
maximumHeartRate,
|
||||||
|
minimumHeartRate,
|
||||||
|
heartRateSeries,
|
||||||
|
heartRateZones,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(durationSeconds: number): string {
|
||||||
|
const hours = Math.floor(durationSeconds / 3600);
|
||||||
|
const minutes = Math.floor((durationSeconds % 3600) / 60);
|
||||||
|
const seconds = durationSeconds % 60;
|
||||||
|
|
||||||
|
const parts = [hours, minutes, seconds].map((unit) =>
|
||||||
|
unit.toString().padStart(2, '0')
|
||||||
|
);
|
||||||
|
|
||||||
|
return parts.join(':');
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeHeartRateSeries(samples: HeartRateSample[]): WorkoutHeartRatePoint[] {
|
||||||
|
return samples
|
||||||
|
.map((sample) => ({
|
||||||
|
timestamp: sample.endDate || sample.startDate,
|
||||||
|
value: Number(sample.value),
|
||||||
|
}))
|
||||||
|
.filter((point) => dayjs(point.timestamp).isValid() && Number.isFinite(point.value))
|
||||||
|
.sort((a, b) => dayjs(a.timestamp).valueOf() - dayjs(b.timestamp).valueOf());
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateHeartRateStats(
|
||||||
|
series: WorkoutHeartRatePoint[],
|
||||||
|
workout: WorkoutData
|
||||||
|
) {
|
||||||
|
if (!series.length) {
|
||||||
|
const fallback = workout.averageHeartRate ?? null;
|
||||||
|
return {
|
||||||
|
average: fallback,
|
||||||
|
max: fallback,
|
||||||
|
min: fallback,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const values = series.map((point) => point.value);
|
||||||
|
const sum = values.reduce((acc, value) => acc + value, 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
average: Math.round(sum / values.length),
|
||||||
|
max: Math.round(Math.max(...values)),
|
||||||
|
min: Math.round(Math.min(...values)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateHeartRateZones(series: WorkoutHeartRatePoint[]): HeartRateZoneStat[] {
|
||||||
|
if (!series.length) {
|
||||||
|
return HEART_RATE_ZONES.map((zone) => ({
|
||||||
|
key: zone.key,
|
||||||
|
label: zone.label,
|
||||||
|
color: zone.color,
|
||||||
|
rangeText: zone.rangeText,
|
||||||
|
durationMinutes: 0,
|
||||||
|
percentage: 0,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const durations: Record<string, number> = HEART_RATE_ZONES.reduce((acc, zone) => {
|
||||||
|
acc[zone.key] = 0;
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, number>);
|
||||||
|
|
||||||
|
for (let i = 0; i < series.length; i++) {
|
||||||
|
const current = series[i];
|
||||||
|
const next = series[i + 1];
|
||||||
|
|
||||||
|
const currentTime = dayjs(current.timestamp);
|
||||||
|
let nextTime = next ? dayjs(next.timestamp) : currentTime.add(DEFAULT_SAMPLE_GAP_SECONDS, 'second');
|
||||||
|
|
||||||
|
if (!nextTime.isValid() || nextTime.isBefore(currentTime)) {
|
||||||
|
nextTime = currentTime.add(DEFAULT_SAMPLE_GAP_SECONDS, 'second');
|
||||||
|
}
|
||||||
|
|
||||||
|
const durationSeconds = Math.max(nextTime.diff(currentTime, 'second'), DEFAULT_SAMPLE_GAP_SECONDS);
|
||||||
|
const zone = getZoneForValue(current.value);
|
||||||
|
durations[zone.key] += durationSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalSeconds = Object.values(durations).reduce((acc, value) => acc + value, 0) || 1;
|
||||||
|
|
||||||
|
return HEART_RATE_ZONES.map((zone) => {
|
||||||
|
const zoneSeconds = durations[zone.key] || 0;
|
||||||
|
const minutes = Math.round(zoneSeconds / 60);
|
||||||
|
const percentage = Math.round((zoneSeconds / totalSeconds) * 100);
|
||||||
|
|
||||||
|
return {
|
||||||
|
key: zone.key,
|
||||||
|
label: zone.label,
|
||||||
|
color: zone.color,
|
||||||
|
rangeText: zone.rangeText,
|
||||||
|
durationMinutes: minutes,
|
||||||
|
percentage,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getZoneForValue(value: number) {
|
||||||
|
return (
|
||||||
|
HEART_RATE_ZONES.find((zone) => value >= zone.min && value < zone.max) ||
|
||||||
|
HEART_RATE_ZONES[HEART_RATE_ZONES.length - 1]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractMetsFromMetadata(metadata: Record<string, any>): number | null {
|
||||||
|
if (!metadata) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidates = [
|
||||||
|
metadata.HKAverageMETs,
|
||||||
|
metadata.averageMETs,
|
||||||
|
metadata.mets,
|
||||||
|
metadata.METs,
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (candidate !== undefined && candidate !== null && Number.isFinite(Number(candidate))) {
|
||||||
|
return Math.round(Number(candidate) * 10) / 10;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateMetsFromWorkoutData(workout: WorkoutData): number | null {
|
||||||
|
// 如果没有卡路里消耗或持续时间数据,无法计算 METs
|
||||||
|
if (!workout.totalEnergyBurned || !workout.duration || workout.duration <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算活动能耗(千卡/小时)
|
||||||
|
const durationInHours = workout.duration / 3600; // 将秒转换为小时
|
||||||
|
const activeEnergyBurnedPerHour = workout.totalEnergyBurned / durationInHours;
|
||||||
|
|
||||||
|
// 使用估算的平均体重(70公斤)来计算 METs
|
||||||
|
// METs = 活动能量消耗(千卡/小时) ÷ 体重(千克)
|
||||||
|
const estimatedWeightKg = 70; // 成年人平均估算体重
|
||||||
|
const mets = activeEnergyBurnedPerHour / estimatedWeightKg;
|
||||||
|
|
||||||
|
// 验证计算结果的合理性
|
||||||
|
// 一般成年人的静息代谢率约为 1 MET,日常活动通常在 1-12 METs 范围内
|
||||||
|
// 高强度运动可能超过 12 METs,但很少超过 20 METs
|
||||||
|
if (mets < 0.5 || mets > 25) {
|
||||||
|
console.warn('计算出的 METs 值可能不合理:', {
|
||||||
|
mets,
|
||||||
|
totalEnergyBurned: workout.totalEnergyBurned,
|
||||||
|
duration: workout.duration,
|
||||||
|
durationInHours,
|
||||||
|
activeEnergyBurnedPerHour,
|
||||||
|
estimatedWeightKg
|
||||||
|
});
|
||||||
|
// 即使值可能不合理,也返回计算结果,但记录警告
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保留一位小数
|
||||||
|
return Math.round(mets * 10) / 10;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -26,6 +26,18 @@ export interface WorkoutData {
|
|||||||
metadata: Record<string, any>;
|
metadata: Record<string, any>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface HeartRateSample {
|
||||||
|
id: string;
|
||||||
|
startDate: string;
|
||||||
|
endDate: string;
|
||||||
|
value: number;
|
||||||
|
source?: {
|
||||||
|
name: string;
|
||||||
|
bundleIdentifier: string;
|
||||||
|
};
|
||||||
|
metadata?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
// 锻炼记录查询选项
|
// 锻炼记录查询选项
|
||||||
export interface WorkoutOptions extends HealthDataOptions {
|
export interface WorkoutOptions extends HealthDataOptions {
|
||||||
limit?: number; // 默认10条
|
limit?: number; // 默认10条
|
||||||
@@ -770,6 +782,44 @@ export async function fetchOxygenSaturation(options: HealthDataOptions): Promise
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchHeartRateSamplesForRange(
|
||||||
|
startDate: Date,
|
||||||
|
endDate: Date,
|
||||||
|
limit: number = 2000
|
||||||
|
): Promise<HeartRateSample[]> {
|
||||||
|
try {
|
||||||
|
const options = {
|
||||||
|
startDate: dayjs(startDate).toISOString(),
|
||||||
|
endDate: dayjs(endDate).toISOString(),
|
||||||
|
limit,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await HealthKitManager.getHeartRateSamples(options);
|
||||||
|
|
||||||
|
if (result && Array.isArray(result.data)) {
|
||||||
|
const samples: HeartRateSample[] = result.data
|
||||||
|
.filter((sample: any) => sample && typeof sample.value === 'number' && !Number.isNaN(sample.value))
|
||||||
|
.map((sample: any) => ({
|
||||||
|
id: sample.id,
|
||||||
|
startDate: sample.startDate,
|
||||||
|
endDate: sample.endDate,
|
||||||
|
value: Number(sample.value),
|
||||||
|
source: sample.source,
|
||||||
|
metadata: sample.metadata,
|
||||||
|
}));
|
||||||
|
|
||||||
|
logSuccess('锻炼心率采样', { count: samples.length, startDate: options.startDate, endDate: options.endDate });
|
||||||
|
return samples;
|
||||||
|
}
|
||||||
|
|
||||||
|
logWarning('锻炼心率采样', '为空或格式错误');
|
||||||
|
return [];
|
||||||
|
} catch (error) {
|
||||||
|
logError('锻炼心率采样', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchHeartRate(options: HealthDataOptions): Promise<number | null> {
|
async function fetchHeartRate(options: HealthDataOptions): Promise<number | null> {
|
||||||
try {
|
try {
|
||||||
const result = await HealthKitManager.getHeartRateSamples(options);
|
const result = await HealthKitManager.getHeartRateSamples(options);
|
||||||
|
|||||||
Reference in New Issue
Block a user