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%',
|
||||
|
||||
75
components/menstrual-cycle/DayCell.tsx
Normal file
75
components/menstrual-cycle/DayCell.tsx
Normal 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',
|
||||
},
|
||||
});
|
||||
111
components/menstrual-cycle/InlineTip.tsx
Normal file
111
components/menstrual-cycle/InlineTip.tsx
Normal 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',
|
||||
},
|
||||
});
|
||||
59
components/menstrual-cycle/Legend.tsx
Normal file
59
components/menstrual-cycle/Legend.tsx
Normal 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',
|
||||
},
|
||||
});
|
||||
137
components/menstrual-cycle/MonthBlock.tsx
Normal file
137
components/menstrual-cycle/MonthBlock.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
12
components/menstrual-cycle/constants.ts
Normal file
12
components/menstrual-cycle/constants.ts
Normal 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;
|
||||
7
components/menstrual-cycle/index.ts
Normal file
7
components/menstrual-cycle/index.ts
Normal 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';
|
||||
|
||||
21
components/menstrual-cycle/types.ts
Normal file
21
components/menstrual-cycle/types.ts
Normal 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';
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Image } from 'expo-image';
|
||||
import React from 'react';
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import Animated, { FadeIn, FadeOut } from 'react-native-reanimated';
|
||||
|
||||
interface HealthDataCardProps {
|
||||
@@ -8,37 +8,36 @@ interface HealthDataCardProps {
|
||||
value: string;
|
||||
unit: string;
|
||||
style?: object;
|
||||
onPress?: () => void;
|
||||
}
|
||||
|
||||
const HealthDataCard: React.FC<HealthDataCardProps> = ({
|
||||
title,
|
||||
value,
|
||||
unit,
|
||||
style
|
||||
style,
|
||||
onPress
|
||||
}) => {
|
||||
const Container = onPress ? Pressable : View;
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
entering={FadeIn.duration(300)}
|
||||
exiting={FadeOut.duration(300)}
|
||||
style={[styles.card, style]}
|
||||
>
|
||||
<View style={styles.content}>
|
||||
<View style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 14,
|
||||
}}>
|
||||
<Image
|
||||
source={require('@/assets/images/icons/icon-blood-oxygen.png')}
|
||||
style={styles.titleIcon}
|
||||
/>
|
||||
<Animated.View entering={FadeIn.duration(300)} exiting={FadeOut.duration(300)} style={[styles.card, style]}>
|
||||
<Container
|
||||
style={styles.content}
|
||||
onPress={onPress}
|
||||
accessibilityRole={onPress ? 'button' : undefined}
|
||||
accessibilityLabel={title}
|
||||
accessibilityHint={onPress ? `${title} details` : undefined}
|
||||
>
|
||||
<View style={styles.headerRow}>
|
||||
<Image source={require('@/assets/images/icons/icon-blood-oxygen.png')} style={styles.titleIcon} />
|
||||
<Text style={styles.title}>{title}</Text>
|
||||
</View>
|
||||
<View style={styles.valueContainer}>
|
||||
<Text style={styles.value}>{value}</Text>
|
||||
<Text style={styles.unit}>{unit}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</Container>
|
||||
</Animated.View>
|
||||
);
|
||||
};
|
||||
@@ -62,6 +61,11 @@ const styles = StyleSheet.create({
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
headerRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 14,
|
||||
},
|
||||
titleIcon: {
|
||||
width: 16,
|
||||
height: 16,
|
||||
@@ -94,4 +98,4 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
});
|
||||
|
||||
export default HealthDataCard;
|
||||
export default HealthDataCard;
|
||||
|
||||
568
components/statistic/WristTemperatureCard.tsx
Normal file
568
components/statistic/WristTemperatureCard.tsx
Normal file
@@ -0,0 +1,568 @@
|
||||
import {
|
||||
ensureHealthPermissions,
|
||||
fetchWristTemperature,
|
||||
fetchWristTemperatureHistory,
|
||||
WristTemperatureHistoryPoint
|
||||
} from '@/utils/health';
|
||||
import { HealthKitUtils } from '@/utils/healthKit';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useIsFocused } from '@react-navigation/native';
|
||||
import dayjs from 'dayjs';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { BlurView } from 'expo-blur';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Dimensions,
|
||||
Modal,
|
||||
Platform,
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View
|
||||
} from 'react-native';
|
||||
import Svg, {
|
||||
Circle,
|
||||
Defs,
|
||||
Line,
|
||||
Path,
|
||||
Stop,
|
||||
LinearGradient as SvgLinearGradient
|
||||
} from 'react-native-svg';
|
||||
import HealthDataCard from './HealthDataCard';
|
||||
|
||||
interface WristTemperatureCardProps {
|
||||
style?: object;
|
||||
selectedDate?: Date;
|
||||
}
|
||||
|
||||
const screenWidth = Dimensions.get('window').width;
|
||||
const INITIAL_CHART_WIDTH = screenWidth - 32;
|
||||
const CHART_HEIGHT = 240;
|
||||
const CHART_HORIZONTAL_PADDING = 20;
|
||||
const LABEL_ESTIMATED_WIDTH = 44;
|
||||
|
||||
const WristTemperatureCard: React.FC<WristTemperatureCardProps> = ({
|
||||
style,
|
||||
selectedDate
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const isFocused = useIsFocused();
|
||||
const [temperature, setTemperature] = useState<number | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const loadingRef = useRef(false);
|
||||
const [historyVisible, setHistoryVisible] = useState(false);
|
||||
const [history, setHistory] = useState<WristTemperatureHistoryPoint[]>([]);
|
||||
const [historyLoading, setHistoryLoading] = useState(false);
|
||||
const historyLoadingRef = useRef(false);
|
||||
const [chartWidth, setChartWidth] = useState(INITIAL_CHART_WIDTH);
|
||||
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
const dateToUse = selectedDate || new Date();
|
||||
|
||||
if (!isFocused) return;
|
||||
if (!HealthKitUtils.isAvailable()) {
|
||||
setTemperature(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// 防止重复请求
|
||||
if (loadingRef.current) return;
|
||||
|
||||
try {
|
||||
loadingRef.current = true;
|
||||
setLoading(true);
|
||||
|
||||
const hasPermission = await ensureHealthPermissions();
|
||||
if (!hasPermission) {
|
||||
setTemperature(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const dayStart = dayjs(dateToUse).startOf('day');
|
||||
// wrist temperature samples often start于前一晚,查询时向前扩展一天以包含跨夜数据
|
||||
const options = {
|
||||
startDate: dayStart.subtract(1, 'day').toDate().toISOString(),
|
||||
endDate: dayStart.endOf('day').toDate().toISOString()
|
||||
};
|
||||
|
||||
const data = await fetchWristTemperature(options, dateToUse);
|
||||
setTemperature(data);
|
||||
} catch (error) {
|
||||
console.error('WristTemperatureCard: Failed to get wrist temperature data:', error);
|
||||
setTemperature(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
loadingRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
loadData();
|
||||
}, [isFocused, selectedDate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!historyVisible || !isFocused) return;
|
||||
|
||||
const loadHistory = async () => {
|
||||
if (historyLoadingRef.current) return;
|
||||
if (!HealthKitUtils.isAvailable()) {
|
||||
setHistory([]);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
historyLoadingRef.current = true;
|
||||
setHistoryLoading(true);
|
||||
|
||||
const hasPermission = await ensureHealthPermissions();
|
||||
if (!hasPermission) {
|
||||
setHistory([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const end = dayjs(selectedDate || new Date()).endOf('day');
|
||||
const start = end.subtract(30, 'day').startOf('day').subtract(1, 'day');
|
||||
const options = {
|
||||
startDate: start.toDate().toISOString(),
|
||||
endDate: end.toDate().toISOString(),
|
||||
limit: 1200
|
||||
};
|
||||
|
||||
const historyData = await fetchWristTemperatureHistory(options);
|
||||
setHistory(historyData);
|
||||
} catch (error) {
|
||||
console.error('WristTemperatureCard: Failed to get wrist temperature history:', error);
|
||||
setHistory([]);
|
||||
} finally {
|
||||
historyLoadingRef.current = false;
|
||||
setHistoryLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadHistory();
|
||||
}, [historyVisible, selectedDate, isFocused]);
|
||||
|
||||
const baseline = useMemo(() => {
|
||||
if (!history.length) return null;
|
||||
const avg = history.reduce((sum, point) => sum + point.value, 0) / history.length;
|
||||
return Number(avg.toFixed(2));
|
||||
}, [history]);
|
||||
|
||||
const chartRange = useMemo(() => {
|
||||
if (!history.length) return { min: -1, max: 1 };
|
||||
|
||||
const values = history.map((p) => p.value);
|
||||
const minValue = Math.min(...values);
|
||||
const maxValue = Math.max(...values);
|
||||
const center = baseline ?? (minValue + maxValue) / 2;
|
||||
const maxDeviation = Math.max(Math.abs(maxValue - center), Math.abs(minValue - center), 0.2);
|
||||
const padding = Math.max(maxDeviation * 0.25, 0.15);
|
||||
|
||||
return {
|
||||
min: center - maxDeviation - padding,
|
||||
max: center + maxDeviation + padding
|
||||
};
|
||||
}, [baseline, history]);
|
||||
|
||||
const xStep = useMemo(() => {
|
||||
if (history.length <= 1) return 0;
|
||||
return (chartWidth - CHART_HORIZONTAL_PADDING * 2) / (history.length - 1);
|
||||
}, [history.length, chartWidth]);
|
||||
|
||||
const valueToY = useCallback(
|
||||
(value: number) => {
|
||||
const range = chartRange.max - chartRange.min || 1;
|
||||
return ((chartRange.max - value) / range) * CHART_HEIGHT;
|
||||
},
|
||||
[chartRange.max, chartRange.min]
|
||||
);
|
||||
|
||||
const linePath = useMemo(() => {
|
||||
if (!history.length) return '';
|
||||
return history.reduce((path, point, index) => {
|
||||
const x = CHART_HORIZONTAL_PADDING + xStep * index;
|
||||
const y = valueToY(point.value);
|
||||
if (index === 0) return `M ${x} ${y}`;
|
||||
return `${path} L ${x} ${y}`;
|
||||
}, '');
|
||||
}, [history, valueToY, xStep]);
|
||||
|
||||
const latestValue = history.length ? history[history.length - 1].value : null;
|
||||
const latestChange = baseline !== null && latestValue !== null ? latestValue - baseline : null;
|
||||
|
||||
const dateLabels = useMemo(() => {
|
||||
if (!history.length) return [];
|
||||
const first = history[0];
|
||||
const middle = history[Math.floor(history.length / 2)];
|
||||
const last = history[history.length - 1];
|
||||
const uniqueDates = [first, middle, last].filter((item, idx, arr) => {
|
||||
if (!item) return false;
|
||||
return arr.findIndex((it) => it?.date === item.date) === idx;
|
||||
});
|
||||
|
||||
return uniqueDates.map((point) => {
|
||||
const index = history.findIndex((p) => p.date === point.date);
|
||||
const positionIndex = index >= 0 ? index : 0;
|
||||
|
||||
return {
|
||||
date: point.date,
|
||||
label: dayjs(point.date).format('MM.DD'),
|
||||
x: CHART_HORIZONTAL_PADDING + positionIndex * xStep
|
||||
};
|
||||
});
|
||||
}, [history, xStep]);
|
||||
|
||||
const openHistory = useCallback(() => {
|
||||
setHistoryVisible(true);
|
||||
}, []);
|
||||
|
||||
const closeHistory = useCallback(() => {
|
||||
setHistoryVisible(false);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<HealthDataCard
|
||||
title={t('statistics.components.wristTemperature.title')}
|
||||
value={loading ? '--' : (temperature !== null && temperature !== undefined ? temperature.toFixed(1) : '--')}
|
||||
unit="°C"
|
||||
style={style}
|
||||
onPress={openHistory}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
visible={historyVisible}
|
||||
animationType="slide"
|
||||
presentationStyle={Platform.OS === 'ios' ? 'pageSheet' : 'fullScreen'}
|
||||
onRequestClose={closeHistory}
|
||||
>
|
||||
<View style={styles.modalSafeArea}>
|
||||
<LinearGradient
|
||||
colors={['#F7F6FF', '#FFFFFF']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 0, y: 1 }}
|
||||
style={StyleSheet.absoluteFill}
|
||||
/>
|
||||
<View style={styles.modalContainer}>
|
||||
<View style={styles.modalHeader}>
|
||||
<View>
|
||||
<Text style={styles.modalTitle}>{t('statistics.components.wristTemperature.title')}</Text>
|
||||
<Text style={styles.modalSubtitle}>{t('statistics.components.wristTemperature.last30Days')}</Text>
|
||||
</View>
|
||||
<Pressable style={styles.closeButton} onPress={closeHistory} hitSlop={10}>
|
||||
<BlurView intensity={24} tint="light" style={StyleSheet.absoluteFill} />
|
||||
<View style={styles.closeButtonInner}>
|
||||
<Ionicons name="close" size={18} color="#111827" />
|
||||
</View>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{historyLoading ? (
|
||||
<Text style={styles.hintText}>{t('statistics.components.wristTemperature.syncing')}</Text>
|
||||
) : null}
|
||||
|
||||
{history.length === 0 ? (
|
||||
<View style={styles.emptyState}>
|
||||
<Text style={styles.emptyText}>{t('statistics.components.wristTemperature.noData')}</Text>
|
||||
</View>
|
||||
) : (
|
||||
<View
|
||||
style={styles.chartCard}
|
||||
onLayout={(event) => {
|
||||
const nextWidth = event.nativeEvent.layout.width;
|
||||
if (nextWidth > 120 && Math.abs(nextWidth - chartWidth) > 2) {
|
||||
setChartWidth(nextWidth);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Svg width={chartWidth} height={CHART_HEIGHT + 36}>
|
||||
<Defs>
|
||||
<SvgLinearGradient id="lineFade" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<Stop offset="0%" stopColor="#1F2A44" stopOpacity="1" />
|
||||
<Stop offset="100%" stopColor="#1F2A44" stopOpacity="0.78" />
|
||||
</SvgLinearGradient>
|
||||
</Defs>
|
||||
|
||||
<Line
|
||||
x1={CHART_HORIZONTAL_PADDING}
|
||||
y1={valueToY(baseline ?? 0)}
|
||||
x2={chartWidth - CHART_HORIZONTAL_PADDING}
|
||||
y2={valueToY(baseline ?? 0)}
|
||||
stroke="#CBD5E1"
|
||||
strokeDasharray="6 6"
|
||||
strokeWidth={1.2}
|
||||
/>
|
||||
|
||||
<Path d={linePath} stroke="url(#lineFade)" strokeWidth={2.6} fill="none" strokeLinecap="round" />
|
||||
|
||||
{history.map((point, index) => {
|
||||
const x = CHART_HORIZONTAL_PADDING + xStep * index;
|
||||
const y = valueToY(point.value);
|
||||
return (
|
||||
<Circle
|
||||
key={point.date}
|
||||
cx={x}
|
||||
cy={y}
|
||||
r={5}
|
||||
stroke="#1F2A44"
|
||||
strokeWidth={1.6}
|
||||
fill="#FFFFFF"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Svg>
|
||||
<View style={styles.labelRow}>
|
||||
{dateLabels.map((item) => {
|
||||
const clampedLeft = Math.min(
|
||||
Math.max(item.x - LABEL_ESTIMATED_WIDTH / 2, CHART_HORIZONTAL_PADDING),
|
||||
chartWidth - CHART_HORIZONTAL_PADDING - LABEL_ESTIMATED_WIDTH
|
||||
);
|
||||
return (
|
||||
<Text key={item.date} style={[styles.axisLabel, { left: clampedLeft, width: LABEL_ESTIMATED_WIDTH }]}>
|
||||
{item.label}
|
||||
</Text>
|
||||
);
|
||||
})}
|
||||
<View style={styles.baselineLabelWrapper}>
|
||||
<View style={styles.baselinePill}>
|
||||
<View style={styles.baselineDot} />
|
||||
<Text style={styles.axisHint}>{t('statistics.components.wristTemperature.baseline')}</Text>
|
||||
{baseline !== null && (
|
||||
<Text style={styles.axisHintValue}>
|
||||
{baseline.toFixed(1)}
|
||||
°C
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
{latestChange !== null && (
|
||||
<View style={styles.deviationBadge}>
|
||||
<Text style={styles.deviationBadgeText}>
|
||||
{latestChange >= 0 ? '+' : ''}
|
||||
{latestChange.toFixed(1)}°C
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={styles.metricsRow}>
|
||||
<View style={styles.metric}>
|
||||
<Text style={styles.metricLabel}>{t('statistics.components.wristTemperature.average')}</Text>
|
||||
<Text style={styles.metricValue}>
|
||||
{baseline !== null ? baseline.toFixed(1) : '--'}
|
||||
<Text style={styles.metricUnit}>°C</Text>
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.metric}>
|
||||
<Text style={styles.metricLabel}>{t('statistics.components.wristTemperature.latest')}</Text>
|
||||
<Text style={styles.metricValue}>
|
||||
{latestValue !== null ? latestValue.toFixed(1) : '--'}
|
||||
<Text style={styles.metricUnit}>°C</Text>
|
||||
</Text>
|
||||
{latestChange !== null && (
|
||||
<Text style={styles.metricHint}>
|
||||
{latestChange >= 0 ? '+' : ''}
|
||||
{latestChange.toFixed(1)}°C {t('statistics.components.wristTemperature.vsBaseline')}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default WristTemperatureCard;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
modalSafeArea: {
|
||||
flex: 1,
|
||||
backgroundColor: '#FFFFFF',
|
||||
paddingTop: Platform.OS === 'ios' ? 10 : 0
|
||||
},
|
||||
modalContainer: {
|
||||
flex: 1,
|
||||
paddingHorizontal: 20,
|
||||
paddingTop: 22
|
||||
},
|
||||
modalHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 14
|
||||
},
|
||||
modalTitle: {
|
||||
fontSize: 22,
|
||||
fontWeight: '700',
|
||||
color: '#1C1C28',
|
||||
fontFamily: 'AliBold'
|
||||
},
|
||||
modalSubtitle: {
|
||||
fontSize: 13,
|
||||
color: '#6B7280',
|
||||
marginTop: 4,
|
||||
fontFamily: 'AliRegular'
|
||||
},
|
||||
closeButton: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
backgroundColor: 'rgba(255,255,255,0.42)',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
borderWidth: 0.5,
|
||||
borderColor: 'rgba(255,255,255,0.6)',
|
||||
shadowColor: '#0F172A',
|
||||
shadowOpacity: 0.08,
|
||||
shadowRadius: 8,
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
elevation: 2
|
||||
},
|
||||
closeButtonInner: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
chartCard: {
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderRadius: 24,
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 12,
|
||||
shadowColor: '#000',
|
||||
shadowOpacity: 0.04,
|
||||
shadowRadius: 12,
|
||||
shadowOffset: { width: 0, height: 10 },
|
||||
elevation: 4,
|
||||
marginTop: 8,
|
||||
marginBottom: 14,
|
||||
borderWidth: 1,
|
||||
borderColor: '#F1F5F9'
|
||||
},
|
||||
labelRow: {
|
||||
marginTop: -6,
|
||||
paddingHorizontal: 12,
|
||||
height: 44,
|
||||
justifyContent: 'center'
|
||||
},
|
||||
axisLabel: {
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
fontSize: 11,
|
||||
color: '#94A3B8',
|
||||
fontFamily: 'AliRegular',
|
||||
textAlign: 'center'
|
||||
},
|
||||
baselineLabelWrapper: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
top: -4,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center'
|
||||
},
|
||||
baselinePill: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 6,
|
||||
backgroundColor: '#F1F5F9',
|
||||
borderRadius: 14,
|
||||
borderWidth: 1,
|
||||
borderColor: '#E2E8F0',
|
||||
gap: 6
|
||||
},
|
||||
baselineDot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
backgroundColor: '#94A3B8'
|
||||
},
|
||||
axisHint: {
|
||||
fontSize: 12,
|
||||
color: '#6B7280',
|
||||
fontFamily: 'AliRegular'
|
||||
},
|
||||
axisHintValue: {
|
||||
fontSize: 13,
|
||||
color: '#111827',
|
||||
fontWeight: '700',
|
||||
fontFamily: 'AliBold'
|
||||
},
|
||||
deviationBadge: {
|
||||
position: 'absolute',
|
||||
right: 12,
|
||||
bottom: 2,
|
||||
backgroundColor: '#ECFEFF',
|
||||
borderRadius: 12,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 5,
|
||||
borderWidth: 1,
|
||||
borderColor: '#CFFAFE'
|
||||
},
|
||||
deviationBadgeText: {
|
||||
fontSize: 12,
|
||||
color: '#0EA5E9',
|
||||
fontWeight: '700',
|
||||
fontFamily: 'AliBold'
|
||||
},
|
||||
metricsRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
gap: 12,
|
||||
paddingVertical: 6
|
||||
},
|
||||
metric: {
|
||||
flex: 1,
|
||||
backgroundColor: '#F8FAFC',
|
||||
borderRadius: 18,
|
||||
padding: 14,
|
||||
borderWidth: 1,
|
||||
borderColor: '#E2E8F0'
|
||||
},
|
||||
metricLabel: {
|
||||
fontSize: 12,
|
||||
color: '#6B7280',
|
||||
marginBottom: 6,
|
||||
fontFamily: 'AliRegular'
|
||||
},
|
||||
metricValue: {
|
||||
fontSize: 20,
|
||||
color: '#111827',
|
||||
fontWeight: '700',
|
||||
fontFamily: 'AliBold'
|
||||
},
|
||||
metricUnit: {
|
||||
fontSize: 12,
|
||||
color: '#6B7280',
|
||||
marginLeft: 4,
|
||||
fontWeight: '500',
|
||||
fontFamily: 'AliRegular'
|
||||
},
|
||||
metricHint: {
|
||||
marginTop: 6,
|
||||
fontSize: 12,
|
||||
color: '#6B21A8',
|
||||
fontFamily: 'AliRegular'
|
||||
},
|
||||
emptyState: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: 32
|
||||
},
|
||||
emptyText: {
|
||||
fontSize: 14,
|
||||
color: '#94A3B8',
|
||||
fontFamily: 'AliRegular'
|
||||
},
|
||||
hintText: {
|
||||
fontSize: 12,
|
||||
color: '#6B7280',
|
||||
marginBottom: 6,
|
||||
fontFamily: 'AliRegular'
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user