Compare commits
2 Commits
409f125db1
...
9b4a300380
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b4a300380 | ||
|
|
5e11da34ee |
2
app.json
2
app.json
@@ -2,7 +2,7 @@
|
||||
"expo": {
|
||||
"name": "Out Live",
|
||||
"slug": "digital-pilates",
|
||||
"version": "1.1.4",
|
||||
"version": "1.1.5",
|
||||
"orientation": "portrait",
|
||||
"scheme": "digitalpilates",
|
||||
"userInterfaceStyle": "light",
|
||||
|
||||
@@ -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}>
|
||||
{/* 动态布局:支持混合瀑布流和全宽卡片 */}
|
||||
<View style={styles.layoutContainer}>
|
||||
{(() => {
|
||||
// 定义所有卡片及其显示状态
|
||||
const allCardsMap: Record<string, any> = {
|
||||
mood: {
|
||||
visible: cardVisibility.showMood,
|
||||
component: (
|
||||
<MoodCard
|
||||
moodCheckin={currentMoodCheckin}
|
||||
onPress={() => pushIfAuthedElseLogin('/mood/calendar')}
|
||||
isLoading={isMoodLoading}
|
||||
/>
|
||||
</FloatingCard>
|
||||
|
||||
<FloatingCard style={styles.masonryCard}>
|
||||
)
|
||||
},
|
||||
steps: {
|
||||
visible: cardVisibility.showSteps,
|
||||
component: (
|
||||
<StepsCard
|
||||
curDate={currentSelectedDate}
|
||||
stepGoal={stepGoal}
|
||||
style={styles.stepsCardOverride}
|
||||
/>
|
||||
</FloatingCard>
|
||||
|
||||
|
||||
<FloatingCard style={styles.masonryCard}>
|
||||
)
|
||||
},
|
||||
stress: {
|
||||
visible: cardVisibility.showStress,
|
||||
component: (
|
||||
<StressMeter
|
||||
curDate={currentSelectedDate}
|
||||
/>
|
||||
</FloatingCard>
|
||||
|
||||
{/* 心率卡片 */}
|
||||
{/* <FloatingCard style={styles.masonryCard} delay={2000}>
|
||||
<HeartRateCard
|
||||
resetToken={animToken}
|
||||
style={styles.basalMetabolismCardOverride}
|
||||
heartRate={heartRate}
|
||||
/>
|
||||
</FloatingCard> */}
|
||||
|
||||
<FloatingCard style={styles.masonryCard}>
|
||||
)
|
||||
},
|
||||
sleep: {
|
||||
visible: cardVisibility.showSleep,
|
||||
component: (
|
||||
<SleepCard
|
||||
selectedDate={currentSelectedDate}
|
||||
/>
|
||||
</FloatingCard>
|
||||
</View>
|
||||
|
||||
{/* 右列 */}
|
||||
<View style={styles.masonryColumn}>
|
||||
<FloatingCard style={styles.masonryCard}>
|
||||
)
|
||||
},
|
||||
fitness: {
|
||||
visible: cardVisibility.showFitnessRings,
|
||||
component: (
|
||||
<FitnessRingsCard
|
||||
selectedDate={currentSelectedDate}
|
||||
resetToken={animToken}
|
||||
/>
|
||||
</FloatingCard>
|
||||
{/* 饮水记录卡片 */}
|
||||
<FloatingCard style={styles.masonryCard}>
|
||||
)
|
||||
},
|
||||
water: {
|
||||
visible: cardVisibility.showWater,
|
||||
component: (
|
||||
<WaterIntakeCard
|
||||
selectedDate={currentSelectedDateString}
|
||||
style={styles.waterCardOverride}
|
||||
/>
|
||||
</FloatingCard>
|
||||
|
||||
|
||||
{/* 基础代谢卡片 */}
|
||||
<FloatingCard style={styles.masonryCard}>
|
||||
)
|
||||
},
|
||||
metabolism: {
|
||||
visible: cardVisibility.showBasalMetabolism,
|
||||
component: (
|
||||
<BasalMetabolismCard
|
||||
selectedDate={currentSelectedDate}
|
||||
style={styles.basalMetabolismCardOverride}
|
||||
/>
|
||||
</FloatingCard>
|
||||
|
||||
{/* 血氧饱和度卡片 */}
|
||||
<FloatingCard style={styles.masonryCard}>
|
||||
)
|
||||
},
|
||||
oxygen: {
|
||||
visible: cardVisibility.showOxygenSaturation,
|
||||
component: (
|
||||
<OxygenSaturationCard
|
||||
selectedDate={currentSelectedDate}
|
||||
style={styles.basalMetabolismCardOverride}
|
||||
/>
|
||||
</FloatingCard>
|
||||
|
||||
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
},
|
||||
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 }} />
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
const allKeys = Object.keys(allCardsMap);
|
||||
const sortedKeys = Array.from(new Set([...cardOrder, ...allKeys]))
|
||||
.filter(key => allCardsMap[key]);
|
||||
|
||||
const visibleCards = sortedKeys
|
||||
.map(key => ({ id: key, ...allCardsMap[key] }))
|
||||
.filter(card => card.visible);
|
||||
|
||||
// 分组逻辑:将连续的瀑布流卡片聚合,全宽卡片单独作为一组
|
||||
const blocks: any[] = [];
|
||||
let currentMasonryBlock: any[] = [];
|
||||
|
||||
visibleCards.forEach(card => {
|
||||
if (card.isFullWidth) {
|
||||
// 如果有未处理的瀑布流卡片,先结算
|
||||
if (currentMasonryBlock.length > 0) {
|
||||
blocks.push({ type: 'masonry', items: [...currentMasonryBlock] });
|
||||
currentMasonryBlock = [];
|
||||
}
|
||||
// 添加全宽卡片
|
||||
blocks.push({ type: 'full', item: card });
|
||||
} else {
|
||||
// 添加到当前瀑布流组
|
||||
currentMasonryBlock.push(card);
|
||||
}
|
||||
});
|
||||
|
||||
// 结算剩余的瀑布流卡片
|
||||
if (currentMasonryBlock.length > 0) {
|
||||
blocks.push({ type: 'masonry', items: [...currentMasonryBlock] });
|
||||
}
|
||||
|
||||
return blocks.map((block, blockIndex) => {
|
||||
if (block.type === 'full') {
|
||||
return (
|
||||
<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);
|
||||
|
||||
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>
|
||||
|
||||
{/* 围度数据卡片 - 占满底部一行 */}
|
||||
<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',
|
||||
},
|
||||
});
|
||||
@@ -8,10 +8,12 @@ import {
|
||||
getMoodReminderEnabled,
|
||||
getNotificationEnabled,
|
||||
getNutritionReminderEnabled,
|
||||
getHRVReminderEnabled,
|
||||
setMedicationReminderEnabled,
|
||||
setMoodReminderEnabled,
|
||||
setNotificationEnabled,
|
||||
setNutritionReminderEnabled
|
||||
setNutritionReminderEnabled,
|
||||
setHRVReminderEnabled
|
||||
} from '@/utils/userPreferences';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
@@ -29,21 +31,24 @@ export default function NotificationSettingsScreen() {
|
||||
const [medicationReminderEnabled, setMedicationReminderEnabledState] = useState(false);
|
||||
const [nutritionReminderEnabled, setNutritionReminderEnabledState] = useState(false);
|
||||
const [moodReminderEnabled, setMoodReminderEnabledState] = useState(false);
|
||||
const [hrvReminderEnabled, setHrvReminderEnabledState] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
// 加载通知设置
|
||||
const loadNotificationSettings = useCallback(async () => {
|
||||
try {
|
||||
const [notification, medicationReminder, nutritionReminder, moodReminder] = await Promise.all([
|
||||
const [notification, medicationReminder, nutritionReminder, moodReminder, hrvReminder] = await Promise.all([
|
||||
getNotificationEnabled(),
|
||||
getMedicationReminderEnabled(),
|
||||
getNutritionReminderEnabled(),
|
||||
getMoodReminderEnabled(),
|
||||
getHRVReminderEnabled(),
|
||||
]);
|
||||
setNotificationEnabledState(notification);
|
||||
setMedicationReminderEnabledState(medicationReminder);
|
||||
setNutritionReminderEnabledState(nutritionReminder);
|
||||
setMoodReminderEnabledState(moodReminder);
|
||||
setHrvReminderEnabledState(hrvReminder);
|
||||
} catch (error) {
|
||||
console.error('Failed to load notification settings:', error);
|
||||
} finally {
|
||||
@@ -103,6 +108,8 @@ export default function NotificationSettingsScreen() {
|
||||
setNutritionReminderEnabledState(false);
|
||||
await setMoodReminderEnabled(false);
|
||||
setMoodReminderEnabledState(false);
|
||||
await setHRVReminderEnabled(false);
|
||||
setHrvReminderEnabledState(false);
|
||||
} catch (error) {
|
||||
console.error('Failed to disable push notifications:', error);
|
||||
Alert.alert(t('notificationSettings.alerts.error.title'), t('notificationSettings.alerts.error.saveFailed'));
|
||||
@@ -173,6 +180,26 @@ export default function NotificationSettingsScreen() {
|
||||
}
|
||||
};
|
||||
|
||||
// 处理 HRV 通知提醒开关变化
|
||||
const handleHrvReminderToggle = async (value: boolean) => {
|
||||
try {
|
||||
await setHRVReminderEnabled(value);
|
||||
setHrvReminderEnabledState(value);
|
||||
|
||||
if (value) {
|
||||
await sendNotification({
|
||||
title: t('notificationSettings.alerts.hrvReminderEnabled.title'),
|
||||
body: t('notificationSettings.alerts.hrvReminderEnabled.body'),
|
||||
sound: true,
|
||||
priority: 'high',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to set HRV reminder:', error);
|
||||
Alert.alert(t('notificationSettings.alerts.error.title'), t('notificationSettings.alerts.error.hrvReminderFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
// 渲染设置项
|
||||
const renderSettingItem = (
|
||||
icon: keyof typeof Ionicons.glyphMap,
|
||||
@@ -297,6 +324,16 @@ export default function NotificationSettingsScreen() {
|
||||
true
|
||||
)}
|
||||
|
||||
{renderSettingItem(
|
||||
'pulse-outline',
|
||||
t('notificationSettings.items.hrvReminder.title'),
|
||||
t('notificationSettings.items.hrvReminder.description'),
|
||||
hrvReminderEnabled,
|
||||
handleHrvReminderToggle,
|
||||
!notificationEnabled,
|
||||
true
|
||||
)}
|
||||
|
||||
{renderSettingItem(
|
||||
'happy-outline',
|
||||
t('notificationSettings.items.moodReminder.title'),
|
||||
|
||||
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 }],
|
||||
},
|
||||
});
|
||||
157
components/MenstrualCycleCard.tsx
Normal file
157
components/MenstrualCycleCard.tsx
Normal 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',
|
||||
},
|
||||
});
|
||||
@@ -1,20 +1,25 @@
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { Ionicons, MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import dayjs from 'dayjs';
|
||||
import 'dayjs/locale/en';
|
||||
import 'dayjs/locale/zh-cn';
|
||||
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Dimensions,
|
||||
Modal,
|
||||
Platform,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
Share,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
TouchableWithoutFeedback,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import ViewShot from 'react-native-view-shot';
|
||||
|
||||
import { useI18n } from '@/hooks/useI18n';
|
||||
import {
|
||||
@@ -26,6 +31,7 @@ import {
|
||||
WorkoutActivityType,
|
||||
WorkoutData,
|
||||
} from '@/utils/health';
|
||||
import { Toast } from '@/utils/toast.utils';
|
||||
|
||||
export interface IntensityBadge {
|
||||
label: string;
|
||||
@@ -65,6 +71,8 @@ export function WorkoutDetailModal({
|
||||
const [isMounted, setIsMounted] = useState(visible);
|
||||
const [shouldRenderChart, setShouldRenderChart] = useState(visible);
|
||||
const [showIntensityInfo, setShowIntensityInfo] = useState(false);
|
||||
const [sharing, setSharing] = useState(false);
|
||||
const shareContentRef = useRef<ViewShot | null>(null);
|
||||
|
||||
const locale = useMemo(() => (i18n.language?.startsWith('en') ? 'en' : 'zh-cn'), [i18n.language]);
|
||||
|
||||
@@ -138,6 +146,49 @@ export function WorkoutDetailModal({
|
||||
}
|
||||
};
|
||||
|
||||
const handleShare = useCallback(async () => {
|
||||
if (!shareContentRef.current || !workout || sharing) {
|
||||
return;
|
||||
}
|
||||
setSharing(true);
|
||||
try {
|
||||
Toast.show({
|
||||
type: 'info',
|
||||
text1: t('workoutDetail.share.generating', '正在生成分享卡片…'),
|
||||
});
|
||||
const uri = await shareContentRef.current.capture?.({
|
||||
format: 'png',
|
||||
quality: 0.95,
|
||||
snapshotContentContainer: true,
|
||||
});
|
||||
if (!uri) {
|
||||
throw new Error('share-capture-failed');
|
||||
}
|
||||
const shareTitle = t('workoutDetail.share.title', { defaultValue: activityName || t('workoutDetail.title', '锻炼详情') });
|
||||
const caloriesLabel = metrics?.calories != null
|
||||
? `${metrics.calories} ${t('workoutDetail.metrics.caloriesUnit')}`
|
||||
: '--';
|
||||
const shareMessage = t('workoutDetail.share.message', {
|
||||
activity: activityName || t('workoutDetail.share.activityFallback', '锻炼'),
|
||||
duration: metrics?.durationLabel ?? '--',
|
||||
calories: caloriesLabel,
|
||||
date: dateInfo.subtitle,
|
||||
defaultValue: `我的${activityName || '锻炼'}:${dateInfo.subtitle},持续${metrics?.durationLabel ?? '--'},消耗${caloriesLabel}`,
|
||||
});
|
||||
|
||||
await Share.share({
|
||||
title: shareTitle,
|
||||
message: shareMessage,
|
||||
url: Platform.OS === 'ios' ? uri : `file://${uri}`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn('workout-detail-share-failed', error);
|
||||
Toast.error(t('workoutDetail.share.failed', '分享失败,请稍后再试'));
|
||||
} finally {
|
||||
setSharing(false);
|
||||
}
|
||||
}, [activityName, dateInfo.subtitle, metrics?.calories, metrics?.durationLabel, sharing, t, workout]);
|
||||
|
||||
if (!isMounted) {
|
||||
return null;
|
||||
}
|
||||
@@ -176,7 +227,48 @@ export function WorkoutDetailModal({
|
||||
<Text style={styles.headerSubtitle}>{dateInfo.subtitle}</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.headerSpacer} />
|
||||
{isLiquidGlassAvailable() ? (
|
||||
<Pressable
|
||||
onPress={handleShare}
|
||||
disabled={loading || sharing || !workout}
|
||||
style={({ pressed }) => [
|
||||
styles.headerIconButton,
|
||||
styles.glassButtonWrapper,
|
||||
pressed && styles.headerIconPressed,
|
||||
(loading || sharing || !workout) && styles.headerIconDisabled,
|
||||
]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t('workoutDetail.share.accessibilityLabel', '分享锻炼记录')}
|
||||
>
|
||||
<GlassView glassEffectStyle="regular" tintColor="rgba(255,255,255,0.9)" isInteractive style={styles.glassButton}>
|
||||
<View style={styles.glassButtonInner}>
|
||||
<Ionicons name="share-outline" size={20} color="#1E2148" />
|
||||
</View>
|
||||
</GlassView>
|
||||
</Pressable>
|
||||
) : (
|
||||
<Pressable
|
||||
onPress={handleShare}
|
||||
disabled={loading || sharing || !workout}
|
||||
style={({ pressed }) => [
|
||||
styles.headerIconButton,
|
||||
styles.headerIconFallback,
|
||||
pressed && styles.headerIconPressed,
|
||||
(loading || sharing || !workout) && styles.headerIconDisabled,
|
||||
]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t('workoutDetail.share.accessibilityLabel', '分享锻炼记录')}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={['#EEF2FF', '#E0E7FF']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={styles.glassButtonInner}
|
||||
>
|
||||
<Ionicons name="share-outline" size={20} color="#1E2148" />
|
||||
</LinearGradient>
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={styles.heroIconWrapper}>
|
||||
@@ -390,6 +482,238 @@ export function WorkoutDetailModal({
|
||||
<View style={styles.homeIndicatorSpacer} />
|
||||
</ScrollView>
|
||||
</View>
|
||||
|
||||
{/* Hidden share capture renders full content height for complete screenshots */}
|
||||
<ViewShot
|
||||
ref={shareContentRef}
|
||||
style={[styles.sheetContainer, styles.shareCaptureContainer]}
|
||||
collapsable={false}
|
||||
options={{ format: 'png', quality: 0.95, snapshotContentContainer: true }}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={['#FFFFFF', '#F3F5FF']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 0, y: 1 }}
|
||||
style={styles.gradientBackground}
|
||||
/>
|
||||
<View style={styles.handleWrapper}>
|
||||
<View style={styles.handle} />
|
||||
</View>
|
||||
<View style={styles.headerRow}>
|
||||
<View style={styles.headerIconButton} />
|
||||
<View style={styles.headerTitleWrapper}>
|
||||
<Text style={styles.headerTitle}>{dateInfo.title}</Text>
|
||||
<Text style={styles.headerSubtitle}>{dateInfo.subtitle}</Text>
|
||||
</View>
|
||||
<View style={styles.headerIconButton} />
|
||||
</View>
|
||||
<View style={styles.heroIconWrapper}>
|
||||
<MaterialCommunityIcons name="run" size={160} color="#E8EAFE" />
|
||||
</View>
|
||||
<ScrollView
|
||||
bounces={false}
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerStyle={styles.contentContainer}
|
||||
>
|
||||
<View style={[styles.summaryCard, loading ? styles.summaryCardLoading : null]}>
|
||||
<View style={styles.summaryHeader}>
|
||||
<Text style={styles.activityName}>{activityName}</Text>
|
||||
{intensityBadge ? (
|
||||
<View
|
||||
style={[
|
||||
styles.intensityPill,
|
||||
{ backgroundColor: intensityBadge.background },
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.intensityPillText, { color: intensityBadge.color }]}>
|
||||
{intensityBadge.label}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
<Text style={styles.summarySubtitle}>
|
||||
{dayjs(workout?.startDate || workout?.endDate)
|
||||
.locale(locale)
|
||||
.format(locale === 'en' ? 'dddd, MMM D, YYYY HH:mm' : 'YYYY年M月D日 dddd HH:mm')}
|
||||
</Text>
|
||||
|
||||
{loading ? (
|
||||
<View style={styles.loadingBlock}>
|
||||
<ActivityIndicator color="#5C55FF" />
|
||||
<Text style={styles.loadingLabel}>{t('workoutDetail.loading')}</Text>
|
||||
</View>
|
||||
) : metrics ? (
|
||||
<>
|
||||
<View style={styles.metricsRow}>
|
||||
<View style={styles.metricItem}>
|
||||
<Text style={styles.metricTitle}>{t('workoutDetail.metrics.duration')}</Text>
|
||||
<Text style={styles.metricValue}>{metrics.durationLabel}</Text>
|
||||
</View>
|
||||
<View style={styles.metricItem}>
|
||||
<Text style={styles.metricTitle}>{t('workoutDetail.metrics.calories')}</Text>
|
||||
<Text style={styles.metricValue}>
|
||||
{metrics.calories != null ? `${metrics.calories} ${t('workoutDetail.metrics.caloriesUnit')}` : '--'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.metricsRow}>
|
||||
<View style={styles.metricItem}>
|
||||
<View style={styles.metricTitleRow}>
|
||||
<Text style={styles.metricTitle}>{t('workoutDetail.metrics.intensity')}</Text>
|
||||
<View style={styles.metricInfoButton}>
|
||||
<MaterialCommunityIcons name="information-outline" size={16} color="#7780AA" />
|
||||
</View>
|
||||
</View>
|
||||
<Text style={styles.metricValue}>
|
||||
{formatMetsValue(metrics.mets)}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.metricItem}>
|
||||
<Text style={styles.metricTitle}>{t('workoutDetail.metrics.averageHeartRate')}</Text>
|
||||
<Text style={styles.metricValue}>
|
||||
{metrics.averageHeartRate != null ? `${metrics.averageHeartRate} ${t('workoutDetail.metrics.heartRateUnit')}` : '--'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
{monthOccurrenceText ? (
|
||||
<Text style={styles.monthOccurrenceText}>{monthOccurrenceText}</Text>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<View style={styles.errorBlock}>
|
||||
<Text style={styles.errorText}>
|
||||
{errorMessage || t('workoutDetail.errors.loadFailed')}
|
||||
</Text>
|
||||
{onRetry ? (
|
||||
<View style={[styles.retryButton, styles.retryButtonDisabled]}>
|
||||
<Text style={styles.retryButtonText}>{t('workoutDetail.retry')}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={[styles.section, loading ? styles.sectionHeartRateLoading : null]}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<Text style={styles.sectionTitle}>{t('workoutDetail.sections.heartRateRange')}</Text>
|
||||
</View>
|
||||
|
||||
{loading ? (
|
||||
<View style={styles.sectionLoading}>
|
||||
<ActivityIndicator color="#5C55FF" />
|
||||
</View>
|
||||
) : metrics ? (
|
||||
<>
|
||||
<View style={styles.heartRateSummaryRow}>
|
||||
<View style={styles.heartRateStat}>
|
||||
<Text style={styles.statLabel}>{t('workoutDetail.sections.averageHeartRate')}</Text>
|
||||
<Text style={styles.statValue}>
|
||||
{metrics.averageHeartRate != null ? `${metrics.averageHeartRate} ${t('workoutDetail.sections.heartRateUnit')}` : '--'}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.heartRateStat}>
|
||||
<Text style={styles.statLabel}>{t('workoutDetail.sections.maximumHeartRate')}</Text>
|
||||
<Text style={styles.statValue}>
|
||||
{metrics.maximumHeartRate != null ? `${metrics.maximumHeartRate} ${t('workoutDetail.sections.heartRateUnit')}` : '--'}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.heartRateStat}>
|
||||
<Text style={styles.statLabel}>{t('workoutDetail.sections.minimumHeartRate')}</Text>
|
||||
<Text style={styles.statValue}>
|
||||
{metrics.minimumHeartRate != null ? `${metrics.minimumHeartRate} ${t('workoutDetail.sections.heartRateUnit')}` : '--'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{heartRateChart ? (
|
||||
LineChart ? (
|
||||
<View style={styles.chartWrapper}>
|
||||
{shouldRenderChart ? (
|
||||
/* @ts-ignore - react-native-chart-kit types are outdated */
|
||||
<LineChart
|
||||
data={{
|
||||
labels: heartRateChart.labels,
|
||||
datasets: [
|
||||
{
|
||||
data: heartRateChart.data,
|
||||
color: () => '#5C55FF',
|
||||
strokeWidth: 2,
|
||||
},
|
||||
],
|
||||
}}
|
||||
width={chartWidth}
|
||||
height={220}
|
||||
fromZero={false}
|
||||
yAxisSuffix={t('workoutDetail.sections.heartRateUnit')}
|
||||
withInnerLines={false}
|
||||
bezier
|
||||
paddingRight={48}
|
||||
chartConfig={{
|
||||
backgroundColor: '#FFFFFF',
|
||||
backgroundGradientFrom: '#FFFFFF',
|
||||
backgroundGradientTo: '#FFFFFF',
|
||||
decimalPlaces: 0,
|
||||
color: (opacity = 1) => `rgba(92, 85, 255, ${opacity})`,
|
||||
labelColor: (opacity = 1) => `rgba(98, 105, 138, ${opacity})`,
|
||||
propsForDots: {
|
||||
r: '3',
|
||||
strokeWidth: '2',
|
||||
stroke: '#FFFFFF',
|
||||
},
|
||||
fillShadowGradientFromOpacity: 0.1,
|
||||
fillShadowGradientToOpacity: 0.02,
|
||||
}}
|
||||
style={styles.chartStyle}
|
||||
/>
|
||||
) : (
|
||||
<View style={[styles.chartLoading, { width: chartWidth }]}>
|
||||
<ActivityIndicator color="#5C55FF" />
|
||||
<Text style={styles.chartLoadingText}>{t('workoutDetail.loading')}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.chartEmpty}>
|
||||
<MaterialCommunityIcons name="chart-line-variant" size={32} color="#C5CBE2" />
|
||||
<Text style={styles.chartEmptyText}>{t('workoutDetail.chart.unavailable')}</Text>
|
||||
</View>
|
||||
)
|
||||
) : (
|
||||
<View style={styles.chartEmpty}>
|
||||
<MaterialCommunityIcons name="heart-off-outline" size={32} color="#C5CBE2" />
|
||||
<Text style={styles.chartEmptyText}>{t('workoutDetail.chart.noData')}</Text>
|
||||
</View>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<View style={styles.sectionError}>
|
||||
<Text style={styles.errorTextSmall}>
|
||||
{errorMessage || t('workoutDetail.errors.noHeartRateData')}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={[styles.section, loading ? styles.sectionZonesLoading : null]}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<Text style={styles.sectionTitle}>{t('workoutDetail.sections.heartRateZones')}</Text>
|
||||
</View>
|
||||
|
||||
{loading ? (
|
||||
<View style={styles.sectionLoading}>
|
||||
<ActivityIndicator color="#5C55FF" />
|
||||
</View>
|
||||
) : metrics ? (
|
||||
metrics.heartRateZones.map((zone) => renderHeartRateZone(zone, t))
|
||||
) : (
|
||||
<Text style={styles.errorTextSmall}>{t('workoutDetail.errors.noZoneStats')}</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={styles.homeIndicatorSpacer} />
|
||||
</ScrollView>
|
||||
</ViewShot>
|
||||
|
||||
{showIntensityInfo ? (
|
||||
<Modal
|
||||
transparent
|
||||
@@ -634,6 +958,39 @@ const styles = StyleSheet.create({
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
headerIconFallback: {
|
||||
overflow: 'hidden',
|
||||
},
|
||||
headerIconPressed: {
|
||||
opacity: 0.75,
|
||||
},
|
||||
headerIconDisabled: {
|
||||
opacity: 0.4,
|
||||
},
|
||||
glassButtonWrapper: {
|
||||
overflow: 'hidden',
|
||||
},
|
||||
glassButton: {
|
||||
borderRadius: 20,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
glassButtonInner: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
borderRadius: 20,
|
||||
},
|
||||
shareCaptureContainer: {
|
||||
position: 'absolute',
|
||||
top: -9999,
|
||||
left: 0,
|
||||
right: 0,
|
||||
opacity: 0,
|
||||
zIndex: -1,
|
||||
},
|
||||
headerTitleWrapper: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
@@ -761,6 +1118,9 @@ const styles = StyleSheet.create({
|
||||
backgroundColor: '#5C55FF',
|
||||
borderRadius: 16,
|
||||
},
|
||||
retryButtonDisabled: {
|
||||
opacity: 0.4,
|
||||
},
|
||||
retryButtonText: {
|
||||
color: '#FFFFFF',
|
||||
fontWeight: '600',
|
||||
@@ -970,8 +1330,4 @@ const styles = StyleSheet.create({
|
||||
intensityHigh: {
|
||||
color: '#FF6767',
|
||||
},
|
||||
headerSpacer: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -112,6 +112,28 @@ export const personal = {
|
||||
},
|
||||
};
|
||||
|
||||
export const statisticsCustomization = {
|
||||
title: 'Home Content Settings',
|
||||
sectionTitle: 'Body Metrics Cards',
|
||||
description: {
|
||||
text: '• Customize the body metrics modules displayed on the home page\n• Hidden modules will not be shown on the home page, but data will be retained',
|
||||
},
|
||||
items: {
|
||||
mood: 'Mood',
|
||||
steps: 'Steps',
|
||||
stress: 'Stress',
|
||||
sleep: 'Sleep',
|
||||
fitnessRings: 'Fitness Rings',
|
||||
water: 'Water Intake',
|
||||
basalMetabolism: 'Basal Metabolism',
|
||||
oxygenSaturation: 'Oxygen Saturation',
|
||||
menstrualCycle: 'Menstrual Cycle',
|
||||
weight: 'Weight',
|
||||
circumference: 'Circumference',
|
||||
},
|
||||
vipRequired: 'VIP membership required to customize home layout',
|
||||
};
|
||||
|
||||
export const editProfile = {
|
||||
title: 'Edit Profile',
|
||||
fields: {
|
||||
@@ -394,6 +416,10 @@ export const notificationSettings = {
|
||||
title: 'Nutrition Record Reminder',
|
||||
description: 'Receive nutrition record reminders at meal times',
|
||||
},
|
||||
hrvReminder: {
|
||||
title: 'HRV Stress Alert',
|
||||
description: 'Get guidance when elevated stress is detected from HRV',
|
||||
},
|
||||
moodReminder: {
|
||||
title: 'Mood Record Reminder',
|
||||
description: 'Receive mood record reminders in the evening',
|
||||
@@ -415,6 +441,7 @@ export const notificationSettings = {
|
||||
saveFailed: 'Failed to save settings',
|
||||
medicationReminderFailed: 'Failed to set medication reminder',
|
||||
nutritionReminderFailed: 'Failed to set nutrition reminder',
|
||||
hrvReminderFailed: 'Failed to set HRV reminder',
|
||||
moodReminderFailed: 'Failed to set mood reminder',
|
||||
},
|
||||
notificationsEnabled: {
|
||||
@@ -429,6 +456,10 @@ export const notificationSettings = {
|
||||
title: 'Nutrition Reminder Enabled',
|
||||
body: 'You will receive nutrition record reminders at meal times',
|
||||
},
|
||||
hrvReminderEnabled: {
|
||||
title: 'HRV Reminder Enabled',
|
||||
body: 'You will get tips when elevated stress is detected from HRV',
|
||||
},
|
||||
moodReminderEnabled: {
|
||||
title: 'Mood Reminder Enabled',
|
||||
body: 'You will receive mood record reminders in the evening',
|
||||
|
||||
@@ -112,6 +112,28 @@ export const personal = {
|
||||
},
|
||||
};
|
||||
|
||||
export const statisticsCustomization = {
|
||||
title: '首页内容设置',
|
||||
sectionTitle: '身体指标卡片',
|
||||
description: {
|
||||
text: '• 自定义首页展示的身体指标模块\n• 关闭的模块将不会在首页显示,但数据仍会保留',
|
||||
},
|
||||
items: {
|
||||
mood: '心情',
|
||||
steps: '步数',
|
||||
stress: '压力',
|
||||
sleep: '睡眠',
|
||||
fitnessRings: '健身圆环',
|
||||
water: '饮水',
|
||||
basalMetabolism: '基础代谢',
|
||||
oxygenSaturation: '血氧',
|
||||
menstrualCycle: '经期',
|
||||
weight: '体重',
|
||||
circumference: '围度',
|
||||
},
|
||||
vipRequired: '需要开通 VIP 会员才能自定义首页布局',
|
||||
};
|
||||
|
||||
export const editProfile = {
|
||||
title: '编辑资料',
|
||||
fields: {
|
||||
@@ -398,6 +420,10 @@ export const notificationSettings = {
|
||||
title: '营养记录提醒',
|
||||
description: '在用餐时间接收营养记录提醒',
|
||||
},
|
||||
hrvReminder: {
|
||||
title: 'HRV 压力提醒',
|
||||
description: '监测到压力偏高时发送健康建议',
|
||||
},
|
||||
moodReminder: {
|
||||
title: '心情记录提醒',
|
||||
description: '在晚间接收心情记录提醒',
|
||||
@@ -419,6 +445,7 @@ export const notificationSettings = {
|
||||
saveFailed: '保存设置失败',
|
||||
medicationReminderFailed: '设置药品提醒失败',
|
||||
nutritionReminderFailed: '设置营养提醒失败',
|
||||
hrvReminderFailed: '设置 HRV 提醒失败',
|
||||
moodReminderFailed: '设置心情提醒失败',
|
||||
},
|
||||
notificationsEnabled: {
|
||||
@@ -433,6 +460,10 @@ export const notificationSettings = {
|
||||
title: '营养提醒已开启',
|
||||
body: '您将在用餐时间收到营养记录提醒',
|
||||
},
|
||||
hrvReminderEnabled: {
|
||||
title: 'HRV 提醒已开启',
|
||||
body: '检测到压力升高时将收到健康建议推送',
|
||||
},
|
||||
moodReminderEnabled: {
|
||||
title: '心情提醒已开启',
|
||||
body: '您将在晚间收到心情记录提醒',
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.1.4</string>
|
||||
<string>1.1.5</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
|
||||
218
ios/Podfile.lock
218
ios/Podfile.lock
@@ -2745,52 +2745,52 @@ EXTERNAL SOURCES:
|
||||
:path: "../node_modules/react-native/ReactCommon/yoga"
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
EASClient: de38c20c1dd9af7ff435afdc8b2c9b7c20d46767
|
||||
EXApplication: a9d1c46d473d36f61302a9a81db2379441f3f094
|
||||
EXConstants: e6e50cdfcb4524f40121d1fdcff24e97b7dcd2fd
|
||||
EXImageLoader: e501c001bc40b8326605e82e6e80363c80fe06b5
|
||||
EASClient: 68127f1248d2b25fdc82dbbfb17be95d1c4700be
|
||||
EXApplication: 296622817d459f46b6c5fe8691f4aac44d2b79e7
|
||||
EXConstants: fd688cef4e401dcf798a021cfb5d87c890c30ba3
|
||||
EXImageLoader: 189e3476581efe3ad4d1d3fb4735b7179eb26f05
|
||||
EXJSONUtils: 1d3e4590438c3ee593684186007028a14b3686cd
|
||||
EXManifests: eaf327418721c4a7482576c579d215516d36319c
|
||||
EXNotifications: 5edf06b6b41a52d8b5b3eccf0e951dc8a880a50b
|
||||
Expo: a429ae95cbae859785ece75d42ff768d6176099f
|
||||
ExpoAppleAuthentication: 414e4316f8e25a2afbc3943cf725579c910f24b8
|
||||
ExpoAsset: ee515c16290e521de1870dcdee66d78173fbc533
|
||||
ExpoBackgroundTask: e0efb5999d30593cbc0a2c443db70cc9310ec51c
|
||||
ExpoBlur: b5b7a26572b3c33a11f0b2aa2f95c17c4c393b76
|
||||
ExpoCamera: d1894aad960492f4881c1f83967167963365d937
|
||||
ExpoClipboard: 99109306a2d9ed2fbd16f6b856e6267b2afa8472
|
||||
ExpoDocumentPicker: dbe4eeeb771891209fee044a6b0bafbd1fdcc4fa
|
||||
ExpoFileSystem: 73a9f3f2e0affc61eba5b9326153f64870438af1
|
||||
ExpoFont: b881d43057dceb7b31ff767b24f612609e80f60f
|
||||
ExpoGlassEffect: 3d147d753d3bfe1a5d6b7920560e206e3e98c19e
|
||||
ExpoHaptics: b48d913e7e5f23816c6f130e525c9a6501b160b5
|
||||
ExpoHead: 4ad178d6b19c2bf786b0d1889a870ba3187c18ef
|
||||
ExpoImage: 6eb842cd07817402640545c41884dd7f5fbfbca5
|
||||
ExpoImagePicker: bd0a5c81d7734548f6908a480609257e85d19ea8
|
||||
ExpoKeepAwake: 3f5e3ac53627849174f3603271df8e08f174ed4a
|
||||
ExpoLinearGradient: f9e7182e5253d53b2de4134b69d70bbfc2d50588
|
||||
ExpoLinking: 50a65cd7beb6051ffc82f84ffb33961dd5e55a5b
|
||||
ExpoLocalization: 6c6f0f89ad2822001ab0bc2eb6d4d980c77f080c
|
||||
ExpoMediaLibrary: 648cee3f5dcba13410ec9cc8ac9a426e89a61a31
|
||||
ExpoModulesCore: bf3cd6f1f564c47031bcb1ee61b6aed1e0909eed
|
||||
ExpoQuickActions: 62b9db8a20618be1cc19efa3b562ac963c803d58
|
||||
ExpoSplashScreen: f46795cd52cdad65d30e54043f04c86401c4f85d
|
||||
ExpoSQLite: 36b8723223f322148606473507da446b1912500c
|
||||
ExpoSymbols: ef7b8ac77ac2d496b1bc3f0f7daf5e19c3a9933a
|
||||
ExpoSystemUI: 9441d46a8efbf9224d1b2e6b18042452ffd0ed79
|
||||
ExpoUI: 821b058da921ea4aa6172b36d080991ea6fb2fae
|
||||
ExpoWebBrowser: 35223474c19e261c0661ecc7caa513d054244ab5
|
||||
EXManifests: 26e15640538c3d5ef028077ebcaf004b744d4932
|
||||
EXNotifications: a62e1f8e3edd258dc3b155d3caa49f32920f1c6c
|
||||
Expo: 7af24402df45b9384900104e88a11896ffc48161
|
||||
ExpoAppleAuthentication: bc9de6e9ff3340604213ab9031d4c4f7f802623e
|
||||
ExpoAsset: d839c8eae8124470332408427327e8f88beb2dfd
|
||||
ExpoBackgroundTask: c498ce99a10f125d8370a5b2f4405e2583a3c896
|
||||
ExpoBlur: 2dd8f64aa31f5d405652c21d3deb2d2588b1852f
|
||||
ExpoCamera: 2a87c210f8955350ea5c70f1d539520b2fc5d940
|
||||
ExpoClipboard: af650d14765f19c60ce2a1eaf9dfe6445eff7365
|
||||
ExpoDocumentPicker: 2200eefc2817f19315fa18f0147e0b80ece86926
|
||||
ExpoFileSystem: 77157a101e03150a4ea4f854b4dd44883c93ae0a
|
||||
ExpoFont: cf9d90ec1d3b97c4f513211905724c8171f82961
|
||||
ExpoGlassEffect: 265fa3d75b46bc58262e4dfa513135fa9dfe4aac
|
||||
ExpoHaptics: 807476b0c39e9d82b7270349d6487928ce32df84
|
||||
ExpoHead: fc0185d5c2a51ea599aff223aba5d61782301044
|
||||
ExpoImage: 9c3428921c536ab29e5c6721d001ad5c1f469566
|
||||
ExpoImagePicker: d251aab45a1b1857e4156fed88511b278b4eee1c
|
||||
ExpoKeepAwake: 1a2e820692e933c94a565ec3fbbe38ac31658ffe
|
||||
ExpoLinearGradient: a464898cb95153125e3b81894fd479bcb1c7dd27
|
||||
ExpoLinking: 77455aa013e9b6a3601de03ecfab09858ee1b031
|
||||
ExpoLocalization: b852a5d8ec14c5349c1593eca87896b5b3ebfcca
|
||||
ExpoMediaLibrary: 641a6952299b395159ccd459bd8f5f6764bf55fe
|
||||
ExpoModulesCore: bdc95c6daa1639e235a16350134152a0b28e5c72
|
||||
ExpoQuickActions: 31a70aa6a606128de4416a4830e09cfabfe6667f
|
||||
ExpoSplashScreen: 268b2f128dc04284c21010540a6c4dd9f95003e3
|
||||
ExpoSQLite: b312b02c8b77ab55951396e6cd13992f8db9215f
|
||||
ExpoSymbols: 1ae04ce686de719b9720453b988d8bc5bf776c68
|
||||
ExpoSystemUI: 2761aa6875849af83286364811d46e8ed8ea64c7
|
||||
ExpoUI: b99a1d1ef5352a60bebf4f4fd3a50d2f896ae804
|
||||
ExpoWebBrowser: b973e1351fdcf5fec0c400997b1851f5a8219ec3
|
||||
EXStructuredHeaders: c951e77f2d936f88637421e9588c976da5827368
|
||||
EXTaskManager: 53f87ed11659341c3f3f02c0041498ef293f5684
|
||||
EXUpdates: 04853b77f4a405c5b85039b9a2ccf1c71438028c
|
||||
EXUpdatesInterface: 1436757deb0d574b84bba063bd024c315e0ec08b
|
||||
EXTaskManager: cbbb80cbccea6487ccca0631809fbba2ed3e5271
|
||||
EXUpdates: 9042dc213f17593a02d59ef7dd9d297edf621936
|
||||
EXUpdatesInterface: 5adf50cb41e079c861da6d9b4b954c3db9a50734
|
||||
FBLazyVector: e95a291ad2dadb88e42b06e0c5fb8262de53ec12
|
||||
hermes-engine: 9f4dfe93326146a1c99eb535b1cb0b857a3cd172
|
||||
libavif: 84bbb62fb232c3018d6f1bab79beea87e35de7b7
|
||||
libdav1d: 23581a4d8ec811ff171ed5e2e05cd27bad64c39f
|
||||
libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8
|
||||
lottie-ios: a881093fab623c467d3bce374367755c272bdd59
|
||||
lottie-react-native: 97a11537edc72d0763edab0c83e8cc8a0b9d8484
|
||||
lottie-react-native: cbe3d931a7c24f7891a8e8032c2bb9b2373c4b9c
|
||||
PurchasesHybridCommon: 71c94158ff8985657d37d5f3be05602881227619
|
||||
RCTDeprecation: 943572d4be82d480a48f4884f670135ae30bf990
|
||||
RCTRequired: 8f3cfc90cc25cf6e420ddb3e7caaaabc57df6043
|
||||
@@ -2798,83 +2798,83 @@ SPEC CHECKSUMS:
|
||||
ReachabilitySwift: 32793e867593cfc1177f5d16491e3a197d2fccda
|
||||
React: 914f8695f9bf38e6418228c2ffb70021e559f92f
|
||||
React-callinvoker: 1c0808402aee0c6d4a0d8e7220ce6547af9fba71
|
||||
React-Core: 4ae98f9e8135b8ddbd7c98730afb6fdae883db90
|
||||
React-Core-prebuilt: 8f4cca589c14e8cf8fc6db4587ef1c2056b5c151
|
||||
React-CoreModules: e878a90bb19b8f3851818af997dbae3b3b0a27ac
|
||||
React-cxxreact: 28af9844f6dc87be1385ab521fbfb3746f19563c
|
||||
React-Core: c61410ef0ca6055e204a963992e363227e0fd1c5
|
||||
React-Core-prebuilt: 02f0ad625ddd47463c009c2d0c5dd35c0d982599
|
||||
React-CoreModules: 1f6d1744b5f9f2ec684a4bb5ced25370f87e5382
|
||||
React-cxxreact: 3af79478e8187b63ffc22b794cd42d3fc1f1f2da
|
||||
React-debug: 6328c2228e268846161f10082e80dc69eac2e90a
|
||||
React-defaultsnativemodule: afc9d809ec75780f39464a6949c07987fbea488c
|
||||
React-domnativemodule: 91a233260411d41f27f67aa1358b7f9f0bfd101d
|
||||
React-Fabric: 21f349b5e93f305a3c38c885902683a9c79cf983
|
||||
React-FabricComponents: 47ac634cc9ecc64b30a9997192f510eebe4177e4
|
||||
React-FabricImage: 21873acd6d4a51a0b97c133141051c7acb11cc86
|
||||
React-featureflags: 653f469f0c3c9dc271d610373e3b6e66a9fd847d
|
||||
React-featureflagsnativemodule: c91a8a3880e0f4838286402241ead47db43aed28
|
||||
React-graphics: b4bdb0f635b8048c652a5d2b73eb8b1ddd950f24
|
||||
React-hermes: fcfad3b917400f49026f3232561e039c9d1c34bf
|
||||
React-idlecallbacksnativemodule: 8cb83207e39f8179ac1d344b6177c6ab3ccebcdc
|
||||
React-ImageManager: 396128004783fc510e629124dce682d38d1088e7
|
||||
React-jserrorhandler: b58b788d788cdbf8bda7db74a88ebfcffc8a0795
|
||||
React-jsi: d2c3f8555175371c02da6dfe7ed1b64b55a9d6c0
|
||||
React-jsiexecutor: ba537434eb45ee018b590ed7d29ee233fddb8669
|
||||
React-jsinspector: f21b6654baf96cb9f71748844a32468a5f73ad51
|
||||
React-jsinspectorcdp: 3f8be4830694c3c1c39442e50f8db877966d43f0
|
||||
React-jsinspectornetwork: 70e41469565712ad60e11d9c8b8f999b9f7f61eb
|
||||
React-jsinspectortracing: eccf9bfa4ec7f130d514f215cfb2222dc3c0e270
|
||||
React-jsitooling: b376a695f5a507627f7934748533b24eed1751ca
|
||||
React-jsitracing: 5c8c3273dda2d95191cc0612fb5e71c4d9018d2a
|
||||
React-logger: c3e2f8a2e284341205f61eef3d4677ab5a309dfd
|
||||
React-Mapbuffer: 603c18db65844bb81dbe62fee8fcc976eaeb7108
|
||||
React-microtasksnativemodule: d77e0c426fce34c23227394c96ca1033b30c813c
|
||||
react-native-render-html: 984dfe2294163d04bf5fe25d7c9f122e60e05ebe
|
||||
react-native-safe-area-context: 53f796cb6c814661bbe99fbdfd0585d07b996cdd
|
||||
react-native-view-shot: 26174e54ec6b4b7c5d70b86964b747919759adc1
|
||||
react-native-voice: f5e8eec2278451d0017eb6a30a6ccc726aca34e0
|
||||
react-native-webview: a4f0775a31b73cf13cfc3d2d2b119aa94ec76e49
|
||||
React-NativeModulesApple: 1664340b8750d64e0ef3907c5e53d9481f74bcbd
|
||||
React-defaultsnativemodule: d635ef36d755321e5d6fc065bd166b2c5a0e9833
|
||||
React-domnativemodule: dd28f6d96cd21236e020be2eff6fe0b7d4ec3b66
|
||||
React-Fabric: 2e32c3fdbb1fbcf5fde54607e3abe453c6652ce2
|
||||
React-FabricComponents: 5ed0cdb81f6b91656cb4d3be432feaa28a58071a
|
||||
React-FabricImage: 2bc714f818cb24e454f5d3961864373271b2faf8
|
||||
React-featureflags: 847642f41fa71ad4eec5e0351badebcad4fe6171
|
||||
React-featureflagsnativemodule: c868a544b2c626fa337bcbd364b1befe749f0d3f
|
||||
React-graphics: 192ec701def5b3f2a07db2814dfba5a44986cff6
|
||||
React-hermes: e875778b496c86d07ab2ccaa36a9505d248a254b
|
||||
React-idlecallbacksnativemodule: 4d57965cdf82c14ee3b337189836cd8491632b76
|
||||
React-ImageManager: bd0b99e370b13de82c9cd15f0f08144ff3de079e
|
||||
React-jserrorhandler: a2fdef4cbcfdcdf3fa9f5d1f7190f7fd4535248d
|
||||
React-jsi: 89d43d1e7d4d0663f8ba67e0b39eb4e4672c27de
|
||||
React-jsiexecutor: abe4874aaab90dfee5dec480680220b2f8af07e3
|
||||
React-jsinspector: a0b3e051aef842b0b2be2353790ae2b2a5a65a8f
|
||||
React-jsinspectorcdp: 6346013b2247c6263fbf5199adf4a8751e53bd89
|
||||
React-jsinspectornetwork: 26281aa50d49fc1ec93abf981d934698fa95714f
|
||||
React-jsinspectortracing: 55eedf6d57540507570259a778663b90060bbd6e
|
||||
React-jsitooling: 0e001113fa56d8498aa8ac28437ac0d36348e51a
|
||||
React-jsitracing: b713793eb8a5bbc4d86a84e9d9e5023c0f58cbaf
|
||||
React-logger: 50fdb9a8236da90c0b1072da5c32ee03aeb5bf28
|
||||
React-Mapbuffer: 9050ee10c19f4f7fca8963d0211b2854d624973e
|
||||
React-microtasksnativemodule: f775db9e991c6f3b8ccbc02bfcde22770f96e23b
|
||||
react-native-render-html: 5afc4751f1a98621b3009432ef84c47019dcb2bd
|
||||
react-native-safe-area-context: 37e680fc4cace3c0030ee46e8987d24f5d3bdab2
|
||||
react-native-view-shot: fb3c0774edb448f42705491802a455beac1502a2
|
||||
react-native-voice: 908a0eba96c8c3d643e4f98b7232c6557d0a6f9c
|
||||
react-native-webview: b29007f4723bca10872028067b07abacfa1cb35a
|
||||
React-NativeModulesApple: 8969913947d5b576de4ed371a939455a8daf28aa
|
||||
React-oscompat: ce47230ed20185e91de62d8c6d139ae61763d09c
|
||||
React-perflogger: b1af3cfb3f095f819b2814910000392a8e17ba9f
|
||||
React-performancetimeline: f9ec65b77bcadbc7bd8b47a6f4b4b697da7b1490
|
||||
React-perflogger: 02b010e665772c7dcb859d85d44c1bfc5ac7c0e4
|
||||
React-performancetimeline: 130db956b5a83aa4fb41ddf5ae68da89f3fb1526
|
||||
React-RCTActionSheet: 0b14875b3963e9124a5a29a45bd1b22df8803916
|
||||
React-RCTAnimation: 60f6eca214a62b9673f64db6df3830cee902b5af
|
||||
React-RCTAppDelegate: 37734b39bac108af30a0fd9d3e1149ec68b82c28
|
||||
React-RCTBlob: 83fbcbd57755caf021787324aac2fe9b028cc264
|
||||
React-RCTFabric: a05cb1df484008db3753c8b4a71e4c6d9f1e43a6
|
||||
React-RCTFBReactNativeSpec: d58d7ae9447020bbbac651e3b0674422aba18266
|
||||
React-RCTImage: 47aba3be7c6c64f956b7918ab933769602406aac
|
||||
React-RCTLinking: 2dbaa4df2e4523f68baa07936bd8efdfa34d5f31
|
||||
React-RCTNetwork: 1fca7455f9dedf7de2b95bec438da06680f3b000
|
||||
React-RCTRuntime: 17819dd1dfc8613efaf4cbb9d8686baae4a83e5b
|
||||
React-RCTSettings: 01bf91c856862354d3d2f642ccb82f3697a4284a
|
||||
React-RCTText: cb576a3797dcb64933613c522296a07eaafc0461
|
||||
React-RCTVibration: 560af8c086741f3525b8456a482cdbe27f9d098e
|
||||
React-RCTAnimation: a7b90fd2af7bb9c084428867445a1481a8cb112e
|
||||
React-RCTAppDelegate: 3262bedd01263f140ec62b7989f4355f57cec016
|
||||
React-RCTBlob: c17531368702f1ebed5d0ada75a7cf5915072a53
|
||||
React-RCTFabric: 6409edd8cfdc3133b6cc75636d3b858fdb1d11ea
|
||||
React-RCTFBReactNativeSpec: c004b27b4fa3bd85878ad2cf53de3bbec85da797
|
||||
React-RCTImage: c68078a120d0123f4f07a5ac77bea3bb10242f32
|
||||
React-RCTLinking: cf8f9391fe7fe471f96da3a5f0435235eca18c5b
|
||||
React-RCTNetwork: ca31f7c879355760c2d9832a06ee35f517938a20
|
||||
React-RCTRuntime: a6cf4a1e42754fc87f493e538f2ac6b820e45418
|
||||
React-RCTSettings: e0e140b2ff4bf86d34e9637f6316848fc00be035
|
||||
React-RCTText: 75915bace6f7877c03a840cc7b6c622fb62bfa6b
|
||||
React-RCTVibration: 25f26b85e5e432bb3c256f8b384f9269e9529f25
|
||||
React-rendererconsistency: 2dac03f448ff337235fd5820b10f81633328870d
|
||||
React-renderercss: c5c6b7a15948dd28facca39a18ac269073718490
|
||||
React-rendererdebug: 3c9d5e1634273f5a24d84cc5669f290ce0bdc812
|
||||
React-RuntimeApple: 887637d1e12ea8262df7d32bc100467df2302613
|
||||
React-RuntimeCore: 91f779835dc4f8f84777fe5dd24f1a22f96454e4
|
||||
React-runtimeexecutor: 8bb6b738f37b0ada4a6269e6f8ab1133dea0285c
|
||||
React-RuntimeHermes: 4cb93de9fa8b1cc753d200dbe61a01b9ec5f5562
|
||||
React-runtimescheduler: 83dc28f530bfbd2fce84ed13aa7feebdc24e5af7
|
||||
React-timing: 03c7217455d2bff459b27a3811be25796b600f47
|
||||
React-utils: 6d46795ae0444ec8a5d9a5f201157b286bf5250a
|
||||
ReactAppDependencyProvider: c277c5b231881ad4f00cd59e3aa0671b99d7ebee
|
||||
ReactCodegen: 4c44b74b77fc41ae25b9e2c7e9bd6e2bc772c23f
|
||||
ReactCommon: e6e232202a447d353e5531f2be82f50f47cbaa9a
|
||||
React-renderercss: 477da167bb96b5ac86d30c5d295412fb853f5453
|
||||
React-rendererdebug: 2a1798c6f3ef5f22d466df24c33653edbabb5b89
|
||||
React-RuntimeApple: 28cf4d8eb18432f6a21abbed7d801ab7f6b6f0b4
|
||||
React-RuntimeCore: 41bf0fd56a00de5660f222415af49879fa49c4f0
|
||||
React-runtimeexecutor: 1afb774dde3011348e8334be69d2f57a359ea43e
|
||||
React-RuntimeHermes: f3b158ea40e8212b1a723a68b4315e7a495c5fc6
|
||||
React-runtimescheduler: 3e1e2bec7300bae512533107d8e54c6e5c63fe0f
|
||||
React-timing: 6fa9883de2e41791e5dc4ec404e5e37f3f50e801
|
||||
React-utils: 6e2035b53d087927768649a11a26c4e092448e34
|
||||
ReactAppDependencyProvider: 1bcd3527ac0390a1c898c114f81ff954be35ed79
|
||||
ReactCodegen: 7d4593f7591f002d137fe40cef3f6c11f13c88cc
|
||||
ReactCommon: 08810150b1206cc44aecf5f6ae19af32f29151a8
|
||||
ReactNativeDependencies: 71ce9c28beb282aa720ea7b46980fff9669f428a
|
||||
RevenueCat: d185cbff8be9425b5835042afd6889389bb756c8
|
||||
RNCAsyncStorage: e85a99325df9eb0191a6ee2b2a842644c7eb29f4
|
||||
RNCMaskedView: 3c9d7586e2b9bbab573591dcb823918bc4668005
|
||||
RNCPicker: e0149590451d5eae242cf686014a6f6d808f93c7
|
||||
RNDateTimePicker: 5e0a759109b63ebc661a4714712361d2d07142fe
|
||||
RNDeviceInfo: 8b6fa8379062949dd79a009cf3d6b02a9c03ca59
|
||||
RNGestureHandler: 6a488ce85c88e82d8610db1108daf04e9b2d5162
|
||||
RNPurchases: 8a21795ad71f932dca9f48e9560924a3c64878c6
|
||||
RNReanimated: 43f611f1c85c90e0273df7399bf1536f8e2bd125
|
||||
RNScreens: dd61bc3a3e6f6901ad833efa411917d44827cf51
|
||||
RNSentry: 54f8041cd06d7ccf484171edde72f1b07323fb2e
|
||||
RNSVG: 2825ee146e0f6a16221e852299943e4cceef4528
|
||||
RNWorklets: e2b9423934700160135e216d8a2e6cb7b194846c
|
||||
RNCAsyncStorage: 3a4f5e2777dae1688b781a487923a08569e27fe4
|
||||
RNCMaskedView: d2578d41c59b936db122b2798ba37e4722d21035
|
||||
RNCPicker: c8a3584b74133464ee926224463fcc54dfdaebca
|
||||
RNDateTimePicker: 19ffa303c4524ec0a2dfdee2658198451c16b7f1
|
||||
RNDeviceInfo: bcce8752b5043a623fe3c26789679b473f705d3c
|
||||
RNGestureHandler: 2914750df066d89bf9d8f48a10ad5f0051108ac3
|
||||
RNPurchases: 34da99c0e14ee484ed57e77dc06dcfe8e7cb1cee
|
||||
RNReanimated: e5c702a3e24cc1c68b2de67671713f35461678f4
|
||||
RNScreens: d8d6f1792f6e7ac12b0190d33d8d390efc0c1845
|
||||
RNSentry: 1d7b9fdae7a01ad8f9053335b5d44e75c39a955e
|
||||
RNSVG: 31d6639663c249b7d5abc9728dde2041eb2a3c34
|
||||
RNWorklets: 9eb6d567fa43984e96b6924a6df504b8a15980cd
|
||||
SDWebImage: e9c98383c7572d713c1a0d7dd2783b10599b9838
|
||||
SDWebImageAVIFCoder: afe194a084e851f70228e4be35ef651df0fc5c57
|
||||
SDWebImageSVGCoder: 15a300a97ec1c8ac958f009c02220ac0402e936c
|
||||
|
||||
15
package-lock.json
generated
15
package-lock.json
generated
@@ -60,6 +60,7 @@
|
||||
"react-native": "0.81.5",
|
||||
"react-native-chart-kit": "^6.12.0",
|
||||
"react-native-device-info": "^14.0.4",
|
||||
"react-native-draggable-flatlist": "^4.0.3",
|
||||
"react-native-gesture-handler": "~2.28.0",
|
||||
"react-native-image-viewing": "^0.2.2",
|
||||
"react-native-markdown-display": "^7.0.2",
|
||||
@@ -12210,6 +12211,20 @@
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/react-native-draggable-flatlist": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://mirrors.tencent.com/npm/react-native-draggable-flatlist/-/react-native-draggable-flatlist-4.0.3.tgz",
|
||||
"integrity": "sha512-2F4x5BFieWdGq9SetD2nSAR7s7oQCSgNllYgERRXXtNfSOuAGAVbDb/3H3lP0y5f7rEyNwabKorZAD/SyyNbDw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/preset-typescript": "^7.17.12"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react-native": ">=0.64.0",
|
||||
"react-native-gesture-handler": ">=2.0.0",
|
||||
"react-native-reanimated": ">=2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-native-fit-image": {
|
||||
"version": "1.5.5",
|
||||
"resolved": "https://mirrors.tencent.com/npm/react-native-fit-image/-/react-native-fit-image-1.5.5.tgz",
|
||||
|
||||
@@ -61,6 +61,7 @@
|
||||
"react-native": "0.81.5",
|
||||
"react-native-chart-kit": "^6.12.0",
|
||||
"react-native-device-info": "^14.0.4",
|
||||
"react-native-draggable-flatlist": "^4.0.3",
|
||||
"react-native-gesture-handler": "~2.28.0",
|
||||
"react-native-image-viewing": "^0.2.2",
|
||||
"react-native-markdown-display": "^7.0.2",
|
||||
|
||||
@@ -2,6 +2,7 @@ import { analyzeHRVData, fetchHRVWithStatus } from '@/utils/health';
|
||||
import AsyncStorage from '@/utils/kvStore';
|
||||
import { logger } from '@/utils/logger';
|
||||
import { convertHrvToStressIndex, getStressLevelInfo, StressLevel } from '@/utils/stress';
|
||||
import { getHRVReminderEnabled, getNotificationEnabled } from '@/utils/userPreferences';
|
||||
import { NativeEventEmitter, NativeModules } from 'react-native';
|
||||
import { sendHRVStressNotification } from './hrvNotificationService';
|
||||
|
||||
@@ -88,6 +89,16 @@ class HRVMonitorService {
|
||||
this.lastProcessedTime = now;
|
||||
|
||||
try {
|
||||
const [notificationsEnabled, hrvReminderEnabled] = await Promise.all([
|
||||
getNotificationEnabled(),
|
||||
getHRVReminderEnabled(),
|
||||
]);
|
||||
|
||||
if (!notificationsEnabled || !hrvReminderEnabled) {
|
||||
logger.info('[HRVMonitor] Notification preference disabled, skip HRV push');
|
||||
return;
|
||||
}
|
||||
|
||||
const canNotify = await this.canSendNotification(now);
|
||||
if (!canNotify) {
|
||||
logger.info('[HRVMonitor] Notification recently sent, skipping');
|
||||
|
||||
260
utils/menstrualCycle.ts
Normal file
260
utils/menstrualCycle.ts
Normal 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];
|
||||
};
|
||||
@@ -14,10 +14,58 @@ const PREFERENCES_KEYS = {
|
||||
MEDICATION_REMINDER_ENABLED: 'user_preference_medication_reminder_enabled',
|
||||
NUTRITION_REMINDER_ENABLED: 'user_preference_nutrition_reminder_enabled',
|
||||
MOOD_REMINDER_ENABLED: 'user_preference_mood_reminder_enabled',
|
||||
HRV_REMINDER_ENABLED: 'user_preference_hrv_reminder_enabled',
|
||||
|
||||
// 首页身体指标卡片显示设置
|
||||
SHOW_MOOD_CARD: 'user_preference_show_mood_card',
|
||||
SHOW_STEPS_CARD: 'user_preference_show_steps_card',
|
||||
SHOW_STRESS_CARD: 'user_preference_show_stress_card',
|
||||
SHOW_SLEEP_CARD: 'user_preference_show_sleep_card',
|
||||
SHOW_FITNESS_RINGS_CARD: 'user_preference_show_fitness_rings_card',
|
||||
SHOW_WATER_CARD: 'user_preference_show_water_card',
|
||||
SHOW_BASAL_METABOLISM_CARD: 'user_preference_show_basal_metabolism_card',
|
||||
SHOW_OXYGEN_SATURATION_CARD: 'user_preference_show_oxygen_saturation_card',
|
||||
SHOW_MENSTRUAL_CYCLE_CARD: 'user_preference_show_menstrual_cycle_card',
|
||||
SHOW_WEIGHT_CARD: 'user_preference_show_weight_card',
|
||||
SHOW_CIRCUMFERENCE_CARD: 'user_preference_show_circumference_card',
|
||||
|
||||
// 首页身体指标卡片排序设置
|
||||
STATISTICS_CARD_ORDER: 'user_preference_statistics_card_order',
|
||||
} as const;
|
||||
|
||||
// 首页身体指标卡片显示设置接口
|
||||
export interface StatisticsCardsVisibility {
|
||||
showMood: boolean;
|
||||
showSteps: boolean;
|
||||
showStress: boolean;
|
||||
showSleep: boolean;
|
||||
showFitnessRings: boolean;
|
||||
showWater: boolean;
|
||||
showBasalMetabolism: boolean;
|
||||
showOxygenSaturation: boolean;
|
||||
showMenstrualCycle: boolean;
|
||||
showWeight: boolean;
|
||||
showCircumference: boolean;
|
||||
}
|
||||
|
||||
// 默认卡片顺序
|
||||
export const DEFAULT_CARD_ORDER: string[] = [
|
||||
'mood',
|
||||
'steps',
|
||||
'stress',
|
||||
'sleep',
|
||||
'fitness',
|
||||
'water',
|
||||
'metabolism',
|
||||
'oxygen',
|
||||
'menstrual',
|
||||
'weight',
|
||||
'circumference',
|
||||
];
|
||||
|
||||
// 用户偏好设置接口
|
||||
export interface UserPreferences {
|
||||
export interface UserPreferences extends StatisticsCardsVisibility {
|
||||
cardOrder: string[];
|
||||
quickWaterAmount: number;
|
||||
waterGoal: number;
|
||||
notificationEnabled: boolean;
|
||||
@@ -30,6 +78,7 @@ export interface UserPreferences {
|
||||
medicationReminderEnabled: boolean;
|
||||
nutritionReminderEnabled: boolean;
|
||||
moodReminderEnabled: boolean;
|
||||
hrvReminderEnabled: boolean;
|
||||
}
|
||||
|
||||
// 默认的用户偏好设置
|
||||
@@ -46,6 +95,23 @@ const DEFAULT_PREFERENCES: UserPreferences = {
|
||||
medicationReminderEnabled: true, // 默认开启药品提醒
|
||||
nutritionReminderEnabled: true, // 默认开启营养提醒
|
||||
moodReminderEnabled: true, // 默认开启心情提醒
|
||||
hrvReminderEnabled: true, // 默认开启 HRV 压力提醒
|
||||
|
||||
// 默认显示所有卡片
|
||||
showMood: true,
|
||||
showSteps: true,
|
||||
showStress: true,
|
||||
showSleep: true,
|
||||
showFitnessRings: true,
|
||||
showWater: true,
|
||||
showBasalMetabolism: true,
|
||||
showOxygenSaturation: true,
|
||||
showMenstrualCycle: true,
|
||||
showWeight: true,
|
||||
showCircumference: true,
|
||||
|
||||
// 默认卡片顺序
|
||||
cardOrder: DEFAULT_CARD_ORDER,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -65,6 +131,22 @@ export const getUserPreferences = async (): Promise<UserPreferences> => {
|
||||
const medicationReminderEnabled = await AsyncStorage.getItem(PREFERENCES_KEYS.MEDICATION_REMINDER_ENABLED);
|
||||
const nutritionReminderEnabled = await AsyncStorage.getItem(PREFERENCES_KEYS.NUTRITION_REMINDER_ENABLED);
|
||||
const moodReminderEnabled = await AsyncStorage.getItem(PREFERENCES_KEYS.MOOD_REMINDER_ENABLED);
|
||||
const hrvReminderEnabled = await AsyncStorage.getItem(PREFERENCES_KEYS.HRV_REMINDER_ENABLED);
|
||||
|
||||
// 获取首页卡片显示设置
|
||||
const showMood = await AsyncStorage.getItem(PREFERENCES_KEYS.SHOW_MOOD_CARD);
|
||||
const showSteps = await AsyncStorage.getItem(PREFERENCES_KEYS.SHOW_STEPS_CARD);
|
||||
const showStress = await AsyncStorage.getItem(PREFERENCES_KEYS.SHOW_STRESS_CARD);
|
||||
const showSleep = await AsyncStorage.getItem(PREFERENCES_KEYS.SHOW_SLEEP_CARD);
|
||||
const showFitnessRings = await AsyncStorage.getItem(PREFERENCES_KEYS.SHOW_FITNESS_RINGS_CARD);
|
||||
const showWater = await AsyncStorage.getItem(PREFERENCES_KEYS.SHOW_WATER_CARD);
|
||||
const showBasalMetabolism = await AsyncStorage.getItem(PREFERENCES_KEYS.SHOW_BASAL_METABOLISM_CARD);
|
||||
const showOxygenSaturation = await AsyncStorage.getItem(PREFERENCES_KEYS.SHOW_OXYGEN_SATURATION_CARD);
|
||||
const showMenstrualCycle = await AsyncStorage.getItem(PREFERENCES_KEYS.SHOW_MENSTRUAL_CYCLE_CARD);
|
||||
const showWeight = await AsyncStorage.getItem(PREFERENCES_KEYS.SHOW_WEIGHT_CARD);
|
||||
const showCircumference = await AsyncStorage.getItem(PREFERENCES_KEYS.SHOW_CIRCUMFERENCE_CARD);
|
||||
const cardOrderStr = await AsyncStorage.getItem(PREFERENCES_KEYS.STATISTICS_CARD_ORDER);
|
||||
const cardOrder = cardOrderStr ? JSON.parse(cardOrderStr) : DEFAULT_PREFERENCES.cardOrder;
|
||||
|
||||
return {
|
||||
quickWaterAmount: quickWaterAmount ? parseInt(quickWaterAmount, 10) : DEFAULT_PREFERENCES.quickWaterAmount,
|
||||
@@ -79,6 +161,20 @@ export const getUserPreferences = async (): Promise<UserPreferences> => {
|
||||
medicationReminderEnabled: medicationReminderEnabled !== null ? medicationReminderEnabled === 'true' : DEFAULT_PREFERENCES.medicationReminderEnabled,
|
||||
nutritionReminderEnabled: nutritionReminderEnabled !== null ? nutritionReminderEnabled === 'true' : DEFAULT_PREFERENCES.nutritionReminderEnabled,
|
||||
moodReminderEnabled: moodReminderEnabled !== null ? moodReminderEnabled === 'true' : DEFAULT_PREFERENCES.moodReminderEnabled,
|
||||
hrvReminderEnabled: hrvReminderEnabled !== null ? hrvReminderEnabled === 'true' : DEFAULT_PREFERENCES.hrvReminderEnabled,
|
||||
|
||||
showMood: showMood !== null ? showMood === 'true' : DEFAULT_PREFERENCES.showMood,
|
||||
showSteps: showSteps !== null ? showSteps === 'true' : DEFAULT_PREFERENCES.showSteps,
|
||||
showStress: showStress !== null ? showStress === 'true' : DEFAULT_PREFERENCES.showStress,
|
||||
showSleep: showSleep !== null ? showSleep === 'true' : DEFAULT_PREFERENCES.showSleep,
|
||||
showFitnessRings: showFitnessRings !== null ? showFitnessRings === 'true' : DEFAULT_PREFERENCES.showFitnessRings,
|
||||
showWater: showWater !== null ? showWater === 'true' : DEFAULT_PREFERENCES.showWater,
|
||||
showBasalMetabolism: showBasalMetabolism !== null ? showBasalMetabolism === 'true' : DEFAULT_PREFERENCES.showBasalMetabolism,
|
||||
showOxygenSaturation: showOxygenSaturation !== null ? showOxygenSaturation === 'true' : DEFAULT_PREFERENCES.showOxygenSaturation,
|
||||
showMenstrualCycle: showMenstrualCycle !== null ? showMenstrualCycle === 'true' : DEFAULT_PREFERENCES.showMenstrualCycle,
|
||||
showWeight: showWeight !== null ? showWeight === 'true' : DEFAULT_PREFERENCES.showWeight,
|
||||
showCircumference: showCircumference !== null ? showCircumference === 'true' : DEFAULT_PREFERENCES.showCircumference,
|
||||
cardOrder,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('获取用户偏好设置失败:', error);
|
||||
@@ -393,6 +489,7 @@ export const resetUserPreferences = async (): Promise<void> => {
|
||||
await AsyncStorage.removeItem(PREFERENCES_KEYS.MEDICATION_REMINDER_ENABLED);
|
||||
await AsyncStorage.removeItem(PREFERENCES_KEYS.NUTRITION_REMINDER_ENABLED);
|
||||
await AsyncStorage.removeItem(PREFERENCES_KEYS.MOOD_REMINDER_ENABLED);
|
||||
await AsyncStorage.removeItem(PREFERENCES_KEYS.HRV_REMINDER_ENABLED);
|
||||
} catch (error) {
|
||||
console.error('重置用户偏好设置失败:', error);
|
||||
throw error;
|
||||
@@ -476,3 +573,111 @@ export const getMoodReminderEnabled = async (): Promise<boolean> => {
|
||||
return DEFAULT_PREFERENCES.moodReminderEnabled;
|
||||
}
|
||||
};
|
||||
|
||||
export const setHRVReminderEnabled = async (enabled: boolean): Promise<void> => {
|
||||
try {
|
||||
await AsyncStorage.setItem(PREFERENCES_KEYS.HRV_REMINDER_ENABLED, enabled.toString());
|
||||
} catch (error) {
|
||||
console.error('设置 HRV 提醒开关失败:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const getHRVReminderEnabled = async (): Promise<boolean> => {
|
||||
try {
|
||||
const enabled = await AsyncStorage.getItem(PREFERENCES_KEYS.HRV_REMINDER_ENABLED);
|
||||
return enabled !== null ? enabled === 'true' : DEFAULT_PREFERENCES.hrvReminderEnabled;
|
||||
} catch (error) {
|
||||
console.error('获取 HRV 提醒开关状态失败:', error);
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user