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.
837 lines
27 KiB
TypeScript
837 lines
27 KiB
TypeScript
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 { useLocalSearchParams } from 'expo-router';
|
|
import React, { useCallback, useMemo, useState } from 'react';
|
|
import {
|
|
ActivityIndicator,
|
|
SectionList,
|
|
StyleSheet,
|
|
Text,
|
|
TouchableOpacity,
|
|
View
|
|
} from 'react-native';
|
|
|
|
import { HeaderBar } from '@/components/ui/HeaderBar';
|
|
import { IntensityBadge, WorkoutDetailModal } from '@/components/workout/WorkoutDetailModal';
|
|
|
|
import { useI18n } from '@/hooks/useI18n';
|
|
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
|
|
import { getWorkoutDetailMetrics, WorkoutDetailMetrics } from '@/services/workoutDetail';
|
|
import {
|
|
addHealthPermissionListener,
|
|
checkHealthPermissionStatus,
|
|
ensureHealthPermissions,
|
|
fetchWorkoutsForDateRange,
|
|
getHealthPermissionStatus,
|
|
getWorkoutTypeDisplayName,
|
|
HealthPermissionStatus,
|
|
removeHealthPermissionListener,
|
|
WorkoutActivityType,
|
|
WorkoutData,
|
|
} from '@/utils/health';
|
|
|
|
type WorkoutSection = {
|
|
title: string;
|
|
data: WorkoutData[];
|
|
};
|
|
|
|
const ICON_MAP: Partial<Record<WorkoutActivityType, keyof typeof MaterialCommunityIcons.glyphMap>> = {
|
|
// 球类运动
|
|
[WorkoutActivityType.AmericanFootball]: 'football',
|
|
[WorkoutActivityType.Archery]: 'target',
|
|
[WorkoutActivityType.AustralianFootball]: 'football',
|
|
[WorkoutActivityType.Badminton]: 'tennis',
|
|
[WorkoutActivityType.Baseball]: 'baseball',
|
|
[WorkoutActivityType.Basketball]: 'basketball',
|
|
[WorkoutActivityType.Bowling]: 'bowling',
|
|
[WorkoutActivityType.Boxing]: 'boxing-glove',
|
|
[WorkoutActivityType.Cricket]: 'cricket',
|
|
[WorkoutActivityType.Fencing]: 'sword',
|
|
[WorkoutActivityType.Golf]: 'golf',
|
|
[WorkoutActivityType.Handball]: 'basketball',
|
|
[WorkoutActivityType.Hockey]: 'hockey-sticks',
|
|
[WorkoutActivityType.Lacrosse]: 'tennis',
|
|
[WorkoutActivityType.Racquetball]: 'tennis',
|
|
[WorkoutActivityType.Soccer]: 'soccer',
|
|
[WorkoutActivityType.Softball]: 'baseball',
|
|
[WorkoutActivityType.Squash]: 'tennis',
|
|
[WorkoutActivityType.TableTennis]: 'table-tennis',
|
|
[WorkoutActivityType.Tennis]: 'tennis',
|
|
[WorkoutActivityType.Volleyball]: 'volleyball',
|
|
[WorkoutActivityType.WaterPolo]: 'swim',
|
|
[WorkoutActivityType.Pickleball]: 'tennis',
|
|
|
|
// 水上运动
|
|
[WorkoutActivityType.Swimming]: 'swim',
|
|
[WorkoutActivityType.Sailing]: 'sail-boat',
|
|
[WorkoutActivityType.SurfingSports]: 'waves',
|
|
[WorkoutActivityType.WaterFitness]: 'swim',
|
|
[WorkoutActivityType.WaterSports]: 'swim',
|
|
[WorkoutActivityType.UnderwaterDiving]: 'swim',
|
|
|
|
// 跑步和步行
|
|
[WorkoutActivityType.Running]: 'run',
|
|
[WorkoutActivityType.Walking]: 'walk',
|
|
[WorkoutActivityType.Hiking]: 'hiking',
|
|
[WorkoutActivityType.StairClimbing]: 'stairs',
|
|
[WorkoutActivityType.Stairs]: 'stairs',
|
|
|
|
// 骑行
|
|
[WorkoutActivityType.Cycling]: 'bike',
|
|
[WorkoutActivityType.HandCycling]: 'bike',
|
|
|
|
// 滑雪和滑冰
|
|
[WorkoutActivityType.CrossCountrySkiing]: 'ski',
|
|
[WorkoutActivityType.DownhillSkiing]: 'ski',
|
|
[WorkoutActivityType.Snowboarding]: 'snowboard',
|
|
[WorkoutActivityType.SkatingSports]: 'skateboarding',
|
|
[WorkoutActivityType.SnowSports]: 'ski',
|
|
|
|
// 力量训练
|
|
[WorkoutActivityType.FunctionalStrengthTraining]: 'weight-lifter',
|
|
[WorkoutActivityType.TraditionalStrengthTraining]: 'dumbbell',
|
|
[WorkoutActivityType.CrossTraining]: 'arm-flex',
|
|
[WorkoutActivityType.CoreTraining]: 'arm-flex',
|
|
|
|
// 有氧运动
|
|
[WorkoutActivityType.Elliptical]: 'bike',
|
|
[WorkoutActivityType.Rowing]: 'rowing',
|
|
[WorkoutActivityType.MixedCardio]: 'heart-pulse',
|
|
[WorkoutActivityType.MixedMetabolicCardioTraining]: 'heart-pulse',
|
|
[WorkoutActivityType.HighIntensityIntervalTraining]: 'run-fast',
|
|
[WorkoutActivityType.JumpRope]: 'skip-forward',
|
|
[WorkoutActivityType.StepTraining]: 'stairs',
|
|
|
|
// 舞蹈和身心训练
|
|
[WorkoutActivityType.Dance]: 'music',
|
|
[WorkoutActivityType.DanceInspiredTraining]: 'music',
|
|
[WorkoutActivityType.CardioDance]: 'music',
|
|
[WorkoutActivityType.SocialDance]: 'music',
|
|
[WorkoutActivityType.Yoga]: 'meditation',
|
|
[WorkoutActivityType.MindAndBody]: 'meditation',
|
|
[WorkoutActivityType.TaiChi]: 'meditation',
|
|
[WorkoutActivityType.Pilates]: 'meditation',
|
|
[WorkoutActivityType.Barre]: 'meditation',
|
|
[WorkoutActivityType.Flexibility]: 'meditation',
|
|
[WorkoutActivityType.Cooldown]: 'meditation',
|
|
[WorkoutActivityType.PreparationAndRecovery]: 'meditation',
|
|
|
|
// 户外运动
|
|
[WorkoutActivityType.Climbing]: 'hiking',
|
|
[WorkoutActivityType.EquestrianSports]: 'horse',
|
|
[WorkoutActivityType.Fishing]: 'target',
|
|
[WorkoutActivityType.Hunting]: 'target',
|
|
[WorkoutActivityType.PaddleSports]: 'rowing',
|
|
|
|
// 综合运动
|
|
[WorkoutActivityType.SwimBikeRun]: 'run-fast',
|
|
[WorkoutActivityType.Transition]: 'swap-horizontal-variant',
|
|
[WorkoutActivityType.Play]: 'gamepad-variant',
|
|
[WorkoutActivityType.FitnessGaming]: 'gamepad-variant',
|
|
[WorkoutActivityType.DiscSports]: 'target',
|
|
|
|
// 其他
|
|
[WorkoutActivityType.Other]: 'arm-flex',
|
|
[WorkoutActivityType.MartialArts]: 'karate',
|
|
[WorkoutActivityType.Kickboxing]: 'boxing-glove',
|
|
[WorkoutActivityType.Gymnastics]: 'human',
|
|
[WorkoutActivityType.TrackAndField]: 'run-fast',
|
|
[WorkoutActivityType.WheelchairWalkPace]: 'wheelchair',
|
|
[WorkoutActivityType.WheelchairRunPace]: 'wheelchair',
|
|
[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(t: (key: string, options?: any) => string, totalCalories?: number, durationInSeconds?: number): { label: string; color: string; background: string } {
|
|
if (!totalCalories || !durationInSeconds) {
|
|
return { label: t('workoutHistory.intensity.low'), color: '#7C85A3', background: '#E4E7F2' };
|
|
}
|
|
|
|
const minutes = Math.max(durationInSeconds / 60, 1);
|
|
const caloriesPerMinute = totalCalories / minutes;
|
|
|
|
if (caloriesPerMinute >= 9) {
|
|
return { label: t('workoutHistory.intensity.high'), color: '#F85959', background: '#FFE6E6' };
|
|
}
|
|
|
|
if (caloriesPerMinute >= 5) {
|
|
return { label: t('workoutHistory.intensity.medium'), color: '#0EAF71', background: '#E4F6EF' };
|
|
}
|
|
|
|
return { label: t('workoutHistory.intensity.low'), color: '#5966FF', background: '#E7EBFF' };
|
|
}
|
|
|
|
function groupWorkouts(workouts: WorkoutData[]): WorkoutSection[] {
|
|
const grouped = workouts.reduce<Record<string, WorkoutData[]>>((acc, workout) => {
|
|
const dateKey = dayjs(workout.startDate || workout.endDate).format('YYYY-MM-DD');
|
|
if (!acc[dateKey]) {
|
|
acc[dateKey] = [];
|
|
}
|
|
acc[dateKey].push(workout);
|
|
return acc;
|
|
}, {});
|
|
|
|
return Object.keys(grouped)
|
|
.sort((a, b) => dayjs(b).valueOf() - dayjs(a).valueOf())
|
|
.map((dateKey) => ({
|
|
title: dayjs(dateKey).format('M月D日'), // 保持中文格式,因为这是日期格式
|
|
data: grouped[dateKey]
|
|
.sort((a, b) => dayjs(b.startDate || b.endDate).valueOf() - dayjs(a.startDate || a.endDate).valueOf()),
|
|
}));
|
|
}
|
|
|
|
export default function WorkoutHistoryScreen() {
|
|
const { t } = useI18n();
|
|
const { workoutId: workoutIdParam } = useLocalSearchParams<{ workoutId?: string | string[] }>();
|
|
const [sections, setSections] = useState<WorkoutSection[]>([]);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
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 [pendingWorkoutId, setPendingWorkoutId] = useState<string | null>(null);
|
|
|
|
const safeAreaTop = useSafeAreaTop();
|
|
|
|
React.useEffect(() => {
|
|
if (!workoutIdParam) {
|
|
return;
|
|
}
|
|
const idParam = Array.isArray(workoutIdParam) ? workoutIdParam[0] : workoutIdParam;
|
|
if (idParam) {
|
|
setPendingWorkoutId(idParam);
|
|
}
|
|
}, [workoutIdParam]);
|
|
|
|
const loadHistory = useCallback(async () => {
|
|
setIsLoading(true);
|
|
setError(null);
|
|
try {
|
|
let permissionStatus = getHealthPermissionStatus();
|
|
if (permissionStatus !== HealthPermissionStatus.Authorized) {
|
|
permissionStatus = await checkHealthPermissionStatus(true);
|
|
}
|
|
|
|
let hasPermission = permissionStatus === HealthPermissionStatus.Authorized;
|
|
if (!hasPermission) {
|
|
hasPermission = await ensureHealthPermissions();
|
|
}
|
|
|
|
if (!hasPermission) {
|
|
setSections([]);
|
|
setError(t('workoutHistory.error.permissionDenied'));
|
|
setMonthlyStats(null);
|
|
return;
|
|
}
|
|
|
|
const end = dayjs();
|
|
const start = end.subtract(1, 'month');
|
|
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('Failed to load workout history:', err);
|
|
setError(t('workoutHistory.error.loadFailed'));
|
|
setSections([]);
|
|
setMonthlyStats(null);
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
useFocusEffect(
|
|
useCallback(() => {
|
|
loadHistory();
|
|
}, [loadHistory])
|
|
);
|
|
|
|
React.useEffect(() => {
|
|
const handlePermissionGranted = () => {
|
|
loadHistory();
|
|
};
|
|
|
|
addHealthPermissionListener('permissionGranted', handlePermissionGranted);
|
|
return () => {
|
|
removeHealthPermissionListener('permissionGranted', handlePermissionGranted);
|
|
};
|
|
}, [loadHistory]);
|
|
|
|
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
|
|
? t('workoutHistory.monthlyStats.overviewWithStats', { date: snapshotLabel, count: monthlyStats.totalCount, duration: formatDurationShort(monthlyStats.totalDuration) })
|
|
: t('workoutHistory.monthlyStats.overviewEmpty');
|
|
const periodText = t('workoutHistory.monthlyStats.periodText', { day: 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}>{t('workoutHistory.monthlyStats.title')}</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}>{t('workoutHistory.monthlyStats.emptyData')}</Text>
|
|
</View>
|
|
)}
|
|
</LinearGradient>
|
|
</View>
|
|
</View>
|
|
</View>
|
|
);
|
|
}, [monthlyStats]);
|
|
|
|
const emptyComponent = useMemo(() => (
|
|
<View style={styles.emptyContainer}>
|
|
<MaterialCommunityIcons name="calendar-blank" size={40} color="#9AA4C4" />
|
|
<Text style={styles.emptyText}>{t('workoutHistory.empty.title')}</Text>
|
|
<Text style={styles.emptySubText}>{t('workoutHistory.empty.subtitle')}</Text>
|
|
</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 t('workoutHistory.monthOccurrence', { month: workoutDate.format('M月'), index: index + 1, activity: activityLabel });
|
|
}, [sections]);
|
|
|
|
const loadWorkoutDetail = useCallback(async (workout: WorkoutData) => {
|
|
setDetailLoading(true);
|
|
setDetailError(null);
|
|
try {
|
|
const metrics = await getWorkoutDetailMetrics(workout);
|
|
setDetailMetrics(metrics);
|
|
} catch (err) {
|
|
console.error('Failed to load workout details:', err);
|
|
setDetailMetrics(null);
|
|
setDetailError(t('workoutHistory.error.detailLoadFailed'));
|
|
} finally {
|
|
setDetailLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
const handleWorkoutPress = useCallback((workout: WorkoutData) => {
|
|
const intensity = getIntensityBadge(t, workout.totalEnergyBurned, workout.duration || 0);
|
|
setSelectedIntensity(intensity);
|
|
setSelectedWorkout(workout);
|
|
setDetailMetrics(null);
|
|
setDetailError(null);
|
|
setMonthOccurrenceText(computeMonthlyOccurrenceText(workout));
|
|
setIsDetailVisible(true);
|
|
loadWorkoutDetail(workout);
|
|
}, [computeMonthlyOccurrenceText, loadWorkoutDetail]);
|
|
|
|
React.useEffect(() => {
|
|
if (!pendingWorkoutId || isLoading) {
|
|
return;
|
|
}
|
|
|
|
const allWorkouts = sections.flatMap((section) => section.data);
|
|
const targetWorkout = allWorkouts.find((workout) => workout.id === pendingWorkoutId);
|
|
|
|
if (targetWorkout) {
|
|
handleWorkoutPress(targetWorkout);
|
|
}
|
|
|
|
// 清理待处理状态,避免重复触发
|
|
setPendingWorkoutId(null);
|
|
}, [pendingWorkoutId, isLoading, sections, handleWorkoutPress]);
|
|
|
|
const handleRetryDetail = useCallback(() => {
|
|
if (selectedWorkout) {
|
|
loadWorkoutDetail(selectedWorkout);
|
|
}
|
|
}, [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);
|
|
const intensity = getIntensityBadge(t, item.totalEnergyBurned, item.duration || 0);
|
|
const iconName = ICON_MAP[item.workoutActivityType as WorkoutActivityType] || 'arm-flex';
|
|
const time = dayjs(item.startDate || item.endDate).format('HH:mm');
|
|
const activityLabel = getWorkoutTypeDisplayName(item.workoutActivityType);
|
|
|
|
return (
|
|
<TouchableOpacity
|
|
style={styles.historyCard}
|
|
activeOpacity={0.85}
|
|
onPress={() => handleWorkoutPress(item)}
|
|
>
|
|
<View style={styles.cardIconWrapper}>
|
|
<MaterialCommunityIcons name={iconName} size={28} color="#5C55FF" />
|
|
</View>
|
|
|
|
<View style={styles.cardContent}>
|
|
<View style={styles.cardTitleRow}>
|
|
<Text style={styles.cardTitle}>{t('workoutHistory.historyCard.calories', { calories, minutes })}</Text>
|
|
<View style={[styles.intensityBadge, { backgroundColor: intensity.background }]}>
|
|
<Text style={[styles.intensityText, { color: intensity.color }]}>{intensity.label}</Text>
|
|
</View>
|
|
</View>
|
|
<Text style={styles.cardSubtitle}>{t('workoutHistory.historyCard.activityTime', { activity: activityLabel, time })}</Text>
|
|
</View>
|
|
|
|
{/* <Ionicons name="chevron-forward" size={20} color="#9AA4C4" /> */}
|
|
</TouchableOpacity>
|
|
);
|
|
}, [handleWorkoutPress]);
|
|
|
|
const renderSectionHeader = useCallback(({ section }: { section: WorkoutSection }) => (
|
|
<Text style={styles.sectionHeader}>{section.title}</Text>
|
|
), []);
|
|
|
|
return (
|
|
<View style={styles.safeArea}>
|
|
<LinearGradient
|
|
colors={["#F3F5FF", "#FFFFFF"]}
|
|
style={StyleSheet.absoluteFill}
|
|
/>
|
|
<HeaderBar title={t('workoutHistory.title')} variant="minimal" transparent={true} />
|
|
{isLoading ? (
|
|
<View style={styles.loadingContainer}>
|
|
<ActivityIndicator size="large" color="#5C55FF" />
|
|
<Text style={styles.loadingText}>{t('workoutHistory.loading')}</Text>
|
|
</View>
|
|
) : (
|
|
<SectionList
|
|
style={[styles.sectionList, {
|
|
paddingTop: safeAreaTop
|
|
}]}
|
|
sections={sections}
|
|
keyExtractor={(item) => item.id}
|
|
renderItem={renderItem}
|
|
renderSectionHeader={renderSectionHeader}
|
|
ListHeaderComponent={headerComponent}
|
|
ListEmptyComponent={error ? (
|
|
<View style={styles.emptyContainer}>
|
|
<MaterialCommunityIcons name="alert-circle" size={40} color="#F85959" />
|
|
<Text style={[styles.emptyText, { color: '#F85959' }]}>{error}</Text>
|
|
<TouchableOpacity style={styles.retryButton} onPress={loadHistory}>
|
|
<Text style={styles.retryText}>{t('workoutHistory.retry')}</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
) : emptyComponent}
|
|
contentContainerStyle={styles.listContent}
|
|
stickySectionHeadersEnabled={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>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
safeArea: {
|
|
flex: 1,
|
|
backgroundColor: 'transparent',
|
|
},
|
|
sectionList: {
|
|
flex: 1,
|
|
},
|
|
headerContainer: {
|
|
paddingHorizontal: 20,
|
|
paddingTop: 12,
|
|
paddingBottom: 16,
|
|
},
|
|
headerTitle: {
|
|
fontSize: 26,
|
|
fontWeight: '700',
|
|
color: '#1F2355',
|
|
marginBottom: 6,
|
|
},
|
|
headerSubtitle: {
|
|
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,
|
|
},
|
|
sectionHeader: {
|
|
fontSize: 14,
|
|
color: '#8087A2',
|
|
fontWeight: '600',
|
|
marginTop: 18,
|
|
paddingHorizontal: 20,
|
|
},
|
|
historyCard: {
|
|
marginTop: 12,
|
|
marginHorizontal: 16,
|
|
paddingVertical: 18,
|
|
paddingHorizontal: 18,
|
|
backgroundColor: '#FFFFFF',
|
|
borderRadius: 26,
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
shadowColor: '#5460E54D',
|
|
shadowOffset: { width: 0, height: 8 },
|
|
shadowOpacity: 0.1,
|
|
shadowRadius: 16,
|
|
elevation: 6,
|
|
},
|
|
cardIconWrapper: {
|
|
width: 46,
|
|
height: 46,
|
|
borderRadius: 23,
|
|
backgroundColor: '#EEF0FF',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
marginRight: 14,
|
|
},
|
|
cardContent: {
|
|
flex: 1,
|
|
},
|
|
cardTitleRow: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
},
|
|
cardTitle: {
|
|
fontSize: 16,
|
|
fontWeight: '700',
|
|
color: '#1F2355',
|
|
flexShrink: 1,
|
|
},
|
|
intensityBadge: {
|
|
marginLeft: 8,
|
|
paddingHorizontal: 8,
|
|
paddingVertical: 3,
|
|
borderRadius: 10,
|
|
},
|
|
intensityText: {
|
|
fontSize: 12,
|
|
fontWeight: '600',
|
|
},
|
|
cardSubtitle: {
|
|
marginTop: 8,
|
|
fontSize: 13,
|
|
color: '#6B7693',
|
|
},
|
|
loadingContainer: {
|
|
flex: 1,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
gap: 12,
|
|
},
|
|
loadingText: {
|
|
fontSize: 14,
|
|
color: '#596182',
|
|
},
|
|
emptyContainer: {
|
|
marginTop: 60,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
paddingHorizontal: 32,
|
|
gap: 12,
|
|
},
|
|
emptyText: {
|
|
fontSize: 16,
|
|
fontWeight: '600',
|
|
color: '#596182',
|
|
},
|
|
emptySubText: {
|
|
fontSize: 13,
|
|
color: '#8F96AF',
|
|
textAlign: 'center',
|
|
lineHeight: 18,
|
|
},
|
|
retryButton: {
|
|
marginTop: 8,
|
|
paddingHorizontal: 18,
|
|
paddingVertical: 8,
|
|
borderRadius: 16,
|
|
backgroundColor: '#5C55FF',
|
|
},
|
|
retryText: {
|
|
color: '#FFFFFF',
|
|
fontSize: 13,
|
|
fontWeight: '600',
|
|
},
|
|
});
|