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

@@ -0,0 +1,75 @@
import { Colors } from '@/constants/Colors';
import React from 'react';
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { STATUS_COLORS } from './constants';
import { DayCellProps } from './types';
export const DayCell: React.FC<DayCellProps> = ({ cell, isSelected, onPress }) => {
const status = cell.info?.status;
const colors = status ? STATUS_COLORS[status] : undefined;
return (
<TouchableOpacity
activeOpacity={0.8}
style={styles.dayCell}
onPress={onPress}
>
<View
style={[
styles.dayCircle,
colors && { backgroundColor: colors.bg },
isSelected && styles.dayCircleSelected,
cell.isToday && styles.todayOutline,
]}
>
<Text
style={[
styles.dayLabel,
colors && { color: colors.text },
!colors && styles.dayLabelDefault,
]}
>
{cell.label}
</Text>
</View>
{cell.isToday && <Text style={styles.todayText}></Text>}
</TouchableOpacity>
);
};
const styles = StyleSheet.create({
dayCell: {
width: '14.28%',
alignItems: 'center',
marginVertical: 6,
},
dayCircle: {
width: 40,
height: 40,
borderRadius: 20,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#f3f4f6',
},
dayCircleSelected: {
borderWidth: 2,
borderColor: Colors.light.primary,
},
todayOutline: {
borderWidth: 2,
borderColor: '#94a3b8',
},
dayLabel: {
fontSize: 15,
fontFamily: 'AliBold',
},
dayLabelDefault: {
color: '#111827',
},
todayText: {
fontSize: 10,
color: '#9ca3af',
marginTop: 2,
fontFamily: 'AliRegular',
},
});

View File

@@ -0,0 +1,111 @@
import { Colors } from '@/constants/Colors';
import { Ionicons } from '@expo/vector-icons';
import dayjs from 'dayjs';
import React from 'react';
import { DimensionValue, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { InlineTipProps } from './types';
export const InlineTip: React.FC<InlineTipProps> = ({
selectedDate,
selectedInfo,
columnIndex,
onMarkStart,
onCancelMark,
}) => {
// 14.28% per cell. Center is 7.14%.
const pointerLeft = `${columnIndex * 14.2857 + 7.1428}%` as DimensionValue;
const isFuture = selectedDate.isAfter(dayjs(), 'day');
return (
<View style={styles.inlineTipCard}>
<View style={[styles.inlineTipPointer, { left: pointerLeft }]} />
<View style={styles.inlineTipRow}>
<View style={styles.inlineTipDate}>
<Ionicons name="calendar-outline" size={16} color="#111827" />
<Text style={styles.inlineTipDateText}>{selectedDate.format('M月D日')}</Text>
</View>
{!isFuture && (!selectedInfo || !selectedInfo.confirmed) && (
<TouchableOpacity style={styles.inlinePrimaryBtn} onPress={onMarkStart}>
<Ionicons name="add" size={14} color="#fff" />
<Text style={styles.inlinePrimaryText}></Text>
</TouchableOpacity>
)}
{!isFuture && selectedInfo?.confirmed && selectedInfo.status === 'period' && (
<TouchableOpacity style={styles.inlineSecondaryBtn} onPress={onCancelMark}>
<Text style={styles.inlineSecondaryText}></Text>
</TouchableOpacity>
)}
</View>
</View>
);
};
const styles = StyleSheet.create({
inlineTipCard: {
backgroundColor: '#e8e7ff',
borderRadius: 18,
paddingVertical: 10,
paddingHorizontal: 12,
shadowColor: '#000',
shadowOpacity: 0.04,
shadowRadius: 6,
shadowOffset: { width: 0, height: 2 },
elevation: 1,
},
inlineTipPointer: {
position: 'absolute',
top: -6,
width: 12,
height: 12,
marginLeft: -6,
backgroundColor: '#e8e7ff',
transform: [{ rotate: '45deg' }],
borderRadius: 3,
},
inlineTipRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: 8,
},
inlineTipDate: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
},
inlineTipDateText: {
fontSize: 14,
color: '#111827',
fontWeight: '800',
fontFamily: 'AliBold',
},
inlinePrimaryBtn: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: Colors.light.primary,
paddingHorizontal: 12,
paddingVertical: 8,
borderRadius: 14,
gap: 6,
},
inlinePrimaryText: {
color: '#fff',
fontSize: 13,
fontWeight: '700',
fontFamily: 'AliBold',
},
inlineSecondaryBtn: {
paddingHorizontal: 12,
paddingVertical: 8,
borderRadius: 14,
backgroundColor: '#fff',
borderWidth: 1,
borderColor: '#d1d5db',
},
inlineSecondaryText: {
color: '#111827',
fontSize: 13,
fontWeight: '700',
fontFamily: 'AliBold',
},
});

View File

@@ -0,0 +1,59 @@
import React from 'react';
import { StyleSheet, Text, View } from 'react-native';
import { STATUS_COLORS } from './constants';
import { LegendItem } from './types';
const LEGEND_ITEMS: LegendItem[] = [
{ label: '经期', key: 'period' },
{ label: '预测经期', key: 'predicted-period' },
{ label: '排卵期', key: 'fertile' },
{ label: '排卵日', key: 'ovulation-day' },
];
export const Legend: React.FC = () => {
return (
<View style={styles.legendRow}>
{LEGEND_ITEMS.map((item) => (
<View key={item.key} style={styles.legendItem}>
<View
style={[
styles.legendDot,
{ backgroundColor: STATUS_COLORS[item.key].bg },
item.key === 'ovulation-day' && styles.legendDotRing,
]}
/>
<Text style={styles.legendLabel}>{item.label}</Text>
</View>
))}
</View>
);
};
const styles = StyleSheet.create({
legendRow: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 12,
marginBottom: 12,
paddingHorizontal: 4,
},
legendItem: {
flexDirection: 'row',
alignItems: 'center',
},
legendDot: {
width: 16,
height: 16,
borderRadius: 8,
marginRight: 6,
},
legendDotRing: {
borderWidth: 2,
borderColor: '#fff',
},
legendLabel: {
fontSize: 13,
color: '#111827',
fontFamily: 'AliRegular',
},
});

View File

@@ -0,0 +1,137 @@
import { MenstrualTimeline } from '@/utils/menstrualCycle';
import React, { useMemo } from 'react';
import { StyleSheet, Text, View } from 'react-native';
import { DayCell } from './DayCell';
import { WEEK_LABELS } from './constants';
const chunkArray = <T,>(array: T[], size: number): T[][] => {
const result: T[][] = [];
for (let i = 0; i < array.length; i += size) {
result.push(array.slice(i, i + size));
}
return result;
};
interface MonthBlockProps {
month: MenstrualTimeline['months'][number];
selectedDateKey: string;
onSelect: (dateKey: string) => void;
renderTip: (colIndex: number) => React.ReactNode;
}
export const MonthBlock: React.FC<MonthBlockProps> = ({
month,
selectedDateKey,
onSelect,
renderTip,
}) => {
const weeks = useMemo(() => chunkArray(month.cells, 7), [month.cells]);
return (
<View style={styles.monthCard}>
<View style={styles.monthHeader}>
<Text style={styles.monthTitle}>{month.title}</Text>
<Text style={styles.monthSubtitle}>{month.subtitle}</Text>
</View>
<View style={styles.weekRow}>
{WEEK_LABELS.map((label) => (
<Text key={label} style={styles.weekLabel}>
{label}
</Text>
))}
</View>
<View style={styles.monthGrid}>
{weeks.map((week, weekIndex) => {
const selectedIndex = week.findIndex(
(c) => c.type === 'day' && c.date.format('YYYY-MM-DD') === selectedDateKey
);
return (
<React.Fragment key={weekIndex}>
<View style={styles.daysRow}>
{week.map((cell) => {
if (cell.type === 'placeholder') {
return <View key={cell.key} style={styles.dayCell} />;
}
const dateKey = cell.date.format('YYYY-MM-DD');
return (
<DayCell
key={cell.key}
cell={cell}
isSelected={selectedDateKey === dateKey}
onPress={() => onSelect(dateKey)}
/>
);
})}
</View>
{selectedIndex !== -1 && (
<View style={styles.inlineTipContainer}>
{renderTip(selectedIndex)}
</View>
)}
</React.Fragment>
);
})}
</View>
</View>
);
};
const styles = StyleSheet.create({
monthCard: {
backgroundColor: '#fff',
borderRadius: 16,
padding: 14,
marginBottom: 12,
shadowColor: '#000',
shadowOpacity: 0.08,
shadowRadius: 8,
shadowOffset: { width: 0, height: 6 },
elevation: 2,
},
monthHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 8,
},
monthTitle: {
fontSize: 17,
fontWeight: '800',
color: '#0f172a',
fontFamily: 'AliBold',
},
monthSubtitle: {
fontSize: 12,
color: '#6b7280',
fontFamily: 'AliRegular',
},
weekRow: {
flexDirection: 'row',
justifyContent: 'space-between',
marginBottom: 6,
paddingHorizontal: 4,
},
weekLabel: {
width: '14.28%',
textAlign: 'center',
fontSize: 12,
color: '#94a3b8',
fontFamily: 'AliRegular',
},
monthGrid: {
flexDirection: 'column',
},
daysRow: {
flexDirection: 'row',
},
dayCell: {
width: '14.28%',
alignItems: 'center',
marginVertical: 6,
},
inlineTipContainer: {
paddingBottom: 6,
marginBottom: 6,
},
});

View File

@@ -0,0 +1,12 @@
import { MenstrualDayStatus } from '@/utils/menstrualCycle';
export const STATUS_COLORS: Record<MenstrualDayStatus, { bg: string; text: string }> = {
period: { bg: '#f5679f', text: '#fff' },
'predicted-period': { bg: '#f8d9e9', text: '#9b2c6a' },
fertile: { bg: '#d9d2ff', text: '#5a52c5' },
'ovulation-day': { bg: '#5b4ee4', text: '#fff' },
};
export const WEEK_LABELS = ['一', '二', '三', '四', '五', '六', '日'];
export const ITEM_HEIGHT = 380;

View File

@@ -0,0 +1,7 @@
export { ITEM_HEIGHT, STATUS_COLORS, WEEK_LABELS } from './constants';
export { DayCell } from './DayCell';
export { InlineTip } from './InlineTip';
export { Legend } from './Legend';
export { MonthBlock } from './MonthBlock';
export type { DayCellProps, InlineTipProps, LegendItem } from './types';

View File

@@ -0,0 +1,21 @@
import { MenstrualDayCell, MenstrualDayInfo } from '@/utils/menstrualCycle';
import { Dayjs } from 'dayjs';
export interface DayCellProps {
cell: Extract<MenstrualDayCell, { type: 'day' }>;
isSelected: boolean;
onPress: () => void;
}
export interface InlineTipProps {
selectedDate: Dayjs;
selectedInfo: MenstrualDayInfo | undefined;
columnIndex: number;
onMarkStart: () => void;
onCancelMark: () => void;
}
export interface LegendItem {
label: string;
key: 'period' | 'predicted-period' | 'fertile' | 'ovulation-day';
}