Files
digital-pilates/app/workout/history.tsx
richarjiang d43d8c692f feat(workout): 重构锻炼模块并新增详细数据展示
- 移除旧的锻炼会话页面和布局文件
- 新增锻炼详情模态框组件,支持心率区间、运动强度等详细数据展示
- 优化锻炼历史页面,增加月度统计卡片和交互式详情查看
- 新增锻炼详情服务,提供心率分析、METs计算等功能
- 更新应用版本至1.0.17并调整iOS后台任务配置
- 添加项目规则文档,明确React Native开发规范
2025-10-11 17:20:51 +08:00

801 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 { 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(totalCalories?: number, durationInSeconds?: number) {
if (!totalCalories || !durationInSeconds) {
return { label: '低强度', color: '#7C85A3', background: '#E4E7F2' };
}
const minutes = Math.max(durationInSeconds / 60, 1);
const caloriesPerMinute = totalCalories / minutes;
if (caloriesPerMinute >= 9) {
return { label: '高强度', color: '#F85959', background: '#FFE6E6' };
}
if (caloriesPerMinute >= 5) {
return { label: '中强度', color: '#0EAF71', background: '#E4F6EF' };
}
return { label: '低强度', 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 [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);
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('尚未授予健康数据权限');
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('加载锻炼历史失败:', err);
setError('加载锻炼记录失败,请稍后再试');
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
? `截至${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}>
<MaterialCommunityIcons name="calendar-blank" size={40} color="#9AA4C4" />
<Text style={styles.emptyText}></Text>
<Text style={styles.emptySubText}></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 `这是你${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);
const intensity = getIntensityBadge(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}>{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}>{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="锻炼历史" variant="minimal" transparent={true} />
{isLoading ? (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color="#5C55FF" />
<Text style={styles.loadingText}>...</Text>
</View>
) : (
<SectionList
style={styles.sectionList}
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}></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',
},
});