新增手腕温度健康数据追踪,支持Apple Watch睡眠手腕温度数据展示和30天历史趋势分析 实现经期数据与HealthKit的完整双向同步,支持读取、写入和删除经期记录 优化经期预测算法,基于历史数据计算更准确的周期和排卵日预测 重构经期UI组件为模块化结构,提升代码可维护性 添加完整的中英文国际化支持,覆盖所有新增功能界面
395 lines
10 KiB
TypeScript
395 lines
10 KiB
TypeScript
import { LinearGradient } from 'expo-linear-gradient';
|
|
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 { 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
|
|
colors={['#f572a7', '#f0a4ff', '#6f6ced']}
|
|
start={{ x: 0, y: 0 }}
|
|
end={{ x: 1, y: 1 }}
|
|
style={styles.iconGradient}
|
|
>
|
|
<View style={styles.iconInner} />
|
|
</LinearGradient>
|
|
</View>
|
|
);
|
|
|
|
export const MenstrualCycleCard: React.FC<Props> = ({ onPress }) => {
|
|
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 (loading && records.length === 0) {
|
|
return {
|
|
state: t('menstrual.card.syncingState'),
|
|
fallbackText: t('menstrual.card.syncingDesc'),
|
|
};
|
|
}
|
|
|
|
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}>{t('menstrual.card.title')}</Text>
|
|
<View style={styles.badgeOuter}>
|
|
<View style={styles.badgeInner} />
|
|
</View>
|
|
</View>
|
|
<View style={styles.content}>
|
|
<Text style={styles.stateText}>{summary.state}</Text>
|
|
<Text style={styles.dayRow}>
|
|
{summary.number !== undefined ? (
|
|
<>
|
|
{summary.prefix}
|
|
<Text style={styles.dayNumber}>{summary.number}</Text>
|
|
{summary.suffix}
|
|
</>
|
|
) : (
|
|
summary.fallbackText
|
|
)}
|
|
</Text>
|
|
</View>
|
|
</TouchableOpacity>
|
|
);
|
|
};
|
|
|
|
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%',
|
|
},
|
|
headerRow: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: 10,
|
|
},
|
|
iconWrapper: {
|
|
width: 24,
|
|
height: 24,
|
|
borderRadius: 12,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
},
|
|
iconGradient: {
|
|
width: 22,
|
|
height: 22,
|
|
borderRadius: 11,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
},
|
|
iconInner: {
|
|
width: 10,
|
|
height: 10,
|
|
borderRadius: 5,
|
|
backgroundColor: '#fff',
|
|
},
|
|
title: {
|
|
fontSize: 14,
|
|
color: '#192126',
|
|
fontWeight: '600',
|
|
flex: 1,
|
|
fontFamily: 'AliBold',
|
|
},
|
|
badgeOuter: {
|
|
width: 18,
|
|
height: 18,
|
|
borderRadius: 9,
|
|
borderWidth: 2,
|
|
borderColor: '#fbcfe8',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
},
|
|
badgeInner: {
|
|
width: 6,
|
|
height: 6,
|
|
borderRadius: 3,
|
|
backgroundColor: Colors.light.primary,
|
|
opacity: 0.35,
|
|
},
|
|
content: {
|
|
marginTop: 12,
|
|
},
|
|
stateText: {
|
|
fontSize: 12,
|
|
color: '#515558',
|
|
marginBottom: 4,
|
|
fontFamily: 'AliRegular',
|
|
},
|
|
dayRow: {
|
|
fontSize: 14,
|
|
color: '#192126',
|
|
fontFamily: 'AliRegular',
|
|
},
|
|
dayNumber: {
|
|
fontSize: 18,
|
|
fontWeight: '700',
|
|
color: '#192126',
|
|
fontFamily: 'AliBold',
|
|
},
|
|
});
|