feat(health): 新增手腕温度监测和经期双向同步功能
新增手腕温度健康数据追踪,支持Apple Watch睡眠手腕温度数据展示和30天历史趋势分析 实现经期数据与HealthKit的完整双向同步,支持读取、写入和删除经期记录 优化经期预测算法,基于历史数据计算更准确的周期和排卵日预测 重构经期UI组件为模块化结构,提升代码可维护性 添加完整的中英文国际化支持,覆盖所有新增功能界面
This commit is contained in:
@@ -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%',
|
||||
|
||||
Reference in New Issue
Block a user