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,
|
||||
|
||||
799
app/menstrual-cycle.tsx
Normal file
799
app/menstrual-cycle.tsx
Normal 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',
|
||||
},
|
||||
});
|
||||
357
app/statistics-customization.tsx
Normal file
357
app/statistics-customization.tsx
Normal 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 }],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user