feat(app): 新增生理周期记录功能与首页卡片自定义支持

- 新增生理周期追踪页面及相关算法逻辑,支持经期记录与预测
- 新增首页统计卡片自定义页面,支持VIP用户调整卡片显示状态与顺序
- 重构首页统计页面布局逻辑,支持动态渲染与混合布局
- 引入 react-native-draggable-flatlist 用于实现拖拽排序功能
- 添加相关多语言配置及用户偏好设置存储接口
This commit is contained in:
richarjiang
2025-12-16 17:25:21 +08:00
parent 5e11da34ee
commit 9b4a300380
10 changed files with 2041 additions and 79 deletions

View File

@@ -1,6 +1,7 @@
import { BasalMetabolismCard } from '@/components/BasalMetabolismCard'; import { BasalMetabolismCard } from '@/components/BasalMetabolismCard';
import { DateSelector } from '@/components/DateSelector'; import { DateSelector } from '@/components/DateSelector';
import { FitnessRingsCard } from '@/components/FitnessRingsCard'; import { FitnessRingsCard } from '@/components/FitnessRingsCard';
import { MenstrualCycleCard } from '@/components/MenstrualCycleCard';
import { MoodCard } from '@/components/MoodCard'; import { MoodCard } from '@/components/MoodCard';
import { NutritionRadarCard } from '@/components/NutritionRadarCard'; import { NutritionRadarCard } from '@/components/NutritionRadarCard';
import CircumferenceCard from '@/components/statistic/CircumferenceCard'; import CircumferenceCard from '@/components/statistic/CircumferenceCard';
@@ -22,13 +23,14 @@ import { fetchTodayWaterStats } from '@/store/waterSlice';
import { getMonthDaysZh, getTodayIndexInMonth } from '@/utils/date'; import { getMonthDaysZh, getTodayIndexInMonth } from '@/utils/date';
import { fetchHealthDataForDate } from '@/utils/health'; import { fetchHealthDataForDate } from '@/utils/health';
import { logger } from '@/utils/logger'; import { logger } from '@/utils/logger';
import { DEFAULT_CARD_ORDER, getStatisticsCardOrder, getStatisticsCardsVisibility, StatisticsCardsVisibility } from '@/utils/userPreferences';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect'; import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
import { LinearGradient } from 'expo-linear-gradient'; import { LinearGradient } from 'expo-linear-gradient';
import { useRouter } from 'expo-router'; import { useFocusEffect, useRouter } from 'expo-router';
import { debounce } from 'lodash'; 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 { useTranslation } from 'react-i18next';
import { import {
AppState, AppState,
@@ -90,9 +92,49 @@ export default function ExploreScreen() {
router.push('/gallery'); router.push('/gallery');
}, [ensureLoggedIn, router]); }, [ensureLoggedIn, router]);
const handleOpenCustomization = React.useCallback(() => {
router.push('/statistics-customization');
}, [router]);
// 用于触发动画重置的 token当日期或数据变化时更新 // 用于触发动画重置的 token当日期或数据变化时更新
const [animToken, setAnimToken] = useState(0); const [animToken, setAnimToken] = useState(0);
// 首页卡片显示设置
const [cardVisibility, setCardVisibility] = useState<StatisticsCardsVisibility>({
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<string[]>(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(); const dispatch = useAppDispatch();
@@ -423,6 +465,26 @@ export default function ExploreScreen() {
</View> </View>
<View style={styles.headerActions}> <View style={styles.headerActions}>
<TouchableOpacity
activeOpacity={0.85}
onPress={handleOpenCustomization}
>
{isLiquidGlassAvailable() ? (
<GlassView
style={styles.liquidGlassButton}
glassEffectStyle="regular"
tintColor="rgba(255, 255, 255, 0.3)"
isInteractive={true}
>
<Ionicons name="options-outline" size={20} color="#0F172A" />
</GlassView>
) : (
<View style={[styles.liquidGlassButton, styles.liquidGlassFallback]}>
<Ionicons name="options-outline" size={20} color="#0F172A" />
</View>
)}
</TouchableOpacity>
<TouchableOpacity <TouchableOpacity
activeOpacity={0.85} activeOpacity={0.85}
onPress={handleOpenGallery} onPress={handleOpenGallery}
@@ -476,90 +538,174 @@ export default function ExploreScreen() {
<Text style={styles.sectionTitle}>{t('statistics.sections.bodyMetrics')}</Text> <Text style={styles.sectionTitle}>{t('statistics.sections.bodyMetrics')}</Text>
</View> </View>
{/* 真正瀑布流布局 */} {/* 动态布局:支持混合瀑布流和全宽卡片 */}
<View style={styles.masonryContainer}> <View style={styles.layoutContainer}>
{/* 左列 */} {(() => {
<View style={styles.masonryColumn}> // 定义所有卡片及其显示状态
{/* 心情卡片 */} const allCardsMap: Record<string, any> = {
<FloatingCard style={styles.masonryCard}> mood: {
<MoodCard visible: cardVisibility.showMood,
moodCheckin={currentMoodCheckin} component: (
onPress={() => pushIfAuthedElseLogin('/mood/calendar')} <MoodCard
isLoading={isMoodLoading} moodCheckin={currentMoodCheckin}
/> onPress={() => pushIfAuthedElseLogin('/mood/calendar')}
</FloatingCard> isLoading={isMoodLoading}
/>
)
},
steps: {
visible: cardVisibility.showSteps,
component: (
<StepsCard
curDate={currentSelectedDate}
stepGoal={stepGoal}
style={styles.stepsCardOverride}
/>
)
},
stress: {
visible: cardVisibility.showStress,
component: (
<StressMeter
curDate={currentSelectedDate}
/>
)
},
sleep: {
visible: cardVisibility.showSleep,
component: (
<SleepCard
selectedDate={currentSelectedDate}
/>
)
},
fitness: {
visible: cardVisibility.showFitnessRings,
component: (
<FitnessRingsCard
selectedDate={currentSelectedDate}
resetToken={animToken}
/>
)
},
water: {
visible: cardVisibility.showWater,
component: (
<WaterIntakeCard
selectedDate={currentSelectedDateString}
style={styles.waterCardOverride}
/>
)
},
metabolism: {
visible: cardVisibility.showBasalMetabolism,
component: (
<BasalMetabolismCard
selectedDate={currentSelectedDate}
style={styles.basalMetabolismCardOverride}
/>
)
},
oxygen: {
visible: cardVisibility.showOxygenSaturation,
component: (
<OxygenSaturationCard
selectedDate={currentSelectedDate}
style={styles.basalMetabolismCardOverride}
/>
)
},
menstrual: {
visible: cardVisibility.showMenstrualCycle,
component: (
<MenstrualCycleCard
onPress={() => pushIfAuthedElseLogin('/menstrual-cycle')}
/>
)
},
weight: {
visible: cardVisibility.showWeight,
isFullWidth: true,
component: (
<WeightHistoryCard />
)
},
circumference: {
visible: cardVisibility.showCircumference,
isFullWidth: true,
component: (
<CircumferenceCard style={{ marginBottom: 0, marginTop: 16 }} />
)
}
};
<FloatingCard style={styles.masonryCard}> const allKeys = Object.keys(allCardsMap);
<StepsCard const sortedKeys = Array.from(new Set([...cardOrder, ...allKeys]))
curDate={currentSelectedDate} .filter(key => allCardsMap[key]);
stepGoal={stepGoal}
style={styles.stepsCardOverride}
/>
</FloatingCard>
const visibleCards = sortedKeys
.map(key => ({ id: key, ...allCardsMap[key] }))
.filter(card => card.visible);
<FloatingCard style={styles.masonryCard}> // 分组逻辑:将连续的瀑布流卡片聚合,全宽卡片单独作为一组
<StressMeter const blocks: any[] = [];
curDate={currentSelectedDate} let currentMasonryBlock: any[] = [];
/>
</FloatingCard>
{/* 心率卡片 */} visibleCards.forEach(card => {
{/* <FloatingCard style={styles.masonryCard} delay={2000}> if (card.isFullWidth) {
<HeartRateCard // 如果有未处理的瀑布流卡片,先结算
resetToken={animToken} if (currentMasonryBlock.length > 0) {
style={styles.basalMetabolismCardOverride} blocks.push({ type: 'masonry', items: [...currentMasonryBlock] });
heartRate={heartRate} currentMasonryBlock = [];
/> }
</FloatingCard> */} // 添加全宽卡片
blocks.push({ type: 'full', item: card });
} else {
// 添加到当前瀑布流组
currentMasonryBlock.push(card);
}
});
<FloatingCard style={styles.masonryCard}> // 结算剩余的瀑布流卡片
<SleepCard if (currentMasonryBlock.length > 0) {
selectedDate={currentSelectedDate} blocks.push({ type: 'masonry', items: [...currentMasonryBlock] });
/> }
</FloatingCard>
</View>
{/* 右列 */} return blocks.map((block, blockIndex) => {
<View style={styles.masonryColumn}> if (block.type === 'full') {
<FloatingCard style={styles.masonryCard}> return (
<FitnessRingsCard <View key={`block-${blockIndex}-${block.item.id}`}>
selectedDate={currentSelectedDate} {block.item.component}
resetToken={animToken} </View>
/> );
</FloatingCard> } else {
{/* 饮水记录卡片 */} // 渲染瀑布流块
<FloatingCard style={styles.masonryCard}> const leftColumnCards = block.items.filter((_: any, index: number) => index % 2 === 0);
<WaterIntakeCard const rightColumnCards = block.items.filter((_: any, index: number) => index % 2 !== 0);
selectedDate={currentSelectedDateString}
style={styles.waterCardOverride}
/>
</FloatingCard>
return (
{/* 基础代谢卡片 */} <View key={`block-${blockIndex}-masonry`} style={styles.masonryContainer}>
<FloatingCard style={styles.masonryCard}> <View style={styles.masonryColumn}>
<BasalMetabolismCard {leftColumnCards.map((card: any) => (
selectedDate={currentSelectedDate} <FloatingCard key={card.id} style={styles.masonryCard}>
style={styles.basalMetabolismCardOverride} {card.component}
/> </FloatingCard>
</FloatingCard> ))}
</View>
{/* 血氧饱和度卡片 */} <View style={styles.masonryColumn}>
<FloatingCard style={styles.masonryCard}> {rightColumnCards.map((card: any) => (
<OxygenSaturationCard <FloatingCard key={card.id} style={styles.masonryCard}>
selectedDate={currentSelectedDate} {card.component}
style={styles.basalMetabolismCardOverride} </FloatingCard>
/> ))}
</FloatingCard> </View>
</View>
);
</View> }
});
})()}
</View> </View>
<WeightHistoryCard />
{/* 围度数据卡片 - 占满底部一行 */}
<CircumferenceCard style={styles.circumferenceCard} />
</ScrollView> </ScrollView>
</View> </View>
@@ -868,6 +1014,9 @@ const styles = StyleSheet.create({
justifyContent: 'space-between', justifyContent: 'space-between',
marginBottom: 16, marginBottom: 16,
}, },
layoutContainer: {
flex: 1,
},
masonryContainer: { masonryContainer: {
flexDirection: 'row', flexDirection: 'row',
gap: 16, gap: 16,

799
app/menstrual-cycle.tsx Normal file
View File

@@ -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<MenstrualDayStatus, { bg: string; text: string }> = {
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 = <T,>(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<MenstrualDayCell, { type: 'day' }>;
isSelected: boolean;
onPress: () => void;
}) => {
const status = cell.info?.status;
const colors = status ? STATUS_COLORS[status] : undefined;
return (
<TouchableOpacity
activeOpacity={0.8}
style={styles.dayCell}
onPress={onPress}
>
<View
style={[
styles.dayCircle,
colors && { backgroundColor: colors.bg },
isSelected && styles.dayCircleSelected,
cell.isToday && styles.todayOutline,
]}
>
<Text
style={[
styles.dayLabel,
colors && { color: colors.text },
!colors && styles.dayLabelDefault,
]}
>
{cell.label}
</Text>
</View>
{cell.isToday && <Text style={styles.todayText}></Text>}
</TouchableOpacity>
);
};
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 (
<View style={styles.monthCard}>
<View style={styles.monthHeader}>
<Text style={styles.monthTitle}>{month.title}</Text>
<Text style={styles.monthSubtitle}>{month.subtitle}</Text>
</View>
<View style={styles.weekRow}>
{WEEK_LABELS.map((label) => (
<Text key={label} style={styles.weekLabel}>
{label}
</Text>
))}
</View>
<View style={styles.monthGrid}>
{weeks.map((week, weekIndex) => {
const selectedIndex = week.findIndex(
(c) => c.type === 'day' && c.date.format('YYYY-MM-DD') === selectedDateKey
);
return (
<React.Fragment key={weekIndex}>
<View style={styles.daysRow}>
{week.map((cell) => {
if (cell.type === 'placeholder') {
return <View key={cell.key} style={styles.dayCell} />;
}
const dateKey = cell.date.format('YYYY-MM-DD');
return (
<DayCell
key={cell.key}
cell={cell}
isSelected={selectedDateKey === dateKey}
onPress={() => onSelect(dateKey)}
/>
);
})}
</View>
{selectedIndex !== -1 && (
<View style={styles.inlineTipContainer}>
{renderTip(selectedIndex)}
</View>
)}
</React.Fragment>
);
})}
</View>
</View>
);
};
export default function MenstrualCycleScreen() {
const router = useRouter();
const [records, setRecords] = useState<CycleRecord[]>([]);
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<TabKey>('cycle');
const [selectedDateKey, setSelectedDateKey] = useState(
dayjs().format('YYYY-MM-DD')
);
const listRef = useRef<FlatList>(null);
const offsetRef = useRef(0);
const prependDeltaRef = useRef(0);
const loadingPrevRef = useRef(false);
const selectedInfo = timeline.dayMap[selectedDateKey];
const selectedDate = dayjs(selectedDateKey);
const handleMarkStart = () => {
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 = () => (
<View style={styles.legendRow}>
{[
{ label: '经期', key: 'period' as const },
{ label: '预测经期', key: 'predicted-period' as const },
{ label: '排卵期', key: 'fertile' as const },
{ label: '排卵日', key: 'ovulation-day' as const },
].map((item) => (
<View key={item.key} style={styles.legendItem}>
<View
style={[
styles.legendDot,
{ backgroundColor: STATUS_COLORS[item.key].bg },
item.key === 'ovulation-day' && styles.legendDotRing,
]}
/>
<Text style={styles.legendLabel}>{item.label}</Text>
</View>
))}
</View>
);
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 = (
<View style={styles.inlineTipCard}>
<View style={[styles.inlineTipPointer, { left: pointerLeft }]} />
<View style={styles.inlineTipRow}>
<View style={styles.inlineTipDate}>
<Ionicons name="calendar-outline" size={16} color="#111827" />
<Text style={styles.inlineTipDateText}>{selectedDate.format('M月D日')}</Text>
</View>
{!isFuture && (!selectedInfo || !selectedInfo.confirmed) && (
<TouchableOpacity style={styles.inlinePrimaryBtn} onPress={handleMarkStart}>
<Ionicons name="add" size={14} color="#fff" />
<Text style={styles.inlinePrimaryText}></Text>
</TouchableOpacity>
)}
{!isFuture && selectedInfo?.confirmed && selectedInfo.status === 'period' && (
<TouchableOpacity style={styles.inlineSecondaryBtn} onPress={handleCancelMark}>
<Text style={styles.inlineSecondaryText}></Text>
</TouchableOpacity>
)}
</View>
</View>
);
return base;
};
const renderCycleTab = () => (
<View style={styles.tabContent}>
{renderLegend()}
<FlatList
ref={listRef}
data={listData}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<MonthBlock
month={item.month}
selectedDateKey={selectedDateKey}
onSelect={(key) => setSelectedDateKey(key)}
renderTip={renderInlineTip}
/>
)}
showsVerticalScrollIndicator={false}
initialNumToRender={3}
windowSize={5}
maxToRenderPerBatch={4}
removeClippedSubviews
contentContainerStyle={styles.listContent}
viewabilityConfig={viewabilityConfig}
onViewableItemsChanged={onViewableItemsChanged}
onScroll={(e) => {
offsetRef.current = e.nativeEvent.contentOffset.y;
}}
scrollEventThrottle={16}
/>
</View>
);
const renderAnalysisTab = () => (
<View style={styles.tabContent}>
<View style={styles.analysisCard}>
<Text style={styles.analysisTitle}></Text>
<Text style={styles.analysisBody}>
6
</Text>
</View>
</View>
);
return (
<View style={styles.container}>
<Stack.Screen options={{ headerShown: false }} />
<LinearGradient
colors={['#fdf1ff', '#f3f4ff', '#f7f8ff']}
style={StyleSheet.absoluteFill}
start={{ x: 0, y: 0 }}
end={{ x: 0, y: 1 }}
/>
<View style={styles.header}>
<TouchableOpacity onPress={() => router.back()} style={styles.headerIcon}>
<Ionicons name="chevron-back" size={22} color="#0f172a" />
</TouchableOpacity>
<Text style={styles.headerTitle}></Text>
<TouchableOpacity style={styles.headerIcon}>
<Ionicons name="settings-outline" size={20} color="#0f172a" />
</TouchableOpacity>
</View>
<View style={styles.tabSwitcher}>
{([
{ key: 'cycle', label: '生理周期' },
{ key: 'analysis', label: '分析' },
] as { key: TabKey; label: string }[]).map((tab) => {
const active = activeTab === tab.key;
return (
<TouchableOpacity
key={tab.key}
style={[styles.tabPill, active && styles.tabPillActive]}
onPress={() => setActiveTab(tab.key)}
activeOpacity={0.9}
>
<Text style={[styles.tabLabel, active && styles.tabLabelActive]}>
{tab.label}
</Text>
</TouchableOpacity>
);
})}
</View>
{activeTab === 'cycle' ? renderCycleTab() : renderAnalysisTab()}
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
paddingTop: 52,
paddingHorizontal: 16,
backgroundColor: 'transparent',
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 12,
},
headerIcon: {
width: 36,
height: 36,
borderRadius: 18,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(255,255,255,0.9)',
},
headerTitle: {
fontSize: 18,
fontWeight: '800',
color: '#0f172a',
fontFamily: 'AliBold',
},
tabSwitcher: {
flexDirection: 'row',
backgroundColor: 'rgba(255,255,255,0.7)',
borderRadius: 18,
padding: 4,
marginBottom: 16,
},
tabPill: {
flex: 1,
alignItems: 'center',
paddingVertical: 10,
borderRadius: 14,
},
tabPillActive: {
backgroundColor: '#fff',
shadowColor: '#000',
shadowOpacity: 0.08,
shadowOffset: { width: 0, height: 8 },
shadowRadius: 10,
elevation: 3,
},
tabLabel: {
color: '#4b5563',
fontWeight: '600',
fontFamily: 'AliRegular',
},
tabLabelActive: {
color: '#0f172a',
fontFamily: 'AliBold',
},
tabContent: {
flex: 1,
},
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',
},
});

View File

@@ -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<CardItem[]>([]);
const CARD_CONFIG: Record<string, { icon: keyof typeof Ionicons.glyphMap; titleKey: string; visibilityKey: keyof StatisticsCardsVisibility }> = {
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<CardItem>) => {
const handleDrag = () => {
if (!isVip) {
showToast({
type: 'info',
message: t('statisticsCustomization.vipRequired'),
});
openMembershipModal();
return;
}
drag();
};
return (
<ScaleDecorator>
<TouchableOpacity
onLongPress={handleDrag}
disabled={isActive}
activeOpacity={1}
style={[
styles.rowItem,
isActive && styles.activeItem,
]}
>
<View style={styles.itemContent}>
<View style={styles.leftContent}>
<View style={styles.dragHandle}>
<Ionicons name="reorder-three-outline" size={24} color="#C7C7CC" />
</View>
<View style={styles.iconContainer}>
<Ionicons name={item.icon} size={24} color={'#9370DB'} />
</View>
<Text style={styles.itemTitle}>{item.title}</Text>
</View>
<Switch
value={item.visible}
onValueChange={(v) => handleToggle(item, v)}
trackColor={{ false: '#E5E5E5', true: '#9370DB' }}
thumbColor="#FFFFFF"
style={styles.switch}
/>
</View>
</TouchableOpacity>
</ScaleDecorator>
);
}, [handleToggle, isVip, t, showToast, openMembershipModal]);
if (isLoading) {
return (
<View style={styles.container}>
<StatusBar barStyle="dark-content" backgroundColor="transparent" translucent />
<LinearGradient
colors={[palette.purple[100], '#F5F5F5']}
start={{ x: 1, y: 0 }}
end={{ x: 0.3, y: 0.4 }}
style={styles.gradientBackground}
/>
<View style={styles.loadingContainer}>
<Text style={styles.loadingText}>{t('notificationSettings.loading')}</Text>
</View>
</View>
);
}
return (
<GestureHandlerRootView style={styles.container}>
<StatusBar barStyle="dark-content" backgroundColor="transparent" translucent />
<LinearGradient
colors={[palette.purple[100], '#F5F5F5']}
start={{ x: 1, y: 0 }}
end={{ x: 0.3, y: 0.4 }}
style={styles.gradientBackground}
/>
<HeaderBar
title={t('statisticsCustomization.title')}
onBack={() => router.back()}
/>
<DraggableFlatList
data={data}
onDragEnd={handleDragEnd}
keyExtractor={(item) => item.key}
renderItem={renderItem}
contentContainerStyle={[
styles.scrollContent,
{ paddingTop: safeAreaTop }
]}
showsVerticalScrollIndicator={false}
ListHeaderComponent={() => (
<>
<View style={styles.headerSection}>
<Text style={styles.subtitle}>{t('notificationSettings.sections.description')}</Text>
<View style={styles.descriptionCard}>
<View style={styles.hintRow}>
<Ionicons name="information-circle-outline" size={20} color="#9370DB" />
<Text style={styles.descriptionText}>
{t('statisticsCustomization.description.text')}
</Text>
</View>
</View>
</View>
<View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}>{t('statisticsCustomization.sectionTitle')}</Text>
</View>
</>
)}
/>
</GestureHandlerRootView>
);
}
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 }],
},
});

View File

@@ -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 = () => (
<View style={styles.iconWrapper}>
<LinearGradient
colors={['#f572a7', '#f0a4ff', '#6f6ced']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.iconGradient}
>
<View style={styles.iconInner} />
</LinearGradient>
</View>
);
export const MenstrualCycleCard: React.FC<Props> = ({ 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 (
<TouchableOpacity activeOpacity={0.92} onPress={onPress} style={styles.wrapper}>
<View style={styles.headerRow}>
<RingIcon />
<Text style={styles.title}></Text>
<View style={styles.badgeOuter}>
<View style={styles.badgeInner} />
</View>
</View>
<View style={styles.content}>
<Text style={styles.stateText}>{summary.state}</Text>
<Text style={styles.dayRow}>
{summary.number !== undefined ? (
<>
<Text style={styles.dayNumber}>{summary.number}</Text> {summary.dayText}
</>
) : (
summary.dayText
)}
</Text>
</View>
</TouchableOpacity>
);
};
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',
},
});

View File

@@ -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 = { export const editProfile = {
title: 'Edit Profile', title: 'Edit Profile',
fields: { fields: {

View File

@@ -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 = { export const editProfile = {
title: '编辑资料', title: '编辑资料',
fields: { fields: {

15
package-lock.json generated
View File

@@ -60,6 +60,7 @@
"react-native": "0.81.5", "react-native": "0.81.5",
"react-native-chart-kit": "^6.12.0", "react-native-chart-kit": "^6.12.0",
"react-native-device-info": "^14.0.4", "react-native-device-info": "^14.0.4",
"react-native-draggable-flatlist": "^4.0.3",
"react-native-gesture-handler": "~2.28.0", "react-native-gesture-handler": "~2.28.0",
"react-native-image-viewing": "^0.2.2", "react-native-image-viewing": "^0.2.2",
"react-native-markdown-display": "^7.0.2", "react-native-markdown-display": "^7.0.2",
@@ -12210,6 +12211,20 @@
"react-native": "*" "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": { "node_modules/react-native-fit-image": {
"version": "1.5.5", "version": "1.5.5",
"resolved": "https://mirrors.tencent.com/npm/react-native-fit-image/-/react-native-fit-image-1.5.5.tgz", "resolved": "https://mirrors.tencent.com/npm/react-native-fit-image/-/react-native-fit-image-1.5.5.tgz",

View File

@@ -61,6 +61,7 @@
"react-native": "0.81.5", "react-native": "0.81.5",
"react-native-chart-kit": "^6.12.0", "react-native-chart-kit": "^6.12.0",
"react-native-device-info": "^14.0.4", "react-native-device-info": "^14.0.4",
"react-native-draggable-flatlist": "^4.0.3",
"react-native-gesture-handler": "~2.28.0", "react-native-gesture-handler": "~2.28.0",
"react-native-image-viewing": "^0.2.2", "react-native-image-viewing": "^0.2.2",
"react-native-markdown-display": "^7.0.2", "react-native-markdown-display": "^7.0.2",

260
utils/menstrualCycle.ts Normal file
View File

@@ -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<string, MenstrualDayInfo>;
cycleLength: number;
periodLength: number;
todayInfo?: MenstrualDayInfo;
};
const STATUS_PRIORITY: Record<MenstrualDayStatus, number> = {
'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<string, MenstrualDayInfo>,
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<string, MenstrualDayInfo> = {};
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<string, MenstrualDayInfo>
) => {
const key = date.format('YYYY-MM-DD');
return dayMap[key];
};

View File

@@ -15,10 +15,57 @@ const PREFERENCES_KEYS = {
NUTRITION_REMINDER_ENABLED: 'user_preference_nutrition_reminder_enabled', NUTRITION_REMINDER_ENABLED: 'user_preference_nutrition_reminder_enabled',
MOOD_REMINDER_ENABLED: 'user_preference_mood_reminder_enabled', MOOD_REMINDER_ENABLED: 'user_preference_mood_reminder_enabled',
HRV_REMINDER_ENABLED: 'user_preference_hrv_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; } 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; quickWaterAmount: number;
waterGoal: number; waterGoal: number;
notificationEnabled: boolean; notificationEnabled: boolean;
@@ -49,6 +96,22 @@ const DEFAULT_PREFERENCES: UserPreferences = {
nutritionReminderEnabled: true, // 默认开启营养提醒 nutritionReminderEnabled: true, // 默认开启营养提醒
moodReminderEnabled: true, // 默认开启心情提醒 moodReminderEnabled: true, // 默认开启心情提醒
hrvReminderEnabled: true, // 默认开启 HRV 压力提醒 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<UserPreferences> => {
const moodReminderEnabled = await AsyncStorage.getItem(PREFERENCES_KEYS.MOOD_REMINDER_ENABLED); const moodReminderEnabled = await AsyncStorage.getItem(PREFERENCES_KEYS.MOOD_REMINDER_ENABLED);
const hrvReminderEnabled = await AsyncStorage.getItem(PREFERENCES_KEYS.HRV_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 { return {
quickWaterAmount: quickWaterAmount ? parseInt(quickWaterAmount, 10) : DEFAULT_PREFERENCES.quickWaterAmount, quickWaterAmount: quickWaterAmount ? parseInt(quickWaterAmount, 10) : DEFAULT_PREFERENCES.quickWaterAmount,
waterGoal: waterGoal ? parseInt(waterGoal, 10) : DEFAULT_PREFERENCES.waterGoal, waterGoal: waterGoal ? parseInt(waterGoal, 10) : DEFAULT_PREFERENCES.waterGoal,
@@ -84,6 +162,19 @@ export const getUserPreferences = async (): Promise<UserPreferences> => {
nutritionReminderEnabled: nutritionReminderEnabled !== null ? nutritionReminderEnabled === 'true' : DEFAULT_PREFERENCES.nutritionReminderEnabled, nutritionReminderEnabled: nutritionReminderEnabled !== null ? nutritionReminderEnabled === 'true' : DEFAULT_PREFERENCES.nutritionReminderEnabled,
moodReminderEnabled: moodReminderEnabled !== null ? moodReminderEnabled === 'true' : DEFAULT_PREFERENCES.moodReminderEnabled, moodReminderEnabled: moodReminderEnabled !== null ? moodReminderEnabled === 'true' : DEFAULT_PREFERENCES.moodReminderEnabled,
hrvReminderEnabled: hrvReminderEnabled !== null ? hrvReminderEnabled === 'true' : DEFAULT_PREFERENCES.hrvReminderEnabled, 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) { } catch (error) {
console.error('获取用户偏好设置失败:', error); console.error('获取用户偏好设置失败:', error);
@@ -501,3 +592,92 @@ export const getHRVReminderEnabled = async (): Promise<boolean> => {
return DEFAULT_PREFERENCES.hrvReminderEnabled; return DEFAULT_PREFERENCES.hrvReminderEnabled;
} }
}; };
/**
* 获取首页卡片显示设置
*/
export const getStatisticsCardsVisibility = async (): Promise<StatisticsCardsVisibility> => {
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<string[]> => {
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<void> => {
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<void> => {
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;
}
};