- 新增 i18n 翻译资源,覆盖睡眠、饮水、体重、锻炼、用药 AI 识别、步数、健身圆环、基础代谢及设置等核心模块 - 重构相关页面及组件(如 SleepDetail, WaterDetail, WorkoutHistory 等)使用 `useI18n` 钩子替换硬编码文本 - 升级 `utils/date` 工具库与 `DateSelector` 组件,支持基于语言环境的日期格式化与显示 - 完善登录页、注销流程及权限申请弹窗的双语提示信息 - 优化部分页面的 UI 细节与字体样式以适配多语言显示
808 lines
26 KiB
TypeScript
808 lines
26 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 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 [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 safeAreaTop = useSafeAreaTop();
|
|
|
|
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]);
|
|
|
|
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',
|
|
},
|
|
});
|