Files
digital-pilates/app/menstrual-cycle.tsx

555 lines
18 KiB
TypeScript

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 { useTranslation } from 'react-i18next';
import {
FlatList,
StyleSheet,
Text,
TouchableOpacity,
View,
} from 'react-native';
import { InlineTip, ITEM_HEIGHT, Legend, MonthBlock } from '@/components/menstrual-cycle';
import { HeaderBar } from '@/components/ui/HeaderBar';
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
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, i18n } = useTranslation();
const safeAreaTop = useSafeAreaTop();
const [records, setRecords] = useState<CycleRecord[]>([]);
const [windowConfig, setWindowConfig] = useState({ before: 2, after: 3 });
const locale = i18n.language.startsWith('en') ? 'en' : 'zh';
const monthTitleFormat = t('menstrual.dateFormats.monthTitle', { defaultValue: 'M月' });
const monthSubtitleFormat = t('menstrual.dateFormats.monthSubtitle', { defaultValue: 'YYYY年' });
const weekLabels = useMemo(() => {
const labels = t('menstrual.weekdays', { returnObjects: true }) as string[];
return Array.isArray(labels) && labels.length === 7 ? labels : undefined;
}, [t]);
// 从 HealthKit 拉取当前窗口范围内的经期数据
useEffect(() => {
const loadData = async () => {
// 根据 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,
locale,
monthTitleFormat,
monthSubtitleFormat,
}),
[records, windowConfig, locale, monthSubtitleFormat, monthTitleFormat]
);
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 hasAutoScrolledRef = useRef(false);
const todayMonthId = useMemo(() => dayjs().format('YYYY-MM'), []);
const selectedInfo = timeline.dayMap[selectedDateKey];
const selectedDate = dayjs(selectedDateKey);
const initialMonthIndex = useMemo(
() => timeline.months.findIndex((month) => month.id === todayMonthId),
[timeline.months, todayMonthId]
);
useEffect(() => {
if (hasAutoScrolledRef.current) return;
if (initialMonthIndex < 0 || !listRef.current) return;
hasAutoScrolledRef.current = true;
offsetRef.current = initialMonthIndex * ITEM_HEIGHT;
requestAnimationFrame(() => {
listRef.current?.scrollToIndex({ index: initialMonthIndex, animated: false });
});
}, [initialMonthIndex]);
// 标记当天为经期开始(包含乐观更新与 HealthKit 同步)
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);
}
};
// 取消选中日期的经期标记(与 HealthKit 同步)
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;
// FlatList 数据源:按月份拆分
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}
weekLabels={weekLabels}
/>
)}
showsVerticalScrollIndicator={false}
initialNumToRender={3}
windowSize={5}
maxToRenderPerBatch={4}
removeClippedSubviews
contentContainerStyle={styles.listContent}
// 使用固定高度优化初始滚动定位
getItemLayout={(_, index) => ({
length: ITEM_HEIGHT,
offset: ITEM_HEIGHT * index,
index,
})}
initialScrollIndex={initialMonthIndex >= 0 ? initialMonthIndex : undefined}
onScrollToIndexFailed={({ index }) => {
listRef.current?.scrollToOffset({
offset: ITEM_HEIGHT * index,
animated: false,
});
}}
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 }}
/>
<HeaderBar
title={t('menstrual.screen.header')}
onBack={() => router.back()}
// right={
// isLiquidGlassAvailable() ? (
// <TouchableOpacity style={styles.headerIconButton} activeOpacity={0.7}>
// <GlassView
// style={styles.headerIconGlass}
// glassEffectStyle="clear"
// tintColor="rgba(255, 255, 255, 0.35)"
// isInteractive={true}
// >
// <Ionicons name="settings-outline" size={20} color="#0f172a" />
// </GlassView>
// </TouchableOpacity>
// ) : (
// <TouchableOpacity style={styles.headerIcon} activeOpacity={0.7}>
// <Ionicons name="settings-outline" size={20} color="#0f172a" />
// </TouchableOpacity>
// )
// }
/>
<View style={{ height: safeAreaTop }} />
<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,
paddingHorizontal: 16,
backgroundColor: 'transparent',
},
headerIcon: {
width: 36,
height: 36,
borderRadius: 18,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(255, 255, 255, 0.5)',
},
headerIconButton: {
width: 36,
height: 36,
borderRadius: 18,
overflow: 'hidden',
},
headerIconGlass: {
width: 36,
height: 36,
borderRadius: 18,
alignItems: 'center',
justifyContent: 'center',
},
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',
},
});