feat(app): 新增生理周期记录功能与首页卡片自定义支持
- 新增生理周期追踪页面及相关算法逻辑,支持经期记录与预测 - 新增首页统计卡片自定义页面,支持VIP用户调整卡片显示状态与顺序 - 重构首页统计页面布局逻辑,支持动态渲染与混合布局 - 引入 react-native-draggable-flatlist 用于实现拖拽排序功能 - 添加相关多语言配置及用户偏好设置存储接口
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user