From 9b4a300380a7baef5505ae859232238706291d32 Mon Sep 17 00:00:00 2001 From: richarjiang Date: Tue, 16 Dec 2025 17:25:21 +0800 Subject: [PATCH] =?UTF-8?q?feat(app):=20=E6=96=B0=E5=A2=9E=E7=94=9F?= =?UTF-8?q?=E7=90=86=E5=91=A8=E6=9C=9F=E8=AE=B0=E5=BD=95=E5=8A=9F=E8=83=BD?= =?UTF-8?q?=E4=B8=8E=E9=A6=96=E9=A1=B5=E5=8D=A1=E7=89=87=E8=87=AA=E5=AE=9A?= =?UTF-8?q?=E4=B9=89=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增生理周期追踪页面及相关算法逻辑,支持经期记录与预测 - 新增首页统计卡片自定义页面,支持VIP用户调整卡片显示状态与顺序 - 重构首页统计页面布局逻辑,支持动态渲染与混合布局 - 引入 react-native-draggable-flatlist 用于实现拖拽排序功能 - 添加相关多语言配置及用户偏好设置存储接口 --- app/(tabs)/statistics.tsx | 305 +++++++++--- app/menstrual-cycle.tsx | 799 ++++++++++++++++++++++++++++++ app/statistics-customization.tsx | 357 +++++++++++++ components/MenstrualCycleCard.tsx | 157 ++++++ i18n/en/personal.ts | 22 + i18n/zh/personal.ts | 22 + package-lock.json | 15 + package.json | 1 + utils/menstrualCycle.ts | 260 ++++++++++ utils/userPreferences.ts | 182 ++++++- 10 files changed, 2041 insertions(+), 79 deletions(-) create mode 100644 app/menstrual-cycle.tsx create mode 100644 app/statistics-customization.tsx create mode 100644 components/MenstrualCycleCard.tsx create mode 100644 utils/menstrualCycle.ts diff --git a/app/(tabs)/statistics.tsx b/app/(tabs)/statistics.tsx index 6ba0113..0d28674 100644 --- a/app/(tabs)/statistics.tsx +++ b/app/(tabs)/statistics.tsx @@ -1,6 +1,7 @@ import { BasalMetabolismCard } from '@/components/BasalMetabolismCard'; import { DateSelector } from '@/components/DateSelector'; import { FitnessRingsCard } from '@/components/FitnessRingsCard'; +import { MenstrualCycleCard } from '@/components/MenstrualCycleCard'; import { MoodCard } from '@/components/MoodCard'; import { NutritionRadarCard } from '@/components/NutritionRadarCard'; import CircumferenceCard from '@/components/statistic/CircumferenceCard'; @@ -22,13 +23,14 @@ import { fetchTodayWaterStats } from '@/store/waterSlice'; import { getMonthDaysZh, getTodayIndexInMonth } from '@/utils/date'; import { fetchHealthDataForDate } from '@/utils/health'; import { logger } from '@/utils/logger'; +import { DEFAULT_CARD_ORDER, getStatisticsCardOrder, getStatisticsCardsVisibility, StatisticsCardsVisibility } from '@/utils/userPreferences'; import { Ionicons } from '@expo/vector-icons'; import dayjs from 'dayjs'; import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect'; import { LinearGradient } from 'expo-linear-gradient'; -import { useRouter } from 'expo-router'; +import { useFocusEffect, useRouter } from 'expo-router'; import { debounce } from 'lodash'; -import React, { useEffect, useMemo, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { AppState, @@ -90,9 +92,49 @@ export default function ExploreScreen() { router.push('/gallery'); }, [ensureLoggedIn, router]); + const handleOpenCustomization = React.useCallback(() => { + router.push('/statistics-customization'); + }, [router]); + // 用于触发动画重置的 token(当日期或数据变化时更新) const [animToken, setAnimToken] = useState(0); + // 首页卡片显示设置 + const [cardVisibility, setCardVisibility] = useState({ + showMood: true, + showSteps: true, + showStress: true, + showSleep: true, + showFitnessRings: true, + showWater: true, + showBasalMetabolism: true, + showOxygenSaturation: true, + showMenstrualCycle: true, + showWeight: true, + showCircumference: true, + }); + const [cardOrder, setCardOrder] = useState(DEFAULT_CARD_ORDER); + + // 加载卡片设置 + const loadSettings = useCallback(async () => { + try { + const [visibility, order] = await Promise.all([ + getStatisticsCardsVisibility(), + getStatisticsCardOrder(), + ]); + setCardVisibility(visibility); + setCardOrder(order); + } catch (error) { + console.error('Failed to load card settings:', error); + } + }, []); + + // 页面聚焦时加载设置 + useFocusEffect( + useCallback(() => { + loadSettings(); + }, [loadSettings]) + ); // 心情相关状态 const dispatch = useAppDispatch(); @@ -423,6 +465,26 @@ export default function ExploreScreen() { + + {isLiquidGlassAvailable() ? ( + + + + ) : ( + + + + )} + + {t('statistics.sections.bodyMetrics')} - {/* 真正瀑布流布局 */} - - {/* 左列 */} - - {/* 心情卡片 */} - - pushIfAuthedElseLogin('/mood/calendar')} - isLoading={isMoodLoading} - /> - + {/* 动态布局:支持混合瀑布流和全宽卡片 */} + + {(() => { + // 定义所有卡片及其显示状态 + const allCardsMap: Record = { + mood: { + visible: cardVisibility.showMood, + component: ( + pushIfAuthedElseLogin('/mood/calendar')} + isLoading={isMoodLoading} + /> + ) + }, + steps: { + visible: cardVisibility.showSteps, + component: ( + + ) + }, + stress: { + visible: cardVisibility.showStress, + component: ( + + ) + }, + sleep: { + visible: cardVisibility.showSleep, + component: ( + + ) + }, + fitness: { + visible: cardVisibility.showFitnessRings, + component: ( + + ) + }, + water: { + visible: cardVisibility.showWater, + component: ( + + ) + }, + metabolism: { + visible: cardVisibility.showBasalMetabolism, + component: ( + + ) + }, + oxygen: { + visible: cardVisibility.showOxygenSaturation, + component: ( + + ) + }, + menstrual: { + visible: cardVisibility.showMenstrualCycle, + component: ( + pushIfAuthedElseLogin('/menstrual-cycle')} + /> + ) + }, + weight: { + visible: cardVisibility.showWeight, + isFullWidth: true, + component: ( + + ) + }, + circumference: { + visible: cardVisibility.showCircumference, + isFullWidth: true, + component: ( + + ) + } + }; - - - + const allKeys = Object.keys(allCardsMap); + const sortedKeys = Array.from(new Set([...cardOrder, ...allKeys])) + .filter(key => allCardsMap[key]); + const visibleCards = sortedKeys + .map(key => ({ id: key, ...allCardsMap[key] })) + .filter(card => card.visible); - - - + // 分组逻辑:将连续的瀑布流卡片聚合,全宽卡片单独作为一组 + const blocks: any[] = []; + let currentMasonryBlock: any[] = []; - {/* 心率卡片 */} - {/* - - */} + visibleCards.forEach(card => { + if (card.isFullWidth) { + // 如果有未处理的瀑布流卡片,先结算 + if (currentMasonryBlock.length > 0) { + blocks.push({ type: 'masonry', items: [...currentMasonryBlock] }); + currentMasonryBlock = []; + } + // 添加全宽卡片 + blocks.push({ type: 'full', item: card }); + } else { + // 添加到当前瀑布流组 + currentMasonryBlock.push(card); + } + }); - - - - + // 结算剩余的瀑布流卡片 + if (currentMasonryBlock.length > 0) { + blocks.push({ type: 'masonry', items: [...currentMasonryBlock] }); + } - {/* 右列 */} - - - - - {/* 饮水记录卡片 */} - - - + return blocks.map((block, blockIndex) => { + if (block.type === 'full') { + return ( + + {block.item.component} + + ); + } else { + // 渲染瀑布流块 + const leftColumnCards = block.items.filter((_: any, index: number) => index % 2 === 0); + const rightColumnCards = block.items.filter((_: any, index: number) => index % 2 !== 0); - - {/* 基础代谢卡片 */} - - - - - {/* 血氧饱和度卡片 */} - - - - - - + return ( + + + {leftColumnCards.map((card: any) => ( + + {card.component} + + ))} + + + {rightColumnCards.map((card: any) => ( + + {card.component} + + ))} + + + ); + } + }); + })()} - - - {/* 围度数据卡片 - 占满底部一行 */} - + @@ -868,6 +1014,9 @@ const styles = StyleSheet.create({ justifyContent: 'space-between', marginBottom: 16, }, + layoutContainer: { + flex: 1, + }, masonryContainer: { flexDirection: 'row', gap: 16, diff --git a/app/menstrual-cycle.tsx b/app/menstrual-cycle.tsx new file mode 100644 index 0000000..9b8ce60 --- /dev/null +++ b/app/menstrual-cycle.tsx @@ -0,0 +1,799 @@ +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', + }, +}); diff --git a/app/statistics-customization.tsx b/app/statistics-customization.tsx new file mode 100644 index 0000000..0ccba49 --- /dev/null +++ b/app/statistics-customization.tsx @@ -0,0 +1,357 @@ +import { HeaderBar } from '@/components/ui/HeaderBar'; +import { palette } from '@/constants/Colors'; +import { useMembershipModal } from '@/contexts/MembershipModalContext'; +import { useToast } from '@/contexts/ToastContext'; +import { useI18n } from '@/hooks/useI18n'; +import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding'; +import { useVipService } from '@/hooks/useVipService'; +import { + getStatisticsCardOrder, + getStatisticsCardsVisibility, + setStatisticsCardOrder, + setStatisticsCardVisibility, + StatisticsCardsVisibility +} from '@/utils/userPreferences'; +import { Ionicons } from '@expo/vector-icons'; +import { LinearGradient } from 'expo-linear-gradient'; +import { router, useFocusEffect } from 'expo-router'; +import React, { useCallback, useState } from 'react'; +import { StatusBar, StyleSheet, Switch, Text, TouchableOpacity, View } from 'react-native'; +import DraggableFlatList, { RenderItemParams, ScaleDecorator } from 'react-native-draggable-flatlist'; +import { GestureHandlerRootView } from 'react-native-gesture-handler'; + +type CardItem = { + key: string; + title: string; + icon: keyof typeof Ionicons.glyphMap; + visible: boolean; + visibilityKey: keyof StatisticsCardsVisibility; +}; + +export default function StatisticsCustomizationScreen() { + const safeAreaTop = useSafeAreaTop(60); + const { t } = useI18n(); + const { isVip } = useVipService(); + const { openMembershipModal } = useMembershipModal(); + const { showToast } = useToast(); + const [isLoading, setIsLoading] = useState(true); + const [data, setData] = useState([]); + + const CARD_CONFIG: Record = { + mood: { icon: 'happy-outline', titleKey: 'statisticsCustomization.items.mood', visibilityKey: 'showMood' }, + steps: { icon: 'footsteps-outline', titleKey: 'statisticsCustomization.items.steps', visibilityKey: 'showSteps' }, + stress: { icon: 'pulse-outline', titleKey: 'statisticsCustomization.items.stress', visibilityKey: 'showStress' }, + sleep: { icon: 'moon-outline', titleKey: 'statisticsCustomization.items.sleep', visibilityKey: 'showSleep' }, + fitness: { icon: 'fitness-outline', titleKey: 'statisticsCustomization.items.fitnessRings', visibilityKey: 'showFitnessRings' }, + water: { icon: 'water-outline', titleKey: 'statisticsCustomization.items.water', visibilityKey: 'showWater' }, + metabolism: { icon: 'flame-outline', titleKey: 'statisticsCustomization.items.basalMetabolism', visibilityKey: 'showBasalMetabolism' }, + oxygen: { icon: 'water-outline', titleKey: 'statisticsCustomization.items.oxygenSaturation', visibilityKey: 'showOxygenSaturation' }, + menstrual: { icon: 'rose-outline', titleKey: 'statisticsCustomization.items.menstrualCycle', visibilityKey: 'showMenstrualCycle' }, + weight: { icon: 'scale-outline', titleKey: 'statisticsCustomization.items.weight', visibilityKey: 'showWeight' }, + circumference: { icon: 'body-outline', titleKey: 'statisticsCustomization.items.circumference', visibilityKey: 'showCircumference' }, + }; + + // 加载设置 + const loadSettings = useCallback(async () => { + try { + const [visibility, order] = await Promise.all([ + getStatisticsCardsVisibility(), + getStatisticsCardOrder(), + ]); + + // 确保 order 包含所有配置的 key (处理新增 key 的情况) + const allKeys = Object.keys(CARD_CONFIG); + const uniqueOrder = Array.from(new Set([...order, ...allKeys])); + + const listData: CardItem[] = uniqueOrder + .filter(key => CARD_CONFIG[key]) // 过滤掉无效 key + .map(key => { + const config = CARD_CONFIG[key]; + return { + key, + title: t(config.titleKey), + icon: config.icon, + visible: visibility[config.visibilityKey], + visibilityKey: config.visibilityKey, + }; + }); + + setData(listData); + } catch (error) { + console.error('Failed to load statistics customization settings:', error); + } finally { + setIsLoading(false); + } + }, [t]); + + // 页面聚焦时加载设置 + useFocusEffect( + useCallback(() => { + loadSettings(); + }, [loadSettings]) + ); + + // 处理开关切换 + const handleToggle = async (item: CardItem, value: boolean) => { + if (!isVip) { + showToast({ + type: 'info', + message: t('statisticsCustomization.vipRequired'), + }); + openMembershipModal(); + return; + } + + try { + // 乐观更新 UI + setData(prev => prev.map(d => d.key === item.key ? { ...d, visible: value } : d)); + + await setStatisticsCardVisibility(item.visibilityKey, value); + } catch (error) { + console.error(`Failed to set ${item.key}:`, error); + // 回滚 + setData(prev => prev.map(d => d.key === item.key ? { ...d, visible: !value } : d)); + } + }; + + // 处理排序结束 + const handleDragEnd = async ({ data: newData }: { data: CardItem[] }) => { + setData(newData); + const newOrder = newData.map(item => item.key); + try { + await setStatisticsCardOrder(newOrder); + } catch (error) { + console.error('Failed to save card order:', error); + } + }; + + const renderItem = useCallback(({ item, drag, isActive }: RenderItemParams) => { + const handleDrag = () => { + if (!isVip) { + showToast({ + type: 'info', + message: t('statisticsCustomization.vipRequired'), + }); + openMembershipModal(); + return; + } + drag(); + }; + + return ( + + + + + + + + + + + {item.title} + + handleToggle(item, v)} + trackColor={{ false: '#E5E5E5', true: '#9370DB' }} + thumbColor="#FFFFFF" + style={styles.switch} + /> + + + + ); + }, [handleToggle, isVip, t, showToast, openMembershipModal]); + + if (isLoading) { + return ( + + + + + {t('notificationSettings.loading')} + + + ); + } + + return ( + + + + + + router.back()} + /> + + item.key} + renderItem={renderItem} + contentContainerStyle={[ + styles.scrollContent, + { paddingTop: safeAreaTop } + ]} + showsVerticalScrollIndicator={false} + ListHeaderComponent={() => ( + <> + + {t('notificationSettings.sections.description')} + + + + + {t('statisticsCustomization.description.text')} + + + + + + + {t('statisticsCustomization.sectionTitle')} + + + )} + /> + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#F5F5F5', + }, + gradientBackground: { + position: 'absolute', + left: 0, + right: 0, + top: 0, + height: '60%', + }, + loadingContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, + loadingText: { + fontSize: 16, + color: '#666', + }, + scrollContent: { + paddingHorizontal: 16, + paddingBottom: 40, + }, + headerSection: { + marginBottom: 20, + }, + subtitle: { + fontSize: 14, + color: '#6C757D', + marginBottom: 12, + marginLeft: 4, + }, + descriptionCard: { + backgroundColor: 'rgba(255, 255, 255, 0.6)', + borderRadius: 12, + padding: 12, + gap: 8, + borderWidth: 1, + borderColor: 'rgba(147, 112, 219, 0.1)', + }, + hintRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + }, + descriptionText: { + flex: 1, + fontSize: 13, + color: '#2C3E50', + lineHeight: 18, + }, + sectionHeader: { + marginBottom: 12, + marginLeft: 4, + }, + sectionTitle: { + fontSize: 16, + fontWeight: '600', + color: '#2C3E50', + }, + rowItem: { + backgroundColor: '#FFFFFF', + borderRadius: 16, + marginBottom: 12, + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.03, + shadowRadius: 4, + elevation: 2, + }, + activeItem: { + backgroundColor: '#FAFAFA', + shadowColor: '#000', + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.1, + shadowRadius: 8, + elevation: 4, + zIndex: 100, + transform: [{ scale: 1.02 }], + }, + itemContent: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + padding: 16, + height: 72, + }, + leftContent: { + flexDirection: 'row', + alignItems: 'center', + flex: 1, + }, + dragHandle: { + paddingRight: 12, + }, + iconContainer: { + width: 40, + height: 40, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: 'rgba(147, 112, 219, 0.05)', + borderRadius: 12, + marginRight: 12, + }, + itemTitle: { + fontSize: 16, + fontWeight: '500', + color: '#2C3E50', + flex: 1, + }, + switch: { + transform: [{ scaleX: 0.9 }, { scaleY: 0.9 }], + }, +}); \ No newline at end of file diff --git a/components/MenstrualCycleCard.tsx b/components/MenstrualCycleCard.tsx new file mode 100644 index 0000000..bd3e7a7 --- /dev/null +++ b/components/MenstrualCycleCard.tsx @@ -0,0 +1,157 @@ +import { LinearGradient } from 'expo-linear-gradient'; +import React, { useMemo } from 'react'; +import { StyleSheet, Text, TouchableOpacity, View } from 'react-native'; + +import { Colors } from '@/constants/Colors'; +import { buildMenstrualTimeline } from '@/utils/menstrualCycle'; + +type Props = { + onPress?: () => void; +}; + +const RingIcon = () => ( + + + + + +); + +export const MenstrualCycleCard: React.FC = ({ onPress }) => { + const { todayInfo, periodLength } = useMemo(() => buildMenstrualTimeline(), []); + + const summary = useMemo(() => { + if (!todayInfo) { + return { + state: '待记录', + dayText: '点击记录本次经期', + number: undefined, + }; + } + + 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 ( + + + + 生理周期 + + + + + + {summary.state} + + {summary.number !== undefined ? ( + <> + 第 {summary.number} {summary.dayText} + + ) : ( + summary.dayText + )} + + + + ); +}; + +const styles = StyleSheet.create({ + wrapper: { + width: '100%', + }, + headerRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 10, + }, + iconWrapper: { + width: 24, + height: 24, + borderRadius: 12, + alignItems: 'center', + justifyContent: 'center', + }, + iconGradient: { + width: 22, + height: 22, + borderRadius: 11, + alignItems: 'center', + justifyContent: 'center', + }, + iconInner: { + width: 10, + height: 10, + borderRadius: 5, + backgroundColor: '#fff', + }, + title: { + fontSize: 14, + color: '#192126', + fontWeight: '600', + flex: 1, + fontFamily: 'AliBold', + }, + badgeOuter: { + width: 18, + height: 18, + borderRadius: 9, + borderWidth: 2, + borderColor: '#fbcfe8', + alignItems: 'center', + justifyContent: 'center', + }, + badgeInner: { + width: 6, + height: 6, + borderRadius: 3, + backgroundColor: Colors.light.primary, + opacity: 0.35, + }, + content: { + marginTop: 12, + }, + stateText: { + fontSize: 12, + color: '#515558', + marginBottom: 4, + fontFamily: 'AliRegular', + }, + dayRow: { + fontSize: 14, + color: '#192126', + fontFamily: 'AliRegular', + }, + dayNumber: { + fontSize: 18, + fontWeight: '700', + color: '#192126', + fontFamily: 'AliBold', + }, +}); diff --git a/i18n/en/personal.ts b/i18n/en/personal.ts index adb0d76..0af3f55 100644 --- a/i18n/en/personal.ts +++ b/i18n/en/personal.ts @@ -112,6 +112,28 @@ export const personal = { }, }; +export const statisticsCustomization = { + title: 'Home Content Settings', + sectionTitle: 'Body Metrics Cards', + description: { + text: '• Customize the body metrics modules displayed on the home page\n• Hidden modules will not be shown on the home page, but data will be retained', + }, + items: { + mood: 'Mood', + steps: 'Steps', + stress: 'Stress', + sleep: 'Sleep', + fitnessRings: 'Fitness Rings', + water: 'Water Intake', + basalMetabolism: 'Basal Metabolism', + oxygenSaturation: 'Oxygen Saturation', + menstrualCycle: 'Menstrual Cycle', + weight: 'Weight', + circumference: 'Circumference', + }, + vipRequired: 'VIP membership required to customize home layout', +}; + export const editProfile = { title: 'Edit Profile', fields: { diff --git a/i18n/zh/personal.ts b/i18n/zh/personal.ts index 6b8d07d..61dc8ac 100644 --- a/i18n/zh/personal.ts +++ b/i18n/zh/personal.ts @@ -112,6 +112,28 @@ export const personal = { }, }; +export const statisticsCustomization = { + title: '首页内容设置', + sectionTitle: '身体指标卡片', + description: { + text: '• 自定义首页展示的身体指标模块\n• 关闭的模块将不会在首页显示,但数据仍会保留', + }, + items: { + mood: '心情', + steps: '步数', + stress: '压力', + sleep: '睡眠', + fitnessRings: '健身圆环', + water: '饮水', + basalMetabolism: '基础代谢', + oxygenSaturation: '血氧', + menstrualCycle: '经期', + weight: '体重', + circumference: '围度', + }, + vipRequired: '需要开通 VIP 会员才能自定义首页布局', +}; + export const editProfile = { title: '编辑资料', fields: { diff --git a/package-lock.json b/package-lock.json index 7c22f6b..68adb50 100644 --- a/package-lock.json +++ b/package-lock.json @@ -60,6 +60,7 @@ "react-native": "0.81.5", "react-native-chart-kit": "^6.12.0", "react-native-device-info": "^14.0.4", + "react-native-draggable-flatlist": "^4.0.3", "react-native-gesture-handler": "~2.28.0", "react-native-image-viewing": "^0.2.2", "react-native-markdown-display": "^7.0.2", @@ -12210,6 +12211,20 @@ "react-native": "*" } }, + "node_modules/react-native-draggable-flatlist": { + "version": "4.0.3", + "resolved": "https://mirrors.tencent.com/npm/react-native-draggable-flatlist/-/react-native-draggable-flatlist-4.0.3.tgz", + "integrity": "sha512-2F4x5BFieWdGq9SetD2nSAR7s7oQCSgNllYgERRXXtNfSOuAGAVbDb/3H3lP0y5f7rEyNwabKorZAD/SyyNbDw==", + "license": "MIT", + "dependencies": { + "@babel/preset-typescript": "^7.17.12" + }, + "peerDependencies": { + "react-native": ">=0.64.0", + "react-native-gesture-handler": ">=2.0.0", + "react-native-reanimated": ">=2.8.0" + } + }, "node_modules/react-native-fit-image": { "version": "1.5.5", "resolved": "https://mirrors.tencent.com/npm/react-native-fit-image/-/react-native-fit-image-1.5.5.tgz", diff --git a/package.json b/package.json index 5da37cd..03121d3 100644 --- a/package.json +++ b/package.json @@ -61,6 +61,7 @@ "react-native": "0.81.5", "react-native-chart-kit": "^6.12.0", "react-native-device-info": "^14.0.4", + "react-native-draggable-flatlist": "^4.0.3", "react-native-gesture-handler": "~2.28.0", "react-native-image-viewing": "^0.2.2", "react-native-markdown-display": "^7.0.2", diff --git a/utils/menstrualCycle.ts b/utils/menstrualCycle.ts new file mode 100644 index 0000000..d2515d9 --- /dev/null +++ b/utils/menstrualCycle.ts @@ -0,0 +1,260 @@ +import dayjs, { Dayjs } from 'dayjs'; + +export type MenstrualDayStatus = 'period' | 'predicted-period' | 'fertile' | 'ovulation-day'; + +export type CycleRecord = { + startDate: string; + periodLength?: number; + cycleLength?: number; + source?: 'healthkit' | 'manual'; +}; + +export type MenstrualDayInfo = { + date: Dayjs; + status: MenstrualDayStatus; + confirmed: boolean; + dayOfCycle?: number; +}; + +export type MenstrualDayCell = + | { + type: 'placeholder'; + key: string; + } + | { + type: 'day'; + key: string; + label: number; + date: Dayjs; + info?: MenstrualDayInfo; + isToday: boolean; + }; + +export type MenstrualMonth = { + id: string; + title: string; + subtitle: string; + cells: MenstrualDayCell[]; +}; + +export type MenstrualTimeline = { + months: MenstrualMonth[]; + dayMap: Record; + cycleLength: number; + periodLength: number; + todayInfo?: MenstrualDayInfo; +}; + +const STATUS_PRIORITY: Record = { + 'ovulation-day': 4, + period: 3, + 'predicted-period': 2, + fertile: 1, +}; + +export const DEFAULT_CYCLE_LENGTH = 28; +export const DEFAULT_PERIOD_LENGTH = 5; + +export const createDefaultRecords = (): CycleRecord[] => { + const today = dayjs(); + const latestStart = today.subtract(4, 'day'); // 默认让今天处于经期第5天 + const previousStart = latestStart.subtract(DEFAULT_CYCLE_LENGTH, 'day'); + const olderStart = previousStart.subtract(DEFAULT_CYCLE_LENGTH, 'day'); + + return [ + { startDate: olderStart.format('YYYY-MM-DD'), periodLength: DEFAULT_PERIOD_LENGTH }, + { startDate: previousStart.format('YYYY-MM-DD'), periodLength: DEFAULT_PERIOD_LENGTH }, + { startDate: latestStart.format('YYYY-MM-DD'), periodLength: DEFAULT_PERIOD_LENGTH }, + ]; +}; + +const calcAverageCycleLength = (records: CycleRecord[], fallback = DEFAULT_CYCLE_LENGTH) => { + if (records.length < 2) return fallback; + const sorted = [...records].sort( + (a, b) => dayjs(a.startDate).valueOf() - dayjs(b.startDate).valueOf() + ); + const intervals: number[] = []; + for (let i = 1; i < sorted.length; i += 1) { + const diff = dayjs(sorted[i].startDate).diff(dayjs(sorted[i - 1].startDate), 'day'); + if (diff > 0) { + intervals.push(diff); + } + } + if (!intervals.length) return fallback; + const avg = intervals.reduce((sum, cur) => sum + cur, 0) / intervals.length; + return Math.round(avg); +}; + +const calcAveragePeriodLength = (records: CycleRecord[], fallback = DEFAULT_PERIOD_LENGTH) => { + const lengths = records + .map((r) => r.periodLength) + .filter((l): l is number => typeof l === 'number' && l > 0); + if (!lengths.length) return fallback; + const avg = lengths.reduce((sum, cur) => sum + cur, 0) / lengths.length; + return Math.round(avg); +}; + +const addDayInfo = ( + dayMap: Record, + date: Dayjs, + info: MenstrualDayInfo +) => { + const key = date.format('YYYY-MM-DD'); + const existing = dayMap[key]; + + if (existing && STATUS_PRIORITY[existing.status] >= STATUS_PRIORITY[info.status]) { + return; + } + + dayMap[key] = info; +}; + +const getOvulationDay = (cycleStart: Dayjs, cycleLength: number) => { + // 默认排卵日位于周期的中间偏后,兼容短/长周期 + const daysFromStart = Math.max(12, Math.round(cycleLength / 2)); + return cycleStart.add(daysFromStart, 'day'); +}; + +export const buildMenstrualTimeline = (options?: { + records?: CycleRecord[]; + monthsBefore?: number; + monthsAfter?: number; + defaultCycleLength?: number; + defaultPeriodLength?: number; +}): MenstrualTimeline => { + const today = dayjs(); + const monthsBefore = options?.monthsBefore ?? 2; + const monthsAfter = options?.monthsAfter ?? 3; + const startMonth = today.subtract(monthsBefore, 'month').startOf('month'); + const endMonth = today.add(monthsAfter, 'month').endOf('month'); + + const records = (options?.records ?? []).sort( + (a, b) => dayjs(a.startDate).valueOf() - dayjs(b.startDate).valueOf() + ); + + const avgCycleLength = + options?.defaultCycleLength ?? calcAverageCycleLength(records, DEFAULT_CYCLE_LENGTH); + const avgPeriodLength = + options?.defaultPeriodLength ?? calcAveragePeriodLength(records, DEFAULT_PERIOD_LENGTH); + + const cycles = records.map((record) => ({ + start: dayjs(record.startDate), + confirmed: true, + periodLength: record.periodLength ?? avgPeriodLength, + cycleLength: record.cycleLength ?? avgCycleLength, + })); + + // 只有当存在历史记录时,才进行后续预测 + if (cycles.length > 0) { + const lastConfirmed = cycles[cycles.length - 1]; + let cursorStart = lastConfirmed.start; + + while (cursorStart.isBefore(endMonth)) { + cursorStart = cursorStart.add(avgCycleLength, 'day'); + cycles.push({ + start: cursorStart, + confirmed: false, + periodLength: avgPeriodLength, + cycleLength: avgCycleLength, + }); + } + } + + const dayMap: Record = {}; + + cycles.forEach((cycle) => { + const ovulationDay = getOvulationDay(cycle.start, cycle.cycleLength); + const fertileStart = ovulationDay.subtract(5, 'day'); + + for (let i = 0; i < cycle.periodLength; i += 1) { + const date = cycle.start.add(i, 'day'); + if (date.isBefore(startMonth) || date.isAfter(endMonth)) continue; + addDayInfo(dayMap, date, { + date, + status: cycle.confirmed ? 'period' : 'predicted-period', + confirmed: cycle.confirmed, + dayOfCycle: i + 1, + }); + } + + for (let i = 0; i < 5; i += 1) { + const date = fertileStart.add(i, 'day'); + if (date.isBefore(startMonth) || date.isAfter(endMonth)) continue; + addDayInfo(dayMap, date, { + date, + status: 'fertile', + confirmed: cycle.confirmed, + }); + } + + if (!ovulationDay.isBefore(startMonth) && !ovulationDay.isAfter(endMonth)) { + addDayInfo(dayMap, ovulationDay, { + date: ovulationDay, + status: 'ovulation-day', + confirmed: cycle.confirmed, + }); + } + }); + + const months: MenstrualMonth[] = []; + let monthCursor = startMonth.startOf('month'); + + while (monthCursor.isBefore(endMonth) || monthCursor.isSame(endMonth, 'month')) { + const firstDay = monthCursor.startOf('month'); + const daysInMonth = firstDay.daysInMonth(); + // 以周一为周首,符合设计稿呈现 + const firstWeekday = (firstDay.day() + 6) % 7; // 0(一) - 6(日) + const cells: MenstrualDayCell[] = []; + + for (let i = 0; i < firstWeekday; i += 1) { + cells.push({ type: 'placeholder', key: `${firstDay.format('YYYY-MM')}-p-${i}` }); + } + + for (let day = 1; day <= daysInMonth; day += 1) { + const date = firstDay.date(day); + const key = date.format('YYYY-MM-DD'); + cells.push({ + type: 'day', + key, + label: day, + date, + info: dayMap[key], + isToday: date.isSame(today, 'day'), + }); + } + + while (cells.length % 7 !== 0) { + cells.push({ + type: 'placeholder', + key: `${firstDay.format('YYYY-MM')}-t-${cells.length}`, + }); + } + + months.push({ + id: firstDay.format('YYYY-MM'), + title: firstDay.format('M月'), + subtitle: firstDay.format('YYYY年'), + cells, + }); + + monthCursor = monthCursor.add(1, 'month'); + } + + const todayKey = today.format('YYYY-MM-DD'); + + return { + months, + dayMap, + cycleLength: avgCycleLength, + periodLength: avgPeriodLength, + todayInfo: dayMap[todayKey], + }; +}; + +export const getMenstrualSummaryForDate = ( + date: Dayjs, + dayMap: Record +) => { + const key = date.format('YYYY-MM-DD'); + return dayMap[key]; +}; diff --git a/utils/userPreferences.ts b/utils/userPreferences.ts index 5f5f23f..6fa3326 100644 --- a/utils/userPreferences.ts +++ b/utils/userPreferences.ts @@ -15,10 +15,57 @@ const PREFERENCES_KEYS = { NUTRITION_REMINDER_ENABLED: 'user_preference_nutrition_reminder_enabled', MOOD_REMINDER_ENABLED: 'user_preference_mood_reminder_enabled', HRV_REMINDER_ENABLED: 'user_preference_hrv_reminder_enabled', + + // 首页身体指标卡片显示设置 + SHOW_MOOD_CARD: 'user_preference_show_mood_card', + SHOW_STEPS_CARD: 'user_preference_show_steps_card', + SHOW_STRESS_CARD: 'user_preference_show_stress_card', + SHOW_SLEEP_CARD: 'user_preference_show_sleep_card', + SHOW_FITNESS_RINGS_CARD: 'user_preference_show_fitness_rings_card', + SHOW_WATER_CARD: 'user_preference_show_water_card', + SHOW_BASAL_METABOLISM_CARD: 'user_preference_show_basal_metabolism_card', + SHOW_OXYGEN_SATURATION_CARD: 'user_preference_show_oxygen_saturation_card', + SHOW_MENSTRUAL_CYCLE_CARD: 'user_preference_show_menstrual_cycle_card', + SHOW_WEIGHT_CARD: 'user_preference_show_weight_card', + SHOW_CIRCUMFERENCE_CARD: 'user_preference_show_circumference_card', + + // 首页身体指标卡片排序设置 + STATISTICS_CARD_ORDER: 'user_preference_statistics_card_order', } as const; +// 首页身体指标卡片显示设置接口 +export interface StatisticsCardsVisibility { + showMood: boolean; + showSteps: boolean; + showStress: boolean; + showSleep: boolean; + showFitnessRings: boolean; + showWater: boolean; + showBasalMetabolism: boolean; + showOxygenSaturation: boolean; + showMenstrualCycle: boolean; + showWeight: boolean; + showCircumference: boolean; +} + +// 默认卡片顺序 +export const DEFAULT_CARD_ORDER: string[] = [ + 'mood', + 'steps', + 'stress', + 'sleep', + 'fitness', + 'water', + 'metabolism', + 'oxygen', + 'menstrual', + 'weight', + 'circumference', +]; + // 用户偏好设置接口 -export interface UserPreferences { +export interface UserPreferences extends StatisticsCardsVisibility { + cardOrder: string[]; quickWaterAmount: number; waterGoal: number; notificationEnabled: boolean; @@ -49,6 +96,22 @@ const DEFAULT_PREFERENCES: UserPreferences = { nutritionReminderEnabled: true, // 默认开启营养提醒 moodReminderEnabled: true, // 默认开启心情提醒 hrvReminderEnabled: true, // 默认开启 HRV 压力提醒 + + // 默认显示所有卡片 + showMood: true, + showSteps: true, + showStress: true, + showSleep: true, + showFitnessRings: true, + showWater: true, + showBasalMetabolism: true, + showOxygenSaturation: true, + showMenstrualCycle: true, + showWeight: true, + showCircumference: true, + + // 默认卡片顺序 + cardOrder: DEFAULT_CARD_ORDER, }; /** @@ -70,6 +133,21 @@ export const getUserPreferences = async (): Promise => { const moodReminderEnabled = await AsyncStorage.getItem(PREFERENCES_KEYS.MOOD_REMINDER_ENABLED); const hrvReminderEnabled = await AsyncStorage.getItem(PREFERENCES_KEYS.HRV_REMINDER_ENABLED); + // 获取首页卡片显示设置 + const showMood = await AsyncStorage.getItem(PREFERENCES_KEYS.SHOW_MOOD_CARD); + const showSteps = await AsyncStorage.getItem(PREFERENCES_KEYS.SHOW_STEPS_CARD); + const showStress = await AsyncStorage.getItem(PREFERENCES_KEYS.SHOW_STRESS_CARD); + const showSleep = await AsyncStorage.getItem(PREFERENCES_KEYS.SHOW_SLEEP_CARD); + const showFitnessRings = await AsyncStorage.getItem(PREFERENCES_KEYS.SHOW_FITNESS_RINGS_CARD); + const showWater = await AsyncStorage.getItem(PREFERENCES_KEYS.SHOW_WATER_CARD); + const showBasalMetabolism = await AsyncStorage.getItem(PREFERENCES_KEYS.SHOW_BASAL_METABOLISM_CARD); + const showOxygenSaturation = await AsyncStorage.getItem(PREFERENCES_KEYS.SHOW_OXYGEN_SATURATION_CARD); + const showMenstrualCycle = await AsyncStorage.getItem(PREFERENCES_KEYS.SHOW_MENSTRUAL_CYCLE_CARD); + const showWeight = await AsyncStorage.getItem(PREFERENCES_KEYS.SHOW_WEIGHT_CARD); + const showCircumference = await AsyncStorage.getItem(PREFERENCES_KEYS.SHOW_CIRCUMFERENCE_CARD); + const cardOrderStr = await AsyncStorage.getItem(PREFERENCES_KEYS.STATISTICS_CARD_ORDER); + const cardOrder = cardOrderStr ? JSON.parse(cardOrderStr) : DEFAULT_PREFERENCES.cardOrder; + return { quickWaterAmount: quickWaterAmount ? parseInt(quickWaterAmount, 10) : DEFAULT_PREFERENCES.quickWaterAmount, waterGoal: waterGoal ? parseInt(waterGoal, 10) : DEFAULT_PREFERENCES.waterGoal, @@ -84,6 +162,19 @@ export const getUserPreferences = async (): Promise => { nutritionReminderEnabled: nutritionReminderEnabled !== null ? nutritionReminderEnabled === 'true' : DEFAULT_PREFERENCES.nutritionReminderEnabled, moodReminderEnabled: moodReminderEnabled !== null ? moodReminderEnabled === 'true' : DEFAULT_PREFERENCES.moodReminderEnabled, hrvReminderEnabled: hrvReminderEnabled !== null ? hrvReminderEnabled === 'true' : DEFAULT_PREFERENCES.hrvReminderEnabled, + + showMood: showMood !== null ? showMood === 'true' : DEFAULT_PREFERENCES.showMood, + showSteps: showSteps !== null ? showSteps === 'true' : DEFAULT_PREFERENCES.showSteps, + showStress: showStress !== null ? showStress === 'true' : DEFAULT_PREFERENCES.showStress, + showSleep: showSleep !== null ? showSleep === 'true' : DEFAULT_PREFERENCES.showSleep, + showFitnessRings: showFitnessRings !== null ? showFitnessRings === 'true' : DEFAULT_PREFERENCES.showFitnessRings, + showWater: showWater !== null ? showWater === 'true' : DEFAULT_PREFERENCES.showWater, + showBasalMetabolism: showBasalMetabolism !== null ? showBasalMetabolism === 'true' : DEFAULT_PREFERENCES.showBasalMetabolism, + showOxygenSaturation: showOxygenSaturation !== null ? showOxygenSaturation === 'true' : DEFAULT_PREFERENCES.showOxygenSaturation, + showMenstrualCycle: showMenstrualCycle !== null ? showMenstrualCycle === 'true' : DEFAULT_PREFERENCES.showMenstrualCycle, + showWeight: showWeight !== null ? showWeight === 'true' : DEFAULT_PREFERENCES.showWeight, + showCircumference: showCircumference !== null ? showCircumference === 'true' : DEFAULT_PREFERENCES.showCircumference, + cardOrder, }; } catch (error) { console.error('获取用户偏好设置失败:', error); @@ -501,3 +592,92 @@ export const getHRVReminderEnabled = async (): Promise => { return DEFAULT_PREFERENCES.hrvReminderEnabled; } }; + +/** + * 获取首页卡片显示设置 + */ +export const getStatisticsCardsVisibility = async (): Promise => { + try { + const userPreferences = await getUserPreferences(); + return { + showMood: userPreferences.showMood, + showSteps: userPreferences.showSteps, + showStress: userPreferences.showStress, + showSleep: userPreferences.showSleep, + showFitnessRings: userPreferences.showFitnessRings, + showWater: userPreferences.showWater, + showBasalMetabolism: userPreferences.showBasalMetabolism, + showOxygenSaturation: userPreferences.showOxygenSaturation, + showMenstrualCycle: userPreferences.showMenstrualCycle, + showWeight: userPreferences.showWeight, + showCircumference: userPreferences.showCircumference, + }; + } catch (error) { + console.error('获取首页卡片显示设置失败:', error); + return { + showMood: DEFAULT_PREFERENCES.showMood, + showSteps: DEFAULT_PREFERENCES.showSteps, + showStress: DEFAULT_PREFERENCES.showStress, + showSleep: DEFAULT_PREFERENCES.showSleep, + showFitnessRings: DEFAULT_PREFERENCES.showFitnessRings, + showWater: DEFAULT_PREFERENCES.showWater, + showBasalMetabolism: DEFAULT_PREFERENCES.showBasalMetabolism, + showOxygenSaturation: DEFAULT_PREFERENCES.showOxygenSaturation, + showMenstrualCycle: DEFAULT_PREFERENCES.showMenstrualCycle, + showWeight: DEFAULT_PREFERENCES.showWeight, + showCircumference: DEFAULT_PREFERENCES.showCircumference, + }; + } +}; + +/** + * 获取首页卡片顺序 + */ +export const getStatisticsCardOrder = async (): Promise => { + try { + const orderStr = await AsyncStorage.getItem(PREFERENCES_KEYS.STATISTICS_CARD_ORDER); + return orderStr ? JSON.parse(orderStr) : DEFAULT_CARD_ORDER; + } catch (error) { + console.error('获取首页卡片顺序失败:', error); + return DEFAULT_CARD_ORDER; + } +}; + +/** + * 设置首页卡片顺序 + */ +export const setStatisticsCardOrder = async (order: string[]): Promise => { + try { + await AsyncStorage.setItem(PREFERENCES_KEYS.STATISTICS_CARD_ORDER, JSON.stringify(order)); + } catch (error) { + console.error('设置首页卡片顺序失败:', error); + throw error; + } +}; + +/** + * 设置首页卡片显示设置 + */ +export const setStatisticsCardVisibility = async (key: keyof StatisticsCardsVisibility, value: boolean): Promise => { + try { + let storageKey: string; + switch (key) { + case 'showMood': storageKey = PREFERENCES_KEYS.SHOW_MOOD_CARD; break; + case 'showSteps': storageKey = PREFERENCES_KEYS.SHOW_STEPS_CARD; break; + case 'showStress': storageKey = PREFERENCES_KEYS.SHOW_STRESS_CARD; break; + case 'showSleep': storageKey = PREFERENCES_KEYS.SHOW_SLEEP_CARD; break; + case 'showFitnessRings': storageKey = PREFERENCES_KEYS.SHOW_FITNESS_RINGS_CARD; break; + case 'showWater': storageKey = PREFERENCES_KEYS.SHOW_WATER_CARD; break; + case 'showBasalMetabolism': storageKey = PREFERENCES_KEYS.SHOW_BASAL_METABOLISM_CARD; break; + case 'showOxygenSaturation': storageKey = PREFERENCES_KEYS.SHOW_OXYGEN_SATURATION_CARD; break; + case 'showMenstrualCycle': storageKey = PREFERENCES_KEYS.SHOW_MENSTRUAL_CYCLE_CARD; break; + case 'showWeight': storageKey = PREFERENCES_KEYS.SHOW_WEIGHT_CARD; break; + case 'showCircumference': storageKey = PREFERENCES_KEYS.SHOW_CIRCUMFERENCE_CARD; break; + default: return; + } + await AsyncStorage.setItem(storageKey, value.toString()); + } catch (error) { + console.error(`设置首页卡片显示失败 (${key}):`, error); + throw error; + } +};