新增手腕温度健康数据追踪,支持Apple Watch睡眠手腕温度数据展示和30天历史趋势分析 实现经期数据与HealthKit的完整双向同步,支持读取、写入和删除经期记录 优化经期预测算法,基于历史数据计算更准确的周期和排卵日预测 重构经期UI组件为模块化结构,提升代码可维护性 添加完整的中英文国际化支持,覆盖所有新增功能界面
491 lines
15 KiB
TypeScript
491 lines
15 KiB
TypeScript
import { Ionicons } from '@expo/vector-icons';
|
|
import dayjs from 'dayjs';
|
|
import { LinearGradient } from 'expo-linear-gradient';
|
|
import { Stack, useRouter } from 'expo-router';
|
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
|
import {
|
|
FlatList,
|
|
StyleSheet,
|
|
Text,
|
|
TouchableOpacity,
|
|
View,
|
|
} from 'react-native';
|
|
import { useTranslation } from 'react-i18next';
|
|
|
|
import { InlineTip, ITEM_HEIGHT, Legend, MonthBlock } from '@/components/menstrual-cycle';
|
|
import {
|
|
deleteMenstrualFlow,
|
|
fetchMenstrualFlowSamples,
|
|
saveMenstrualFlow
|
|
} from '@/utils/health';
|
|
import {
|
|
buildMenstrualTimeline,
|
|
convertHealthKitSamplesToCycleRecords,
|
|
CycleRecord,
|
|
DEFAULT_PERIOD_LENGTH
|
|
} from '@/utils/menstrualCycle';
|
|
|
|
type TabKey = 'cycle' | 'analysis';
|
|
|
|
export default function MenstrualCycleScreen() {
|
|
const router = useRouter();
|
|
const { t } = useTranslation();
|
|
const [records, setRecords] = useState<CycleRecord[]>([]);
|
|
const [windowConfig, setWindowConfig] = useState({ before: 2, after: 3 });
|
|
|
|
// Load data from HealthKit
|
|
useEffect(() => {
|
|
const loadData = async () => {
|
|
// Calculate date range based on windowConfig
|
|
const today = dayjs();
|
|
const startDate = today.subtract(windowConfig.before, 'month').startOf('month').toDate();
|
|
const endDate = today.add(windowConfig.after, 'month').endOf('month').toDate();
|
|
|
|
const samples = await fetchMenstrualFlowSamples(startDate, endDate);
|
|
const convertedRecords = convertHealthKitSamplesToCycleRecords(samples);
|
|
setRecords(convertedRecords);
|
|
};
|
|
|
|
loadData();
|
|
}, [windowConfig]);
|
|
|
|
const timeline = useMemo(
|
|
() =>
|
|
buildMenstrualTimeline({
|
|
monthsBefore: windowConfig.before,
|
|
monthsAfter: windowConfig.after,
|
|
records,
|
|
defaultPeriodLength: DEFAULT_PERIOD_LENGTH,
|
|
}),
|
|
[records, windowConfig]
|
|
);
|
|
const [activeTab, setActiveTab] = useState<TabKey>('cycle');
|
|
const [selectedDateKey, setSelectedDateKey] = useState(
|
|
dayjs().format('YYYY-MM-DD')
|
|
);
|
|
const listRef = useRef<FlatList>(null);
|
|
const offsetRef = useRef(0);
|
|
const prependDeltaRef = useRef(0);
|
|
const loadingPrevRef = useRef(false);
|
|
|
|
const selectedInfo = timeline.dayMap[selectedDateKey];
|
|
const selectedDate = dayjs(selectedDateKey);
|
|
|
|
|
|
const handleMarkStart = async () => {
|
|
if (selectedDate.isAfter(dayjs(), 'day')) return;
|
|
|
|
// Check if the selected date is already covered
|
|
const isCovered = records.some((r) => {
|
|
const start = dayjs(r.startDate);
|
|
const end = start.add((r.periodLength ?? DEFAULT_PERIOD_LENGTH) - 1, 'day');
|
|
return (
|
|
(selectedDate.isSame(start, 'day') || selectedDate.isAfter(start, 'day')) &&
|
|
(selectedDate.isSame(end, 'day') || selectedDate.isBefore(end, 'day'))
|
|
);
|
|
});
|
|
if (isCovered) return;
|
|
|
|
// Optimistic Update
|
|
const originalRecords = [...records];
|
|
setRecords((prev) => {
|
|
const updated = [...prev];
|
|
// Logic for optimistic UI update (same as original logic)
|
|
const prevRecordIndex = updated.findIndex((r) => {
|
|
const start = dayjs(r.startDate);
|
|
const end = start.add((r.periodLength ?? DEFAULT_PERIOD_LENGTH) - 1, 'day');
|
|
return end.add(1, 'day').isSame(selectedDate, 'day');
|
|
});
|
|
const nextRecordIndex = updated.findIndex((r) => {
|
|
return dayjs(r.startDate).subtract(1, 'day').isSame(selectedDate, 'day');
|
|
});
|
|
|
|
if (prevRecordIndex !== -1 && nextRecordIndex !== -1) {
|
|
const prevRecord = updated[prevRecordIndex];
|
|
const nextRecord = updated[nextRecordIndex];
|
|
const newLength =
|
|
(prevRecord.periodLength ?? DEFAULT_PERIOD_LENGTH) +
|
|
1 +
|
|
(nextRecord.periodLength ?? DEFAULT_PERIOD_LENGTH);
|
|
updated[prevRecordIndex] = { ...prevRecord, periodLength: newLength };
|
|
updated.splice(nextRecordIndex, 1);
|
|
} else if (prevRecordIndex !== -1) {
|
|
const prevRecord = updated[prevRecordIndex];
|
|
updated[prevRecordIndex] = {
|
|
...prevRecord,
|
|
periodLength: (prevRecord.periodLength ?? DEFAULT_PERIOD_LENGTH) + 1,
|
|
};
|
|
} else if (nextRecordIndex !== -1) {
|
|
const nextRecord = updated[nextRecordIndex];
|
|
updated[nextRecordIndex] = {
|
|
...nextRecord,
|
|
startDate: selectedDate.format('YYYY-MM-DD'),
|
|
periodLength: (nextRecord.periodLength ?? DEFAULT_PERIOD_LENGTH) + 1,
|
|
};
|
|
} else {
|
|
const newRecord: CycleRecord = {
|
|
startDate: selectedDate.format('YYYY-MM-DD'),
|
|
periodLength: 7,
|
|
source: 'manual',
|
|
};
|
|
updated.push(newRecord);
|
|
}
|
|
return updated.sort((a, b) => dayjs(a.startDate).valueOf() - dayjs(b.startDate).valueOf());
|
|
});
|
|
|
|
try {
|
|
// Determine what to save to HealthKit
|
|
// If we are merging or extending, we are effectively adding one day of flow
|
|
// If we are creating a new record, we default to 7 days
|
|
// However, accurate HealthKit logging should be per day.
|
|
// The previous UI logic "creates" a 7-day period for a single tap.
|
|
// We should replicate this behavior in HealthKit for consistency.
|
|
|
|
const isNewIsolatedRecord = !records.some((r) => {
|
|
const start = dayjs(r.startDate);
|
|
const end = start.add((r.periodLength ?? DEFAULT_PERIOD_LENGTH) - 1, 'day');
|
|
// Check adjacency
|
|
return (
|
|
end.add(1, 'day').isSame(selectedDate, 'day') ||
|
|
dayjs(r.startDate).subtract(1, 'day').isSame(selectedDate, 'day')
|
|
);
|
|
});
|
|
|
|
if (isNewIsolatedRecord) {
|
|
// Save 7 days of flow starting from selectedDate
|
|
const promises = [];
|
|
for (let i = 0; i < 7; i++) {
|
|
const date = selectedDate.add(i, 'day');
|
|
// Don't save future dates if they exceed today (though logic allows predicting)
|
|
// But for flow logging, we usually only log past/present.
|
|
// However, UI allows setting a period that might extend slightly?
|
|
// Let's stick to the selected date logic.
|
|
// Wait, if I tap "Mark Start", it creates a 7 day period.
|
|
// Should I write 7 samples? Yes, to match the UI state.
|
|
promises.push(saveMenstrualFlow(date.toDate(), 1, i === 0)); // 1=unspecified
|
|
}
|
|
await Promise.all(promises);
|
|
} else {
|
|
// Just adding a single day to bridge/extend
|
|
await saveMenstrualFlow(selectedDate.toDate(), 1, false);
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to save to HealthKit', error);
|
|
// Revert optimistic update
|
|
setRecords(originalRecords);
|
|
}
|
|
};
|
|
|
|
const handleCancelMark = async () => {
|
|
if (!selectedInfo || !selectedInfo.confirmed) return;
|
|
if (selectedDate.isAfter(dayjs(), 'day')) return;
|
|
const target = selectedDate;
|
|
|
|
// Optimistic Update
|
|
const originalRecords = [...records];
|
|
setRecords((prev) => {
|
|
const updated: CycleRecord[] = [];
|
|
prev.forEach((record) => {
|
|
const start = dayjs(record.startDate);
|
|
const periodLength = record.periodLength ?? DEFAULT_PERIOD_LENGTH;
|
|
const diff = target.diff(start, 'day');
|
|
|
|
if (diff < 0 || diff >= periodLength) {
|
|
updated.push(record);
|
|
return;
|
|
}
|
|
if (diff === 0) return; // Remove entire record (or start of it)
|
|
updated.push({ ...record, periodLength: diff }); // Shorten it
|
|
});
|
|
return updated;
|
|
});
|
|
|
|
try {
|
|
// Logic:
|
|
// 1. Find the record covering the target date
|
|
const record = records.find((r) => {
|
|
const start = dayjs(r.startDate);
|
|
const end = start.add((r.periodLength ?? DEFAULT_PERIOD_LENGTH) - 1, 'day');
|
|
return (
|
|
(target.isSame(start, 'day') || target.isAfter(start, 'day')) &&
|
|
(target.isSame(end, 'day') || target.isBefore(end, 'day'))
|
|
);
|
|
});
|
|
|
|
if (record) {
|
|
const start = dayjs(record.startDate);
|
|
const diff = target.diff(start, 'day');
|
|
|
|
if (diff === 0) {
|
|
// If cancelling the start date, the UI removes the ENTIRE period record.
|
|
// So we should delete all samples for this period range.
|
|
const periodLength = record.periodLength ?? DEFAULT_PERIOD_LENGTH;
|
|
const endDate = start.add(periodLength - 1, 'day');
|
|
await deleteMenstrualFlow(start.toDate(), endDate.toDate());
|
|
} else {
|
|
// If cancelling a middle/end date, the UI shortens the period to end BEFORE target.
|
|
// So we delete from target date onwards to the original end date.
|
|
const periodLength = record.periodLength ?? DEFAULT_PERIOD_LENGTH;
|
|
const originalEnd = start.add(periodLength - 1, 'day');
|
|
// Delete from target to originalEnd
|
|
await deleteMenstrualFlow(target.toDate(), originalEnd.toDate());
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to delete from HealthKit', error);
|
|
setRecords(originalRecords);
|
|
}
|
|
};
|
|
|
|
const handleLoadPrevious = () => {
|
|
if (loadingPrevRef.current) return;
|
|
loadingPrevRef.current = true;
|
|
const delta = 3;
|
|
prependDeltaRef.current = delta;
|
|
setWindowConfig((prev) => ({ ...prev, before: prev.before + delta }));
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (prependDeltaRef.current > 0 && listRef.current) {
|
|
const offset = offsetRef.current + prependDeltaRef.current * ITEM_HEIGHT;
|
|
requestAnimationFrame(() => {
|
|
listRef.current?.scrollToOffset({ offset, animated: false });
|
|
prependDeltaRef.current = 0;
|
|
loadingPrevRef.current = false;
|
|
});
|
|
}
|
|
}, [timeline.months.length]);
|
|
|
|
const viewabilityConfig = useRef({
|
|
viewAreaCoveragePercentThreshold: 10,
|
|
}).current;
|
|
|
|
const onViewableItemsChanged = useRef(({ viewableItems }: any) => {
|
|
const minIndex = viewableItems.reduce(
|
|
(acc: number, cur: any) => Math.min(acc, cur.index ?? acc),
|
|
Number.MAX_SAFE_INTEGER
|
|
);
|
|
if (minIndex <= 1) {
|
|
handleLoadPrevious();
|
|
}
|
|
}).current;
|
|
|
|
|
|
|
|
const listData = useMemo(() => {
|
|
return timeline.months.map((m) => ({
|
|
type: 'month' as const,
|
|
id: m.id,
|
|
month: m,
|
|
}));
|
|
}, [timeline.months]);
|
|
|
|
const renderInlineTip = (columnIndex: number) => (
|
|
<InlineTip
|
|
selectedDate={selectedDate}
|
|
selectedInfo={selectedInfo}
|
|
columnIndex={columnIndex}
|
|
onMarkStart={handleMarkStart}
|
|
onCancelMark={handleCancelMark}
|
|
/>
|
|
);
|
|
|
|
const renderCycleTab = () => (
|
|
<View style={styles.tabContent}>
|
|
<Legend />
|
|
|
|
|
|
<FlatList
|
|
ref={listRef}
|
|
data={listData}
|
|
keyExtractor={(item) => item.id}
|
|
renderItem={({ item }) => (
|
|
<MonthBlock
|
|
month={item.month}
|
|
selectedDateKey={selectedDateKey}
|
|
onSelect={(key) => setSelectedDateKey(key)}
|
|
renderTip={renderInlineTip}
|
|
/>
|
|
)}
|
|
showsVerticalScrollIndicator={false}
|
|
initialNumToRender={3}
|
|
windowSize={5}
|
|
maxToRenderPerBatch={4}
|
|
removeClippedSubviews
|
|
contentContainerStyle={styles.listContent}
|
|
viewabilityConfig={viewabilityConfig}
|
|
onViewableItemsChanged={onViewableItemsChanged}
|
|
onScroll={(e) => {
|
|
offsetRef.current = e.nativeEvent.contentOffset.y;
|
|
}}
|
|
scrollEventThrottle={16}
|
|
/>
|
|
</View>
|
|
);
|
|
|
|
const renderAnalysisTab = () => (
|
|
<View style={styles.tabContent}>
|
|
<View style={styles.analysisCard}>
|
|
<Text style={styles.analysisTitle}>{t('menstrual.screen.analysis.title')}</Text>
|
|
<Text style={styles.analysisBody}>
|
|
{t('menstrual.screen.analysis.description')}
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
);
|
|
|
|
return (
|
|
<View style={styles.container}>
|
|
<Stack.Screen options={{ headerShown: false }} />
|
|
<LinearGradient
|
|
colors={['#fdf1ff', '#f3f4ff', '#f7f8ff']}
|
|
style={StyleSheet.absoluteFill}
|
|
start={{ x: 0, y: 0 }}
|
|
end={{ x: 0, y: 1 }}
|
|
/>
|
|
|
|
<View style={styles.header}>
|
|
<TouchableOpacity onPress={() => router.back()} style={styles.headerIcon}>
|
|
<Ionicons name="chevron-back" size={22} color="#0f172a" />
|
|
</TouchableOpacity>
|
|
<Text style={styles.headerTitle}>{t('menstrual.screen.header')}</Text>
|
|
<TouchableOpacity style={styles.headerIcon}>
|
|
<Ionicons name="settings-outline" size={20} color="#0f172a" />
|
|
</TouchableOpacity>
|
|
</View>
|
|
|
|
<View style={styles.tabSwitcher}>
|
|
{([
|
|
{ key: 'cycle', label: t('menstrual.screen.tabs.cycle') },
|
|
{ key: 'analysis', label: t('menstrual.screen.tabs.analysis') },
|
|
] as { key: TabKey; label: string }[]).map((tab) => {
|
|
const active = activeTab === tab.key;
|
|
return (
|
|
<TouchableOpacity
|
|
key={tab.key}
|
|
style={[styles.tabPill, active && styles.tabPillActive]}
|
|
onPress={() => setActiveTab(tab.key)}
|
|
activeOpacity={0.9}
|
|
>
|
|
<Text style={[styles.tabLabel, active && styles.tabLabelActive]}>
|
|
{tab.label}
|
|
</Text>
|
|
</TouchableOpacity>
|
|
);
|
|
})}
|
|
</View>
|
|
|
|
{activeTab === 'cycle' ? renderCycleTab() : renderAnalysisTab()}
|
|
</View>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
container: {
|
|
flex: 1,
|
|
paddingTop: 52,
|
|
paddingHorizontal: 16,
|
|
backgroundColor: 'transparent',
|
|
},
|
|
header: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
marginBottom: 12,
|
|
},
|
|
headerIcon: {
|
|
width: 36,
|
|
height: 36,
|
|
borderRadius: 18,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
backgroundColor: 'rgba(255,255,255,0.9)',
|
|
},
|
|
headerTitle: {
|
|
fontSize: 18,
|
|
fontWeight: '800',
|
|
color: '#0f172a',
|
|
fontFamily: 'AliBold',
|
|
},
|
|
tabSwitcher: {
|
|
flexDirection: 'row',
|
|
backgroundColor: 'rgba(255,255,255,0.7)',
|
|
borderRadius: 18,
|
|
padding: 4,
|
|
marginBottom: 16,
|
|
},
|
|
tabPill: {
|
|
flex: 1,
|
|
alignItems: 'center',
|
|
paddingVertical: 10,
|
|
borderRadius: 14,
|
|
},
|
|
tabPillActive: {
|
|
backgroundColor: '#fff',
|
|
shadowColor: '#000',
|
|
shadowOpacity: 0.08,
|
|
shadowOffset: { width: 0, height: 8 },
|
|
shadowRadius: 10,
|
|
elevation: 3,
|
|
},
|
|
tabLabel: {
|
|
color: '#4b5563',
|
|
fontWeight: '600',
|
|
fontFamily: 'AliRegular',
|
|
},
|
|
tabLabelActive: {
|
|
color: '#0f172a',
|
|
fontFamily: 'AliBold',
|
|
},
|
|
tabContent: {
|
|
flex: 1,
|
|
},
|
|
|
|
selectedCard: {
|
|
backgroundColor: '#fff',
|
|
borderRadius: 16,
|
|
paddingVertical: 10,
|
|
paddingHorizontal: 12,
|
|
marginBottom: 10,
|
|
shadowColor: '#000',
|
|
shadowOpacity: 0.08,
|
|
shadowRadius: 10,
|
|
shadowOffset: { width: 0, height: 8 },
|
|
elevation: 3,
|
|
},
|
|
selectedStatus: {
|
|
fontSize: 14,
|
|
color: '#111827',
|
|
fontWeight: '700',
|
|
fontFamily: 'AliBold',
|
|
},
|
|
|
|
listContent: {
|
|
paddingBottom: 80,
|
|
},
|
|
analysisCard: {
|
|
backgroundColor: '#fff',
|
|
borderRadius: 16,
|
|
padding: 16,
|
|
marginTop: 8,
|
|
shadowColor: '#000',
|
|
shadowOpacity: 0.08,
|
|
shadowRadius: 10,
|
|
shadowOffset: { width: 0, height: 6 },
|
|
elevation: 3,
|
|
},
|
|
analysisTitle: {
|
|
fontSize: 17,
|
|
fontWeight: '800',
|
|
color: '#0f172a',
|
|
marginBottom: 8,
|
|
fontFamily: 'AliBold',
|
|
},
|
|
analysisBody: {
|
|
fontSize: 14,
|
|
color: '#6b7280',
|
|
lineHeight: 20,
|
|
fontFamily: 'AliRegular',
|
|
},
|
|
});
|