feat(health): 新增手腕温度监测和经期双向同步功能

新增手腕温度健康数据追踪,支持Apple Watch睡眠手腕温度数据展示和30天历史趋势分析
实现经期数据与HealthKit的完整双向同步,支持读取、写入和删除经期记录
优化经期预测算法,基于历史数据计算更准确的周期和排卵日预测
重构经期UI组件为模块化结构,提升代码可维护性
添加完整的中英文国际化支持,覆盖所有新增功能界面
This commit is contained in:
richarjiang
2025-12-18 08:40:08 +08:00
parent 9b4a300380
commit 4836058d56
31 changed files with 2249 additions and 539 deletions

View File

@@ -10,7 +10,7 @@ import { useVersionCheck } from '@/contexts/VersionCheckContext';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useAuthGuard } from '@/hooks/useAuthGuard';
import { useColorScheme } from '@/hooks/useColorScheme';
import { useI18n } from '@/hooks/useI18n';
import type { BadgeDto } from '@/services/badges';
import { reportBadgeShowcaseDisplayed } from '@/services/badges';
import { updateUser, type UserLanguage } from '@/services/users';

View File

@@ -7,6 +7,7 @@ import { NutritionRadarCard } from '@/components/NutritionRadarCard';
import CircumferenceCard from '@/components/statistic/CircumferenceCard';
import OxygenSaturationCard from '@/components/statistic/OxygenSaturationCard';
import SleepCard from '@/components/statistic/SleepCard';
import WristTemperatureCard from '@/components/statistic/WristTemperatureCard';
import StepsCard from '@/components/StepsCard';
import { StressMeter } from '@/components/StressMeter';
import WaterIntakeCard from '@/components/WaterIntakeCard';
@@ -109,6 +110,7 @@ export default function ExploreScreen() {
showWater: true,
showBasalMetabolism: true,
showOxygenSaturation: true,
showWristTemperature: true,
showMenstrualCycle: true,
showWeight: true,
showCircumference: true,
@@ -443,7 +445,7 @@ export default function ExploreScreen() {
style={styles.scrollView}
contentContainerStyle={{
paddingTop: insets.top,
paddingBottom: 60,
paddingBottom: 100,
paddingHorizontal: 20
}}
showsVerticalScrollIndicator={false}
@@ -615,6 +617,15 @@ export default function ExploreScreen() {
/>
)
},
temperature: {
visible: cardVisibility.showWristTemperature,
component: (
<WristTemperatureCard
selectedDate={currentSelectedDate}
style={styles.basalMetabolismCardOverride}
/>
)
},
menstrual: {
visible: cardVisibility.showMenstrualCycle,
component: (

View File

@@ -4,152 +4,51 @@ 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 { useTranslation } from 'react-i18next';
import { Colors } from '@/constants/Colors';
import { InlineTip, ITEM_HEIGHT, Legend, MonthBlock } from '@/components/menstrual-cycle';
import {
deleteMenstrualFlow,
fetchMenstrualFlowSamples,
saveMenstrualFlow
} from '@/utils/health';
import {
buildMenstrualTimeline,
convertHealthKitSamplesToCycleRecords,
CycleRecord,
DEFAULT_PERIOD_LENGTH,
MenstrualDayCell,
MenstrualDayStatus,
MenstrualTimeline,
buildMenstrualTimeline
DEFAULT_PERIOD_LENGTH
} 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 { t } = useTranslation();
const [records, setRecords] = useState<CycleRecord[]>([]);
const [windowConfig, setWindowConfig] = useState({ before: 2, after: 3 });
// Load data from HealthKit
useEffect(() => {
const loadData = async () => {
// Calculate date range based on windowConfig
const today = dayjs();
const startDate = today.subtract(windowConfig.before, 'month').startOf('month').toDate();
const endDate = today.add(windowConfig.after, 'month').endOf('month').toDate();
const samples = await fetchMenstrualFlowSamples(startDate, endDate);
const convertedRecords = convertHealthKitSamplesToCycleRecords(samples);
setRecords(convertedRecords);
};
loadData();
}, [windowConfig]);
const timeline = useMemo(
() =>
buildMenstrualTimeline({
@@ -173,10 +72,10 @@ export default function MenstrualCycleScreen() {
const selectedDate = dayjs(selectedDateKey);
const handleMarkStart = () => {
const handleMarkStart = async () => {
if (selectedDate.isAfter(dayjs(), 'day')) return;
// Check if the selected date is already covered by an existing record (including duration)
// Check if the selected date is already covered
const isCovered = records.some((r) => {
const start = dayjs(r.startDate);
const end = start.add((r.periodLength ?? DEFAULT_PERIOD_LENGTH) - 1, 'day');
@@ -187,45 +86,36 @@ export default function MenstrualCycleScreen() {
});
if (isCovered) return;
// Optimistic Update
const originalRecords = [...records];
setRecords((prev) => {
const updated = [...prev];
// 1. Check if selectedDate is immediately after an existing period
// Logic for optimistic UI update (same as original logic)
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[prevRecordIndex] = { ...prevRecord, periodLength: newLength };
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,
@@ -233,7 +123,6 @@ export default function MenstrualCycleScreen() {
periodLength: (nextRecord.periodLength ?? DEFAULT_PERIOD_LENGTH) + 1,
};
} else {
// Create new isolated record
const newRecord: CycleRecord = {
startDate: selectedDate.format('YYYY-MM-DD'),
periodLength: 7,
@@ -241,18 +130,59 @@ export default function MenstrualCycleScreen() {
};
updated.push(newRecord);
}
return updated.sort(
(a, b) => dayjs(a.startDate).valueOf() - dayjs(b.startDate).valueOf()
);
return updated.sort((a, b) => dayjs(a.startDate).valueOf() - dayjs(b.startDate).valueOf());
});
try {
// Determine what to save to HealthKit
// If we are merging or extending, we are effectively adding one day of flow
// If we are creating a new record, we default to 7 days
// However, accurate HealthKit logging should be per day.
// The previous UI logic "creates" a 7-day period for a single tap.
// We should replicate this behavior in HealthKit for consistency.
const isNewIsolatedRecord = !records.some((r) => {
const start = dayjs(r.startDate);
const end = start.add((r.periodLength ?? DEFAULT_PERIOD_LENGTH) - 1, 'day');
// Check adjacency
return (
end.add(1, 'day').isSame(selectedDate, 'day') ||
dayjs(r.startDate).subtract(1, 'day').isSame(selectedDate, 'day')
);
});
if (isNewIsolatedRecord) {
// Save 7 days of flow starting from selectedDate
const promises = [];
for (let i = 0; i < 7; i++) {
const date = selectedDate.add(i, 'day');
// Don't save future dates if they exceed today (though logic allows predicting)
// But for flow logging, we usually only log past/present.
// However, UI allows setting a period that might extend slightly?
// Let's stick to the selected date logic.
// Wait, if I tap "Mark Start", it creates a 7 day period.
// Should I write 7 samples? Yes, to match the UI state.
promises.push(saveMenstrualFlow(date.toDate(), 1, i === 0)); // 1=unspecified
}
await Promise.all(promises);
} else {
// Just adding a single day to bridge/extend
await saveMenstrualFlow(selectedDate.toDate(), 1, false);
}
} catch (error) {
console.error('Failed to save to HealthKit', error);
// Revert optimistic update
setRecords(originalRecords);
}
};
const handleCancelMark = () => {
const handleCancelMark = async () => {
if (!selectedInfo || !selectedInfo.confirmed) return;
if (selectedDate.isAfter(dayjs(), 'day')) return;
const target = selectedDate;
// Optimistic Update
const originalRecords = [...records];
setRecords((prev) => {
const updated: CycleRecord[] = [];
prev.forEach((record) => {
@@ -264,21 +194,47 @@ export default function MenstrualCycleScreen() {
updated.push(record);
return;
}
if (diff === 0) {
// 取消开始日:移除整段记录
return;
}
// diff > 0 且在区间内:将该日标记为结束日 (选中当日也被取消,所以长度为 diff)
updated.push({
...record,
periodLength: diff,
});
if (diff === 0) return; // Remove entire record (or start of it)
updated.push({ ...record, periodLength: diff }); // Shorten it
});
return updated;
});
try {
// Logic:
// 1. Find the record covering the target date
const record = records.find((r) => {
const start = dayjs(r.startDate);
const end = start.add((r.periodLength ?? DEFAULT_PERIOD_LENGTH) - 1, 'day');
return (
(target.isSame(start, 'day') || target.isAfter(start, 'day')) &&
(target.isSame(end, 'day') || target.isBefore(end, 'day'))
);
});
if (record) {
const start = dayjs(record.startDate);
const diff = target.diff(start, 'day');
if (diff === 0) {
// If cancelling the start date, the UI removes the ENTIRE period record.
// So we should delete all samples for this period range.
const periodLength = record.periodLength ?? DEFAULT_PERIOD_LENGTH;
const endDate = start.add(periodLength - 1, 'day');
await deleteMenstrualFlow(start.toDate(), endDate.toDate());
} else {
// If cancelling a middle/end date, the UI shortens the period to end BEFORE target.
// So we delete from target date onwards to the original end date.
const periodLength = record.periodLength ?? DEFAULT_PERIOD_LENGTH;
const originalEnd = start.add(periodLength - 1, 'day');
// Delete from target to originalEnd
await deleteMenstrualFlow(target.toDate(), originalEnd.toDate());
}
}
} catch (error) {
console.error('Failed to delete from HealthKit', error);
setRecords(originalRecords);
}
};
const handleLoadPrevious = () => {
@@ -314,27 +270,7 @@ export default function MenstrualCycleScreen() {
}
}).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) => ({
@@ -344,40 +280,19 @@ export default function MenstrualCycleScreen() {
}));
}, [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 renderInlineTip = (columnIndex: number) => (
<InlineTip
selectedDate={selectedDate}
selectedInfo={selectedInfo}
columnIndex={columnIndex}
onMarkStart={handleMarkStart}
onCancelMark={handleCancelMark}
/>
);
const renderCycleTab = () => (
<View style={styles.tabContent}>
{renderLegend()}
<Legend />
<FlatList
@@ -411,9 +326,9 @@ export default function MenstrualCycleScreen() {
const renderAnalysisTab = () => (
<View style={styles.tabContent}>
<View style={styles.analysisCard}>
<Text style={styles.analysisTitle}></Text>
<Text style={styles.analysisTitle}>{t('menstrual.screen.analysis.title')}</Text>
<Text style={styles.analysisBody}>
6
{t('menstrual.screen.analysis.description')}
</Text>
</View>
</View>
@@ -433,7 +348,7 @@ export default function MenstrualCycleScreen() {
<TouchableOpacity onPress={() => router.back()} style={styles.headerIcon}>
<Ionicons name="chevron-back" size={22} color="#0f172a" />
</TouchableOpacity>
<Text style={styles.headerTitle}></Text>
<Text style={styles.headerTitle}>{t('menstrual.screen.header')}</Text>
<TouchableOpacity style={styles.headerIcon}>
<Ionicons name="settings-outline" size={20} color="#0f172a" />
</TouchableOpacity>
@@ -441,8 +356,8 @@ export default function MenstrualCycleScreen() {
<View style={styles.tabSwitcher}>
{([
{ key: 'cycle', label: '生理周期' },
{ key: 'analysis', label: '分析' },
{ key: 'cycle', label: t('menstrual.screen.tabs.cycle') },
{ key: 'analysis', label: t('menstrual.screen.tabs.analysis') },
] as { key: TabKey; label: string }[]).map((tab) => {
const active = activeTab === tab.key;
return (
@@ -525,32 +440,7 @@ const styles = StyleSheet.create({
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,
@@ -569,206 +459,7 @@ const styles = StyleSheet.create({
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,
},

View File

@@ -46,6 +46,7 @@ export default function StatisticsCustomizationScreen() {
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' },
temperature: { icon: 'thermometer-outline', titleKey: 'statisticsCustomization.items.wristTemperature', visibilityKey: 'showWristTemperature' },
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' },