feat(workout): 重构锻炼模块并新增详细数据展示
- 移除旧的锻炼会话页面和布局文件 - 新增锻炼详情模态框组件,支持心率区间、运动强度等详细数据展示 - 优化锻炼历史页面,增加月度统计卡片和交互式详情查看 - 新增锻炼详情服务,提供心率分析、METs计算等功能 - 更新应用版本至1.0.17并调整iOS后台任务配置 - 添加项目规则文档,明确React Native开发规范
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { useFocusEffect } from '@react-navigation/native';
|
||||
import dayjs from 'dayjs';
|
||||
import isBetween from 'dayjs/plugin/isBetween';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import {
|
||||
@@ -13,7 +14,9 @@ import {
|
||||
} from 'react-native';
|
||||
|
||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { IntensityBadge, WorkoutDetailModal } from '@/components/workout/WorkoutDetailModal';
|
||||
|
||||
import { getWorkoutDetailMetrics, WorkoutDetailMetrics } from '@/services/workoutDetail';
|
||||
import {
|
||||
addHealthPermissionListener,
|
||||
checkHealthPermissionStatus,
|
||||
@@ -138,6 +141,97 @@ const ICON_MAP: Partial<Record<WorkoutActivityType, keyof typeof MaterialCommuni
|
||||
[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) {
|
||||
if (!totalCalories || !durationInSeconds) {
|
||||
return { label: '低强度', color: '#7C85A3', background: '#E4E7F2' };
|
||||
@@ -180,6 +274,14 @@ export default function WorkoutHistoryScreen() {
|
||||
const [sections, setSections] = useState<WorkoutSection[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
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 () => {
|
||||
setIsLoading(true);
|
||||
@@ -198,6 +300,7 @@ export default function WorkoutHistoryScreen() {
|
||||
if (!hasPermission) {
|
||||
setSections([]);
|
||||
setError('尚未授予健康数据权限');
|
||||
setMonthlyStats(null);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -206,11 +309,13 @@ export default function WorkoutHistoryScreen() {
|
||||
const workouts = await fetchWorkoutsForDateRange(start.toDate(), end.toDate(), 200);
|
||||
const filteredWorkouts = workouts.filter((workout) => workout.duration && workout.duration > 0);
|
||||
|
||||
setMonthlyStats(computeMonthlyStats(filteredWorkouts));
|
||||
setSections(groupWorkouts(filteredWorkouts));
|
||||
} catch (err) {
|
||||
console.error('加载锻炼历史失败:', err);
|
||||
setError('加载锻炼记录失败,请稍后再试');
|
||||
setSections([]);
|
||||
setMonthlyStats(null);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@@ -233,12 +338,77 @@ export default function WorkoutHistoryScreen() {
|
||||
};
|
||||
}, [loadHistory]);
|
||||
|
||||
const headerComponent = useMemo(() => (
|
||||
<View style={styles.headerContainer}>
|
||||
<Text style={styles.headerTitle}>历史</Text>
|
||||
<Text style={styles.headerSubtitle}>最近一个月的锻炼记录</Text>
|
||||
</View>
|
||||
), []);
|
||||
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}>
|
||||
{/* <Text style={styles.headerTitle}>历史</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 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(() => (
|
||||
<View style={styles.emptyContainer}>
|
||||
@@ -248,6 +418,77 @@ export default function WorkoutHistoryScreen() {
|
||||
</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 calories = Math.round(item.totalEnergyBurned || 0);
|
||||
const minutes = Math.max(Math.round((item.duration || 0) / 60), 1);
|
||||
@@ -257,7 +498,11 @@ export default function WorkoutHistoryScreen() {
|
||||
const activityLabel = getWorkoutTypeDisplayName(item.workoutActivityType);
|
||||
|
||||
return (
|
||||
<TouchableOpacity style={styles.historyCard} activeOpacity={0.85} onPress={() => { }}>
|
||||
<TouchableOpacity
|
||||
style={styles.historyCard}
|
||||
activeOpacity={0.85}
|
||||
onPress={() => handleWorkoutPress(item)}
|
||||
>
|
||||
<View style={styles.cardIconWrapper}>
|
||||
<MaterialCommunityIcons name={iconName} size={28} color="#5C55FF" />
|
||||
</View>
|
||||
@@ -275,7 +520,7 @@ export default function WorkoutHistoryScreen() {
|
||||
{/* <Ionicons name="chevron-forward" size={20} color="#9AA4C4" /> */}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}, []);
|
||||
}, [handleWorkoutPress]);
|
||||
|
||||
const renderSectionHeader = useCallback(({ section }: { section: WorkoutSection }) => (
|
||||
<Text style={styles.sectionHeader}>{section.title}</Text>
|
||||
@@ -315,6 +560,17 @@ export default function WorkoutHistoryScreen() {
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -342,6 +598,103 @@ const styles = StyleSheet.create({
|
||||
fontSize: 14,
|
||||
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: {
|
||||
paddingBottom: 40,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user