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([]); 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('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 = 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) => ( ); const renderCycleTab = () => ( 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 = () => ( {t('menstrual.screen.analysis.title')} {t('menstrual.screen.analysis.description')} ); return ( router.back()} style={styles.headerIcon}> {t('menstrual.screen.header')} {([ { 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 ( 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, }, 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', }, });