- 新增 i18n 翻译资源,覆盖睡眠、饮水、体重、锻炼、用药 AI 识别、步数、健身圆环、基础代谢及设置等核心模块 - 重构相关页面及组件(如 SleepDetail, WaterDetail, WorkoutHistory 等)使用 `useI18n` 钩子替换硬编码文本 - 升级 `utils/date` 工具库与 `DateSelector` 组件,支持基于语言环境的日期格式化与显示 - 完善登录页、注销流程及权限申请弹窗的双语提示信息 - 优化部分页面的 UI 细节与字体样式以适配多语言显示
335 lines
10 KiB
TypeScript
335 lines
10 KiB
TypeScript
import { Colors } from '@/constants/Colors';
|
||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||
import { formatTime, getSleepStageColor, SleepStage, type SleepSample } from '@/utils/sleepHealthKit';
|
||
import { Ionicons } from '@expo/vector-icons';
|
||
import dayjs from 'dayjs';
|
||
import React, { useMemo } from 'react';
|
||
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||
import Svg, { Rect, Text as SvgText } from 'react-native-svg';
|
||
|
||
import { StyleProp, ViewStyle } from 'react-native';
|
||
|
||
export type SleepStageTimelineProps = {
|
||
sleepSamples: SleepSample[];
|
||
bedtime: string;
|
||
wakeupTime: string;
|
||
onInfoPress?: () => void;
|
||
hideHeader?: boolean;
|
||
style?: StyleProp<ViewStyle>;
|
||
};
|
||
|
||
export const SleepStageTimeline = ({
|
||
sleepSamples,
|
||
bedtime,
|
||
wakeupTime,
|
||
onInfoPress,
|
||
hideHeader = false,
|
||
style
|
||
}: SleepStageTimelineProps) => {
|
||
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
||
const colorTokens = Colors[theme];
|
||
|
||
// 图表尺寸参数
|
||
const containerWidth = 320;
|
||
const chartPadding = 25; // 左右边距,为时间标签预留空间
|
||
const chartWidth = containerWidth - chartPadding * 2;
|
||
const chartHeight = 80;
|
||
const timelineHeight = 32;
|
||
const timelineY = 24;
|
||
const timeScaleY = timelineY + timelineHeight + 16;
|
||
|
||
// 计算时间范围和刻度
|
||
const { timelineData, timeLabels } = useMemo(() => {
|
||
if (!bedtime || !wakeupTime || sleepSamples.length === 0) {
|
||
return { timelineData: [], timeLabels: [] };
|
||
}
|
||
|
||
const startTime = dayjs(bedtime);
|
||
const endTime = dayjs(wakeupTime);
|
||
const totalMinutes = endTime.diff(startTime, 'minute');
|
||
|
||
// 过滤相关睡眠阶段,排除InBed
|
||
const relevantSamples = sleepSamples.filter(sample =>
|
||
sample.value !== SleepStage.InBed
|
||
);
|
||
|
||
// 转换样本数据为时间轴数据
|
||
const data = relevantSamples.map(sample => {
|
||
const sampleStart = dayjs(sample.startDate);
|
||
const sampleEnd = dayjs(sample.endDate);
|
||
|
||
const startOffset = sampleStart.diff(startTime, 'minute');
|
||
const duration = sampleEnd.diff(sampleStart, 'minute');
|
||
|
||
const x = Math.max(0, (startOffset / totalMinutes) * chartWidth) + chartPadding;
|
||
const width = Math.max(2, (duration / totalMinutes) * chartWidth);
|
||
|
||
return {
|
||
x,
|
||
width,
|
||
stage: sample.value,
|
||
color: getSleepStageColor(sample.value)
|
||
};
|
||
});
|
||
|
||
// 智能生成时间标签,避免重合
|
||
const labels = [];
|
||
const minLabelSpacing = 50; // 最小标签间距(像素)
|
||
|
||
// 总是显示起始时间
|
||
labels.push({
|
||
time: startTime.format('HH:mm'),
|
||
x: chartPadding
|
||
});
|
||
|
||
// 根据睡眠总时长动态调整时间间隔
|
||
const sleepDurationHours = totalMinutes / 60;
|
||
let timeStepMinutes;
|
||
|
||
if (sleepDurationHours <= 4) {
|
||
timeStepMinutes = 60; // 1小时间隔
|
||
} else if (sleepDurationHours <= 8) {
|
||
timeStepMinutes = 120; // 2小时间隔
|
||
} else {
|
||
timeStepMinutes = 180; // 3小时间隔
|
||
}
|
||
|
||
// 添加中间时间标签,确保不重合
|
||
let currentTime = startTime;
|
||
let stepCount = 1;
|
||
|
||
while (currentTime.add(timeStepMinutes * stepCount, 'minute').isBefore(endTime)) {
|
||
const stepTime = currentTime.add(timeStepMinutes * stepCount, 'minute');
|
||
const x = (stepTime.diff(startTime, 'minute') / totalMinutes) * chartWidth + chartPadding;
|
||
|
||
// 检查与前一个标签的间距
|
||
const lastLabel = labels[labels.length - 1];
|
||
if (x - lastLabel.x >= minLabelSpacing && x <= containerWidth - chartPadding) {
|
||
labels.push({
|
||
time: stepTime.format('HH:mm'),
|
||
x: x
|
||
});
|
||
}
|
||
|
||
stepCount++;
|
||
}
|
||
|
||
// 总是显示结束时间,但要确保与前一个标签有足够间距
|
||
const endX = containerWidth - chartPadding;
|
||
const lastLabel = labels[labels.length - 1];
|
||
if (endX - lastLabel.x >= minLabelSpacing) {
|
||
labels.push({
|
||
time: endTime.format('HH:mm'),
|
||
x: endX
|
||
});
|
||
} else {
|
||
// 如果空间不够,替换最后一个标签为结束时间
|
||
labels[labels.length - 1] = {
|
||
time: endTime.format('HH:mm'),
|
||
x: endX
|
||
};
|
||
}
|
||
|
||
return { timelineData: data, timeLabels: labels };
|
||
}, [sleepSamples, bedtime, wakeupTime, chartWidth]);
|
||
|
||
// 如果没有数据,显示空状态
|
||
if (timelineData.length === 0) {
|
||
return (
|
||
<View style={[styles.container, { backgroundColor: colorTokens.background }, style]}>
|
||
{!hideHeader && (
|
||
<View style={styles.header}>
|
||
<Text style={[styles.title, { color: colorTokens.text }]}>睡眠阶段图</Text>
|
||
{onInfoPress && (
|
||
<TouchableOpacity style={styles.infoButton} onPress={onInfoPress}>
|
||
<Ionicons name="help-circle-outline" size={20} color={colorTokens.textSecondary} />
|
||
</TouchableOpacity>
|
||
)}
|
||
</View>
|
||
)}
|
||
<View style={styles.emptyState}>
|
||
<Text style={[styles.emptyText, { color: colorTokens.textSecondary }]}>
|
||
暂无睡眠阶段数据
|
||
</Text>
|
||
</View>
|
||
</View>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<View style={[styles.container, { backgroundColor: colorTokens.background }, style]}>
|
||
{/* 标题栏 */}
|
||
{!hideHeader && (
|
||
<View style={styles.header}>
|
||
<Text style={[styles.title, { color: colorTokens.text }]}>睡眠阶段图</Text>
|
||
{onInfoPress && (
|
||
<TouchableOpacity style={styles.infoButton} onPress={onInfoPress}>
|
||
<Ionicons name="help-circle-outline" size={20} color={colorTokens.textSecondary} />
|
||
</TouchableOpacity>
|
||
)}
|
||
</View>
|
||
)}
|
||
|
||
{/* 睡眠时间范围 */}
|
||
<View style={styles.timeRange}>
|
||
<View style={styles.timePoint}>
|
||
<Text style={[styles.timeLabel, { color: colorTokens.textSecondary }]}>入睡</Text>
|
||
<Text style={[styles.timeValue, { color: colorTokens.text }]}>
|
||
{formatTime(bedtime)}
|
||
</Text>
|
||
</View>
|
||
<View style={styles.timePoint}>
|
||
<Text style={[styles.timeLabel, { color: colorTokens.textSecondary }]}>起床</Text>
|
||
<Text style={[styles.timeValue, { color: colorTokens.text }]}>
|
||
{formatTime(wakeupTime)}
|
||
</Text>
|
||
</View>
|
||
</View>
|
||
|
||
{/* SVG 图表 */}
|
||
<View style={styles.chartContainer}>
|
||
<Svg width={containerWidth} height={chartHeight}>
|
||
{/* 绘制睡眠阶段条形图 */}
|
||
{timelineData.map((segment, index) => (
|
||
<Rect
|
||
key={index}
|
||
x={segment.x}
|
||
y={timelineY}
|
||
width={segment.width}
|
||
height={timelineHeight}
|
||
fill={segment.color}
|
||
rx={2}
|
||
/>
|
||
))}
|
||
|
||
{/* 绘制时间刻度标签 */}
|
||
{timeLabels.map((label, index) => (
|
||
<React.Fragment key={index}>
|
||
{/* 刻度线 */}
|
||
<Rect
|
||
x={label.x - 0.5}
|
||
y={timelineY + timelineHeight}
|
||
width={1}
|
||
height={6}
|
||
fill={colorTokens.border}
|
||
/>
|
||
{/* 时间标签 */}
|
||
<SvgText
|
||
x={label.x}
|
||
y={timeScaleY}
|
||
fontSize={11}
|
||
fill={colorTokens.textSecondary}
|
||
textAnchor="middle"
|
||
>
|
||
{label.time}
|
||
</SvgText>
|
||
</React.Fragment>
|
||
))}
|
||
</Svg>
|
||
</View>
|
||
|
||
{/* 图例 */}
|
||
<View style={styles.legend}>
|
||
<View style={styles.legendRow}>
|
||
<View style={styles.legendItem}>
|
||
<View style={[styles.legendDot, { backgroundColor: getSleepStageColor(SleepStage.Deep) }]} />
|
||
<Text style={[styles.legendText, { color: colorTokens.textSecondary }]}>深度睡眠</Text>
|
||
</View>
|
||
<View style={styles.legendItem}>
|
||
<View style={[styles.legendDot, { backgroundColor: getSleepStageColor(SleepStage.Core) }]} />
|
||
<Text style={[styles.legendText, { color: colorTokens.textSecondary }]}>核心睡眠</Text>
|
||
</View>
|
||
</View>
|
||
<View style={styles.legendRow}>
|
||
<View style={styles.legendItem}>
|
||
<View style={[styles.legendDot, { backgroundColor: getSleepStageColor(SleepStage.REM) }]} />
|
||
<Text style={[styles.legendText, { color: colorTokens.textSecondary }]}>快速眼动</Text>
|
||
</View>
|
||
<View style={styles.legendItem}>
|
||
<View style={[styles.legendDot, { backgroundColor: getSleepStageColor(SleepStage.Awake) }]} />
|
||
<Text style={[styles.legendText, { color: colorTokens.textSecondary }]}>清醒时间</Text>
|
||
</View>
|
||
</View>
|
||
</View>
|
||
</View>
|
||
);
|
||
};
|
||
|
||
const styles = StyleSheet.create({
|
||
container: {
|
||
borderRadius: 16,
|
||
padding: 16,
|
||
marginBottom: 24,
|
||
shadowColor: '#000',
|
||
shadowOffset: { width: 0, height: 2 },
|
||
shadowOpacity: 0.1,
|
||
shadowRadius: 8,
|
||
elevation: 3,
|
||
marginHorizontal: 4,
|
||
},
|
||
header: {
|
||
flexDirection: 'row',
|
||
justifyContent: 'space-between',
|
||
alignItems: 'center',
|
||
marginBottom: 16,
|
||
},
|
||
title: {
|
||
fontSize: 16,
|
||
fontWeight: '600',
|
||
},
|
||
infoButton: {
|
||
padding: 4,
|
||
},
|
||
timeRange: {
|
||
flexDirection: 'row',
|
||
justifyContent: 'space-between',
|
||
marginBottom: 20,
|
||
},
|
||
timePoint: {
|
||
alignItems: 'center',
|
||
},
|
||
timeLabel: {
|
||
fontSize: 12,
|
||
fontWeight: '500',
|
||
marginBottom: 4,
|
||
},
|
||
timeValue: {
|
||
fontSize: 16,
|
||
fontWeight: '700',
|
||
letterSpacing: -0.2,
|
||
},
|
||
chartContainer: {
|
||
alignItems: 'center',
|
||
marginBottom: 16,
|
||
},
|
||
emptyState: {
|
||
alignItems: 'center',
|
||
paddingVertical: 32,
|
||
},
|
||
emptyText: {
|
||
fontSize: 14,
|
||
fontStyle: 'italic',
|
||
},
|
||
legend: {
|
||
gap: 8,
|
||
},
|
||
legendRow: {
|
||
flexDirection: 'row',
|
||
justifyContent: 'center',
|
||
gap: 24,
|
||
},
|
||
legendItem: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
gap: 6,
|
||
},
|
||
legendDot: {
|
||
width: 8,
|
||
height: 8,
|
||
borderRadius: 4,
|
||
},
|
||
legendText: {
|
||
fontSize: 12,
|
||
fontWeight: '500',
|
||
},
|
||
}); |