feat(health): 新增手腕温度监测和经期双向同步功能

新增手腕温度健康数据追踪,支持Apple Watch睡眠手腕温度数据展示和30天历史趋势分析
实现经期数据与HealthKit的完整双向同步,支持读取、写入和删除经期记录
优化经期预测算法,基于历史数据计算更准确的周期和排卵日预测
重构经期UI组件为模块化结构,提升代码可维护性
添加完整的中英文国际化支持,覆盖所有新增功能界面
This commit is contained in:
richarjiang
2025-12-18 08:40:08 +08:00
parent 9b4a300380
commit 4836058d56
31 changed files with 2249 additions and 539 deletions

View File

@@ -1,14 +1,33 @@
import { LinearGradient } from 'expo-linear-gradient';
import React, { useMemo } from 'react';
import dayjs, { Dayjs } from 'dayjs';
import React, { useEffect, useMemo, useState } from 'react';
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { useTranslation } from 'react-i18next';
import { Colors } from '@/constants/Colors';
import { buildMenstrualTimeline } from '@/utils/menstrualCycle';
import { fetchMenstrualFlowSamples } from '@/utils/health';
import {
buildMenstrualTimeline,
convertHealthKitSamplesToCycleRecords,
CycleRecord,
DEFAULT_PERIOD_LENGTH,
MenstrualDayInfo,
MenstrualDayStatus,
MenstrualTimeline,
} from '@/utils/menstrualCycle';
type Props = {
onPress?: () => void;
};
type Summary = {
state: string;
prefix?: string;
suffix?: string;
number?: number;
fallbackText: string;
};
const RingIcon = () => (
<View style={styles.iconWrapper}>
<LinearGradient
@@ -23,45 +42,68 @@ const RingIcon = () => (
);
export const MenstrualCycleCard: React.FC<Props> = ({ onPress }) => {
const { todayInfo, periodLength } = useMemo(() => buildMenstrualTimeline(), []);
const { t } = useTranslation();
const [records, setRecords] = useState<CycleRecord[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
let mounted = true;
const loadMenstrualData = async () => {
setLoading(true);
try {
const today = dayjs();
const startDate = today.subtract(3, 'month').startOf('month').toDate();
const endDate = today.add(4, 'month').endOf('month').toDate();
const samples = await fetchMenstrualFlowSamples(startDate, endDate);
if (!mounted) return;
const converted = convertHealthKitSamplesToCycleRecords(samples);
setRecords(converted);
} catch (error) {
console.error('Failed to load menstrual flow samples', error);
if (mounted) {
setRecords([]);
}
} finally {
if (mounted) {
setLoading(false);
}
}
};
loadMenstrualData();
return () => {
mounted = false;
};
}, []);
const timeline = useMemo(
() =>
buildMenstrualTimeline({
records,
monthsBefore: 2,
monthsAfter: 4,
defaultPeriodLength: DEFAULT_PERIOD_LENGTH,
}),
[records]
);
const summary = useMemo(() => {
if (!todayInfo) {
if (loading && records.length === 0) {
return {
state: '待记录',
dayText: '点击记录本次经期',
number: undefined,
state: t('menstrual.card.syncingState'),
fallbackText: t('menstrual.card.syncingDesc'),
};
}
if (todayInfo.status === 'period' || todayInfo.status === 'predicted-period') {
return {
state: todayInfo.status === 'period' ? '经期' : '预测经期',
dayText: '天',
number: todayInfo.dayOfCycle ?? 1,
};
}
if (todayInfo.status === 'ovulation-day') {
return {
state: '排卵日',
dayText: '易孕窗口',
number: undefined,
};
}
return {
state: '排卵期',
dayText: `距离排卵日${Math.max(periodLength - 1, 1)}`,
number: undefined,
};
}, [periodLength, todayInfo]);
return deriveSummary(timeline, records.length > 0, t);
}, [loading, records.length, timeline, t]);
return (
<TouchableOpacity activeOpacity={0.92} onPress={onPress} style={styles.wrapper}>
<View style={styles.headerRow}>
<RingIcon />
<Text style={styles.title}></Text>
<Text style={styles.title}>{t('menstrual.card.title')}</Text>
<View style={styles.badgeOuter}>
<View style={styles.badgeInner} />
</View>
@@ -71,10 +113,12 @@ export const MenstrualCycleCard: React.FC<Props> = ({ onPress }) => {
<Text style={styles.dayRow}>
{summary.number !== undefined ? (
<>
<Text style={styles.dayNumber}>{summary.number}</Text> {summary.dayText}
{summary.prefix}
<Text style={styles.dayNumber}>{summary.number}</Text>
{summary.suffix}
</>
) : (
summary.dayText
summary.fallbackText
)}
</Text>
</View>
@@ -82,6 +126,199 @@ export const MenstrualCycleCard: React.FC<Props> = ({ onPress }) => {
);
};
const periodStatuses = new Set<MenstrualDayStatus>(['period', 'predicted-period']);
const fertileStatuses = new Set<MenstrualDayStatus>(['fertile', 'ovulation-day']);
const ovulationStatuses = new Set<MenstrualDayStatus>(['ovulation-day']);
const deriveSummary = (
timeline: MenstrualTimeline,
hasRecords: boolean,
t: (key: string, options?: Record<string, any>) => string
): Summary => {
const today = dayjs();
const { dayMap, todayInfo } = timeline;
if (!hasRecords || !Object.keys(dayMap).length) {
return {
state: t('menstrual.card.emptyState'),
fallbackText: t('menstrual.card.emptyDesc'),
};
}
const sortedInfos = Object.values(dayMap).sort(
(a, b) => a.date.valueOf() - b.date.valueOf()
);
const findContinuousRange = (
date: Dayjs,
targetStatuses: Set<MenstrualDayStatus>
): { start: Dayjs; end: Dayjs } | null => {
const key = date.format('YYYY-MM-DD');
if (!targetStatuses.has(dayMap[key]?.status)) return null;
let start = date;
let end = date;
while (true) {
const prev = start.subtract(1, 'day');
const prevInfo = dayMap[prev.format('YYYY-MM-DD')];
if (prevInfo && targetStatuses.has(prevInfo.status)) {
start = prev;
} else {
break;
}
}
while (true) {
const next = end.add(1, 'day');
const nextInfo = dayMap[next.format('YYYY-MM-DD')];
if (nextInfo && targetStatuses.has(nextInfo.status)) {
end = next;
} else {
break;
}
}
return { start, end };
};
const findFutureStatus = (
targetStatuses: Set<MenstrualDayStatus>,
inclusive = true
): MenstrualDayInfo | undefined => {
return sortedInfos.find((info) => {
const isInRange = inclusive
? !info.date.isBefore(today, 'day')
: info.date.isAfter(today, 'day');
return isInRange && targetStatuses.has(info.status);
});
};
const findPastStatus = (targetStatuses: Set<MenstrualDayStatus>) => {
for (let i = sortedInfos.length - 1; i >= 0; i -= 1) {
const info = sortedInfos[i];
if (!info.date.isAfter(today, 'day') && targetStatuses.has(info.status)) {
return info;
}
}
return undefined;
};
if (todayInfo && periodStatuses.has(todayInfo.status)) {
const range = findContinuousRange(today, periodStatuses);
const end = range?.end ?? today;
const daysLeft = Math.max(end.diff(today, 'day'), 0);
if (daysLeft === 0) {
return {
state:
todayInfo.status === 'period'
? t('menstrual.card.periodState')
: t('menstrual.card.predictedPeriodState'),
fallbackText: t('menstrual.card.periodEndToday', {
date: end.format(t('menstrual.dateFormatShort')),
}),
};
}
return {
state:
todayInfo.status === 'period'
? t('menstrual.card.periodState')
: t('menstrual.card.predictedPeriodState'),
prefix: t('menstrual.card.periodEndPrefix'),
number: daysLeft,
suffix: t('menstrual.card.periodEndSuffix', {
date: end.format(t('menstrual.dateFormatShort')),
}),
fallbackText: '',
};
}
const nextPeriod = findFutureStatus(periodStatuses, false);
const lastPeriodInfo = findPastStatus(periodStatuses);
const lastPeriodStart = lastPeriodInfo
? findContinuousRange(lastPeriodInfo.date, periodStatuses)?.start
: undefined;
const ovulationThisCycle = sortedInfos.find((info) => {
if (!ovulationStatuses.has(info.status)) return false;
if (lastPeriodStart && info.date.isBefore(lastPeriodStart, 'day')) return false;
if (nextPeriod && !info.date.isBefore(nextPeriod.date, 'day')) return false;
return true;
});
if (todayInfo?.status === 'fertile') {
const targetOvulation = ovulationThisCycle ?? findFutureStatus(ovulationStatuses);
if (targetOvulation) {
const days = Math.max(targetOvulation.date.diff(today, 'day'), 0);
if (days === 0) {
return {
state: t('menstrual.card.fertileState'),
fallbackText: t('menstrual.card.ovulationToday'),
};
}
return {
state: t('menstrual.card.fertileState'),
prefix: t('menstrual.card.ovulationCountdownPrefix'),
number: days,
suffix: t('menstrual.card.ovulationCountdownSuffix'),
fallbackText: '',
};
}
}
const nextFertile = findFutureStatus(fertileStatuses);
if (nextFertile && (!nextPeriod || nextFertile.date.isBefore(nextPeriod.date))) {
const days = Math.max(nextFertile.date.diff(today, 'day'), 0);
if (days === 0) {
return {
state: t('menstrual.card.fertileState'),
fallbackText: t('menstrual.card.fertileToday'),
};
}
return {
state: t('menstrual.card.fertileState'),
prefix: t('menstrual.card.fertileCountdownPrefix'),
number: days,
suffix: t('menstrual.card.fertileCountdownSuffix'),
fallbackText: '',
};
}
if (
ovulationThisCycle &&
nextPeriod &&
today.isAfter(ovulationThisCycle.date, 'day') &&
today.isBefore(nextPeriod.date, 'day')
) {
const days = Math.max(nextPeriod.date.diff(today, 'day'), 0);
return {
state: t('menstrual.card.periodState'),
prefix: t('menstrual.card.nextPeriodPrefix'),
number: days,
suffix: t('menstrual.card.nextPeriodSuffix'),
fallbackText: '',
};
}
if (nextPeriod) {
const days = Math.max(nextPeriod.date.diff(today, 'day'), 0);
return {
state: t('menstrual.card.periodState'),
prefix: t('menstrual.card.nextPeriodPrefix'),
number: days,
suffix: t('menstrual.card.nextPeriodSuffix'),
fallbackText: '',
};
}
return {
state: t('menstrual.card.emptyState'),
fallbackText: t('menstrual.card.emptyDesc'),
};
};
const styles = StyleSheet.create({
wrapper: {
width: '100%',