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 { 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<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();
@@ -423,6 +465,26 @@ export default function ExploreScreen() {
</View>
<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
activeOpacity={0.85}
onPress={handleOpenGallery}
@@ -476,90 +538,174 @@ export default function ExploreScreen() {
<Text style={styles.sectionTitle}>{t('statistics.sections.bodyMetrics')}</Text>
</View>
{/* 真正瀑布流布局 */}
<View style={styles.masonryContainer}>
{/* 左列 */}
<View style={styles.masonryColumn}>
{/* 心情卡片 */}
<FloatingCard style={styles.masonryCard}>
<MoodCard
moodCheckin={currentMoodCheckin}
onPress={() => pushIfAuthedElseLogin('/mood/calendar')}
isLoading={isMoodLoading}
/>
</FloatingCard>
{/* 动态布局:支持混合瀑布流和全宽卡片 */}
<View style={styles.layoutContainer}>
{(() => {
// 定义所有卡片及其显示状态
const allCardsMap: Record<string, any> = {
mood: {
visible: cardVisibility.showMood,
component: (
<MoodCard
moodCheckin={currentMoodCheckin}
onPress={() => pushIfAuthedElseLogin('/mood/calendar')}
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}>
<StepsCard
curDate={currentSelectedDate}
stepGoal={stepGoal}
style={styles.stepsCardOverride}
/>
</FloatingCard>
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);
<FloatingCard style={styles.masonryCard}>
<StressMeter
curDate={currentSelectedDate}
/>
</FloatingCard>
// 分组逻辑:将连续的瀑布流卡片聚合,全宽卡片单独作为一组
const blocks: any[] = [];
let currentMasonryBlock: any[] = [];
{/* 心率卡片 */}
{/* <FloatingCard style={styles.masonryCard} delay={2000}>
<HeartRateCard
resetToken={animToken}
style={styles.basalMetabolismCardOverride}
heartRate={heartRate}
/>
</FloatingCard> */}
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);
}
});
<FloatingCard style={styles.masonryCard}>
<SleepCard
selectedDate={currentSelectedDate}
/>
</FloatingCard>
</View>
// 结算剩余的瀑布流卡片
if (currentMasonryBlock.length > 0) {
blocks.push({ type: 'masonry', items: [...currentMasonryBlock] });
}
{/* 右列 */}
<View style={styles.masonryColumn}>
<FloatingCard style={styles.masonryCard}>
<FitnessRingsCard
selectedDate={currentSelectedDate}
resetToken={animToken}
/>
</FloatingCard>
{/* 饮水记录卡片 */}
<FloatingCard style={styles.masonryCard}>
<WaterIntakeCard
selectedDate={currentSelectedDateString}
style={styles.waterCardOverride}
/>
</FloatingCard>
return blocks.map((block, blockIndex) => {
if (block.type === 'full') {
return (
<View key={`block-${blockIndex}-${block.item.id}`}>
{block.item.component}
</View>
);
} else {
// 渲染瀑布流块
const leftColumnCards = block.items.filter((_: any, index: number) => index % 2 === 0);
const rightColumnCards = block.items.filter((_: any, index: number) => index % 2 !== 0);
{/* 基础代谢卡片 */}
<FloatingCard style={styles.masonryCard}>
<BasalMetabolismCard
selectedDate={currentSelectedDate}
style={styles.basalMetabolismCardOverride}
/>
</FloatingCard>
{/* 血氧饱和度卡片 */}
<FloatingCard style={styles.masonryCard}>
<OxygenSaturationCard
selectedDate={currentSelectedDate}
style={styles.basalMetabolismCardOverride}
/>
</FloatingCard>
</View>
return (
<View key={`block-${blockIndex}-masonry`} style={styles.masonryContainer}>
<View style={styles.masonryColumn}>
{leftColumnCards.map((card: any) => (
<FloatingCard key={card.id} style={styles.masonryCard}>
{card.component}
</FloatingCard>
))}
</View>
<View style={styles.masonryColumn}>
{rightColumnCards.map((card: any) => (
<FloatingCard key={card.id} style={styles.masonryCard}>
{card.component}
</FloatingCard>
))}
</View>
</View>
);
}
});
})()}
</View>
<WeightHistoryCard />
{/* 围度数据卡片 - 占满底部一行 */}
<CircumferenceCard style={styles.circumferenceCard} />
</ScrollView>
</View>
@@ -868,6 +1014,9 @@ const styles = StyleSheet.create({
justifyContent: 'space-between',
marginBottom: 16,
},
layoutContainer: {
flex: 1,
},
masonryContainer: {
flexDirection: 'row',
gap: 16,