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 { DimensionValue, FlatList, StyleSheet, Text, TouchableOpacity, View, } from 'react-native'; import { Colors } from '@/constants/Colors'; import { CycleRecord, DEFAULT_PERIOD_LENGTH, MenstrualDayCell, MenstrualDayStatus, MenstrualTimeline, buildMenstrualTimeline } from '@/utils/menstrualCycle'; type TabKey = 'cycle' | 'analysis'; const ITEM_HEIGHT = 380; const STATUS_COLORS: Record = { period: { bg: '#f5679f', text: '#fff' }, 'predicted-period': { bg: '#f8d9e9', text: '#9b2c6a' }, fertile: { bg: '#d9d2ff', text: '#5a52c5' }, 'ovulation-day': { bg: '#5b4ee4', text: '#fff' }, }; const WEEK_LABELS = ['一', '二', '三', '四', '五', '六', '日']; const chunkArray = (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; }; const DayCell = ({ cell, isSelected, onPress, }: { cell: Extract; isSelected: boolean; onPress: () => void; }) => { const status = cell.info?.status; const colors = status ? STATUS_COLORS[status] : undefined; return ( {cell.label} {cell.isToday && 今天} ); }; const MonthBlock = ({ month, selectedDateKey, onSelect, renderTip, }: { month: MenstrualTimeline['months'][number]; selectedDateKey: string; onSelect: (dateKey: string) => void; renderTip: (colIndex: number) => React.ReactNode; }) => { const weeks = useMemo(() => chunkArray(month.cells, 7), [month.cells]); return ( {month.title} {month.subtitle} {WEEK_LABELS.map((label) => ( {label} ))} {weeks.map((week, weekIndex) => { const selectedIndex = week.findIndex( (c) => c.type === 'day' && c.date.format('YYYY-MM-DD') === selectedDateKey ); return ( {week.map((cell) => { if (cell.type === 'placeholder') { return ; } const dateKey = cell.date.format('YYYY-MM-DD'); return ( onSelect(dateKey)} /> ); })} {selectedIndex !== -1 && ( {renderTip(selectedIndex)} )} ); })} ); }; export default function MenstrualCycleScreen() { const router = useRouter(); const [records, setRecords] = useState([]); const [windowConfig, setWindowConfig] = useState({ before: 2, after: 3 }); const timeline = useMemo( () => buildMenstrualTimeline({ monthsBefore: windowConfig.before, monthsAfter: windowConfig.after, records, defaultPeriodLength: DEFAULT_PERIOD_LENGTH, }), [records, windowConfig] ); const [activeTab, setActiveTab] = useState('cycle'); const [selectedDateKey, setSelectedDateKey] = useState( dayjs().format('YYYY-MM-DD') ); const listRef = useRef(null); const offsetRef = useRef(0); const prependDeltaRef = useRef(0); const loadingPrevRef = useRef(false); const selectedInfo = timeline.dayMap[selectedDateKey]; const selectedDate = dayjs(selectedDateKey); const handleMarkStart = () => { if (selectedDate.isAfter(dayjs(), 'day')) return; // Check if the selected date is already covered by an existing record (including duration) 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; setRecords((prev) => { const updated = [...prev]; // 1. Check if selectedDate is immediately after an existing period 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'); }); // 2. Check if selectedDate is immediately before an existing period const nextRecordIndex = updated.findIndex((r) => { return dayjs(r.startDate).subtract(1, 'day').isSame(selectedDate, 'day'); }); if (prevRecordIndex !== -1 && nextRecordIndex !== -1) { // Merge three parts: Prev + Selected + Next 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, }; // Remove the next record since it's merged updated.splice(nextRecordIndex, 1); } else if (prevRecordIndex !== -1) { // Extend previous record const prevRecord = updated[prevRecordIndex]; updated[prevRecordIndex] = { ...prevRecord, periodLength: (prevRecord.periodLength ?? DEFAULT_PERIOD_LENGTH) + 1, }; } else if (nextRecordIndex !== -1) { // Extend next record (start earlier) const nextRecord = updated[nextRecordIndex]; updated[nextRecordIndex] = { ...nextRecord, startDate: selectedDate.format('YYYY-MM-DD'), periodLength: (nextRecord.periodLength ?? DEFAULT_PERIOD_LENGTH) + 1, }; } else { // Create new isolated record 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() ); }); }; const handleCancelMark = () => { if (!selectedInfo || !selectedInfo.confirmed) return; if (selectedDate.isAfter(dayjs(), 'day')) return; const target = selectedDate; 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; } // diff > 0 且在区间内:将该日标记为结束日 (选中当日也被取消,所以长度为 diff) updated.push({ ...record, periodLength: diff, }); }); return updated; }); }; 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 renderLegend = () => ( {[ { label: '经期', key: 'period' as const }, { label: '预测经期', key: 'predicted-period' as const }, { label: '排卵期', key: 'fertile' as const }, { label: '排卵日', key: 'ovulation-day' as const }, ].map((item) => ( {item.label} ))} ); const listData = useMemo(() => { return timeline.months.map((m) => ({ type: 'month' as const, id: m.id, month: m, })); }, [timeline.months]); const renderInlineTip = (columnIndex: number) => { // 14.28% per cell. Center is 7.14%. const pointerLeft = `${columnIndex * 14.2857 + 7.1428}%` as DimensionValue; const isFuture = selectedDate.isAfter(dayjs(), 'day'); const base = ( {selectedDate.format('M月D日')} {!isFuture && (!selectedInfo || !selectedInfo.confirmed) && ( 标记经期 )} {!isFuture && selectedInfo?.confirmed && selectedInfo.status === 'period' && ( 取消标记 )} ); return base; }; const renderCycleTab = () => ( {renderLegend()} item.id} renderItem={({ item }) => ( 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} /> ); const renderAnalysisTab = () => ( 分析 基于最近 6 个周期的记录,计算平均经期和周期长度,后续会展示趋势和预测准确度。 ); return ( router.back()} style={styles.headerIcon}> 生理周期 {([ { key: 'cycle', label: '生理周期' }, { key: 'analysis', label: '分析' }, ] as { key: TabKey; label: string }[]).map((tab) => { const active = activeTab === tab.key; return ( setActiveTab(tab.key)} activeOpacity={0.9} > {tab.label} ); })} {activeTab === 'cycle' ? renderCycleTab() : renderAnalysisTab()} ); } 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, }, 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', }, 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', }, tipCard: { backgroundColor: '#f4f3ff', borderRadius: 14, padding: 12, marginTop: 10, borderWidth: 1, borderColor: '#ede9fe', }, tipTitle: { fontSize: 14, color: '#111827', fontWeight: '700', marginBottom: 4, fontFamily: 'AliBold', }, tipDesc: { fontSize: 12, color: '#6b7280', lineHeight: 18, marginBottom: 8, fontFamily: 'AliRegular', }, tipButton: { backgroundColor: Colors.light.primary, paddingVertical: 10, borderRadius: 12, alignItems: 'center', }, tipButtonText: { color: '#fff', fontSize: 14, fontWeight: '700', fontFamily: 'AliBold', }, tipSecondaryButton: { backgroundColor: '#fff', paddingVertical: 10, borderRadius: 12, alignItems: 'center', borderWidth: 1, borderColor: '#e5e7eb', }, tipSecondaryButtonText: { color: '#0f172a', fontSize: 14, fontWeight: '700', fontFamily: 'AliBold', }, 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', }, 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, }, 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', }, 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', }, });