feat: 新增健康档案模块,支持家庭邀请与个人健康数据管理
This commit is contained in:
@@ -1,9 +1,15 @@
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { fetchHRVSamples, HRVData } from '@/utils/health';
|
||||
import { convertHrvToStressIndex, getStressLevelInfo } from '@/utils/stress';
|
||||
import dayjs from 'dayjs';
|
||||
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import React from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Modal,
|
||||
Platform,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
@@ -18,18 +24,103 @@ interface StressAnalysisModalProps {
|
||||
updateTime: Date;
|
||||
}
|
||||
|
||||
interface StressStats {
|
||||
percentage: number;
|
||||
count: number;
|
||||
range: string;
|
||||
}
|
||||
|
||||
interface HistoryData {
|
||||
goodEvents: StressStats;
|
||||
energetic: StressStats;
|
||||
stressed: StressStats;
|
||||
totalSamples: number;
|
||||
}
|
||||
|
||||
export function StressAnalysisModal({ visible, onClose, hrvValue, updateTime }: StressAnalysisModalProps) {
|
||||
const colorScheme = useColorScheme();
|
||||
const colors = Colors[colorScheme ?? 'light'];
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [historyData, setHistoryData] = useState<HistoryData>({
|
||||
goodEvents: { percentage: 0, count: 0, range: '>75毫秒' },
|
||||
energetic: { percentage: 0, count: 0, range: '40-75毫秒' },
|
||||
stressed: { percentage: 0, count: 0, range: '<40毫秒' },
|
||||
totalSamples: 0
|
||||
});
|
||||
|
||||
// 模拟30天HRV数据
|
||||
const hrvData = {
|
||||
goodEvents: { percentage: 26, count: 53, range: '>80毫秒' },
|
||||
energetic: { percentage: 47, count: 97, range: '43-80毫秒' },
|
||||
stressed: { percentage: 27, count: 56, range: '<43毫秒' },
|
||||
// 当前压力状态
|
||||
const stressIndex = convertHrvToStressIndex(hrvValue);
|
||||
const stressInfo = getStressLevelInfo(stressIndex);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
loadHistoryData();
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
const loadHistoryData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const endDate = new Date();
|
||||
const startDate = dayjs().subtract(30, 'day').toDate();
|
||||
|
||||
const samples = await fetchHRVSamples(startDate, endDate);
|
||||
processHistoryData(samples);
|
||||
} catch (error) {
|
||||
console.error('Failed to load HRV history:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const processHistoryData = (samples: HRVData[]) => {
|
||||
if (!samples.length) return;
|
||||
|
||||
let goodCount = 0;
|
||||
let energeticCount = 0;
|
||||
let stressedCount = 0;
|
||||
|
||||
samples.forEach(sample => {
|
||||
const val = sample.value;
|
||||
if (val > 75) {
|
||||
goodCount++;
|
||||
} else if (val >= 40) {
|
||||
energeticCount++;
|
||||
} else {
|
||||
stressedCount++;
|
||||
}
|
||||
});
|
||||
|
||||
const total = samples.length;
|
||||
|
||||
setHistoryData({
|
||||
goodEvents: {
|
||||
percentage: Math.round((goodCount / total) * 100),
|
||||
count: goodCount,
|
||||
range: '>75毫秒'
|
||||
},
|
||||
energetic: {
|
||||
percentage: Math.round((energeticCount / total) * 100),
|
||||
count: energeticCount,
|
||||
range: '40-75毫秒'
|
||||
},
|
||||
stressed: {
|
||||
percentage: Math.round((stressedCount / total) * 100),
|
||||
count: stressedCount,
|
||||
range: '<40毫秒'
|
||||
},
|
||||
totalSamples: total
|
||||
});
|
||||
};
|
||||
|
||||
const getStatusColor = (level: string) => {
|
||||
switch (level) {
|
||||
case 'low': return '#10B981';
|
||||
case 'moderate': return '#3B82F6';
|
||||
case 'high': return '#F59E0B';
|
||||
default: return colors.text;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@@ -45,80 +136,139 @@ export function StressAnalysisModal({ visible, onClose, hrvValue, updateTime }:
|
||||
end={{ x: 0, y: 1 }}
|
||||
>
|
||||
<ScrollView style={styles.content} showsVerticalScrollIndicator={false}>
|
||||
{/* 标题 */}
|
||||
<Text style={styles.title}>压力情况分析</Text>
|
||||
{/* 标题区域 */}
|
||||
<Text style={styles.title}>压力分析</Text>
|
||||
|
||||
{/* 当前状态卡片 */}
|
||||
<View style={styles.currentStatusCard}>
|
||||
<View style={styles.statusHeader}>
|
||||
<Text style={styles.statusLabel}>当前状态</Text>
|
||||
<Text style={styles.updateTime}>更新于 {dayjs(updateTime).format('HH:mm')}</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.statusValueContainer}>
|
||||
<View>
|
||||
<Text style={[styles.statusText, { color: getStatusColor(stressInfo.level) }]}>
|
||||
{stressInfo.label}
|
||||
</Text>
|
||||
<Text style={styles.statusDesc}>{stressInfo.description}</Text>
|
||||
</View>
|
||||
<View style={styles.hrvValueBox}>
|
||||
<Text style={styles.hrvValueLabel}>HRV</Text>
|
||||
<Text style={styles.hrvValue}>{Math.round(hrvValue)}<Text style={styles.hrvUnit}>ms</Text></Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
|
||||
{/* 最近30天HRV情况 */}
|
||||
<Text style={styles.sectionTitle}>最近30天HRV情况</Text>
|
||||
<Text style={styles.sectionTitle}>最近30天压力分布</Text>
|
||||
|
||||
{/* 彩色横条图 */}
|
||||
<View style={styles.chartContainer}>
|
||||
<View style={styles.colorBar}>
|
||||
<LinearGradient
|
||||
colors={['#F59E0B', '#3B82F6', '#10B981']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 0 }}
|
||||
style={styles.gradientBar}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.legend}>
|
||||
<View style={styles.legendItem}>
|
||||
<View style={[styles.legendDot, { backgroundColor: '#F59E0B' }]} />
|
||||
<Text style={styles.legendText}>鸭梨山大</Text>
|
||||
</View>
|
||||
<View style={styles.legendItem}>
|
||||
<View style={[styles.legendDot, { backgroundColor: '#3B82F6' }]} />
|
||||
<Text style={styles.legendText}>活力满满</Text>
|
||||
</View>
|
||||
<View style={styles.legendItem}>
|
||||
<View style={[styles.legendDot, { backgroundColor: '#10B981' }]} />
|
||||
<Text style={styles.legendText}>好事发生</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 数据统计卡片 */}
|
||||
<View style={styles.statsCard}>
|
||||
{/* 好事发生 & 活力满满 */}
|
||||
<View style={styles.statsRow}>
|
||||
<View style={styles.statItem}>
|
||||
<Text style={[styles.statTitle, { color: '#10B981' }]}>好事发生</Text>
|
||||
<Text style={styles.statPercentage}>{hrvData.goodEvents.percentage}%</Text>
|
||||
<View style={styles.statDetails}>
|
||||
<Text style={styles.statRange}>❤️ {hrvData.goodEvents.range}</Text>
|
||||
{loading ? (
|
||||
<ActivityIndicator size="large" color={colors.primary} style={{ marginTop: 20 }} />
|
||||
) : (
|
||||
<>
|
||||
{/* 彩色横条图 */}
|
||||
<View style={styles.chartContainer}>
|
||||
<View style={styles.colorBar}>
|
||||
{historyData.totalSamples > 0 ? (
|
||||
<View style={styles.progressBarContainer}>
|
||||
{historyData.stressed.percentage > 0 && (
|
||||
<View style={[styles.progressSegment, { flex: historyData.stressed.percentage, backgroundColor: '#F59E0B', marginRight: 2 }]} />
|
||||
)}
|
||||
{historyData.energetic.percentage > 0 && (
|
||||
<View style={[styles.progressSegment, { flex: historyData.energetic.percentage, backgroundColor: '#3B82F6', marginRight: 2 }]} />
|
||||
)}
|
||||
{historyData.goodEvents.percentage > 0 && (
|
||||
<View style={[styles.progressSegment, { flex: historyData.goodEvents.percentage, backgroundColor: '#10B981' }]} />
|
||||
)}
|
||||
</View>
|
||||
) : (
|
||||
<View style={[styles.progressBarContainer, { backgroundColor: '#E5E7EB' }]} />
|
||||
)}
|
||||
</View>
|
||||
<Text style={styles.statCount}>{hrvData.goodEvents.count}次</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.statItem}>
|
||||
<Text style={[styles.statTitle, { color: '#3B82F6' }]}>活力满满</Text>
|
||||
<Text style={styles.statPercentage}>{hrvData.energetic.percentage}%</Text>
|
||||
<View style={styles.statDetails}>
|
||||
<Text style={styles.statRange}>❤️ {hrvData.energetic.range}</Text>
|
||||
|
||||
<View style={styles.legend}>
|
||||
<View style={styles.legendItem}>
|
||||
<View style={[styles.legendDot, { backgroundColor: '#F59E0B' }]} />
|
||||
<Text style={styles.legendText}>鸭梨山大</Text>
|
||||
</View>
|
||||
<View style={styles.legendItem}>
|
||||
<View style={[styles.legendDot, { backgroundColor: '#3B82F6' }]} />
|
||||
<Text style={styles.legendText}>活力满满</Text>
|
||||
</View>
|
||||
<View style={styles.legendItem}>
|
||||
<View style={[styles.legendDot, { backgroundColor: '#10B981' }]} />
|
||||
<Text style={styles.legendText}>好事发生</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Text style={styles.statCount}>{hrvData.energetic.count}次</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 鸭梨山大 */}
|
||||
<View style={styles.statItem}>
|
||||
<Text style={[styles.statTitle, { color: '#F59E0B' }]}>鸭梨山大</Text>
|
||||
<Text style={styles.statPercentage}>{hrvData.stressed.percentage}%</Text>
|
||||
<View style={styles.statDetails}>
|
||||
<Text style={styles.statRange}>❤️ {hrvData.stressed.range}</Text>
|
||||
{/* 数据统计卡片 */}
|
||||
<View style={styles.statsCard}>
|
||||
{/* 好事发生 & 活力满满 */}
|
||||
<View style={styles.statsRow}>
|
||||
<View style={styles.statItem}>
|
||||
<Text style={[styles.statTitle, { color: '#10B981' }]}>好事发生</Text>
|
||||
<Text style={styles.statPercentage}>{historyData.goodEvents.percentage}%</Text>
|
||||
<View style={styles.statDetails}>
|
||||
<Text style={[styles.statRange, { color: '#10B981', backgroundColor: '#ECFDF5' }]}>
|
||||
HRV {historyData.goodEvents.range}
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={styles.statCount}>{historyData.goodEvents.count}次</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.statItem}>
|
||||
<Text style={[styles.statTitle, { color: '#3B82F6' }]}>活力满满</Text>
|
||||
<Text style={styles.statPercentage}>{historyData.energetic.percentage}%</Text>
|
||||
<View style={styles.statDetails}>
|
||||
<Text style={[styles.statRange, { color: '#3B82F6', backgroundColor: '#EFF6FF' }]}>
|
||||
HRV {historyData.energetic.range}
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={styles.statCount}>{historyData.energetic.count}次</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 鸭梨山大 */}
|
||||
<View style={styles.statItem}>
|
||||
<Text style={[styles.statTitle, { color: '#F59E0B' }]}>鸭梨山大</Text>
|
||||
<Text style={styles.statPercentage}>{historyData.stressed.percentage}%</Text>
|
||||
<View style={styles.statDetails}>
|
||||
<Text style={[styles.statRange, { color: '#F59E0B', backgroundColor: '#FFFBEB' }]}>
|
||||
HRV {historyData.stressed.range}
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={styles.statCount}>{historyData.stressed.count}次</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Text style={styles.statCount}>{hrvData.stressed.count}次</Text>
|
||||
</View>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</ScrollView>
|
||||
|
||||
{/* 底部继续按钮 */}
|
||||
<View style={styles.bottomContainer}>
|
||||
<TouchableOpacity style={styles.continueButton} onPress={onClose}>
|
||||
<View style={styles.buttonBackground}>
|
||||
<Text style={styles.buttonText}>继续</Text>
|
||||
</View>
|
||||
<TouchableOpacity style={styles.continueButton} onPress={onClose} activeOpacity={0.85}>
|
||||
{isLiquidGlassAvailable() ? (
|
||||
<GlassView
|
||||
glassEffectStyle="regular"
|
||||
tintColor="rgba(139, 92, 246, 0.85)"
|
||||
isInteractive={true}
|
||||
style={styles.glassButton}
|
||||
>
|
||||
<Text style={styles.buttonText}>继续</Text>
|
||||
</GlassView>
|
||||
) : (
|
||||
<LinearGradient
|
||||
colors={['#8B5CF6', '#7C3AED']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 0 }}
|
||||
style={styles.buttonGradient}
|
||||
>
|
||||
<Text style={styles.buttonText}>继续</Text>
|
||||
</LinearGradient>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
<View style={styles.homeIndicator} />
|
||||
</View>
|
||||
@@ -140,15 +290,78 @@ const styles = StyleSheet.create({
|
||||
fontWeight: '800',
|
||||
color: '#111827',
|
||||
textAlign: 'center',
|
||||
marginTop: 20,
|
||||
marginTop: 24,
|
||||
marginBottom: 32,
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
|
||||
sectionTitle: {
|
||||
fontSize: 22,
|
||||
currentStatusCard: {
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderRadius: 20,
|
||||
padding: 20,
|
||||
marginBottom: 32,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.06,
|
||||
shadowRadius: 12,
|
||||
elevation: 4,
|
||||
},
|
||||
statusHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 16,
|
||||
},
|
||||
statusLabel: {
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
color: '#374151',
|
||||
},
|
||||
updateTime: {
|
||||
fontSize: 12,
|
||||
color: '#9CA3AF',
|
||||
},
|
||||
statusValueContainer: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
},
|
||||
statusText: {
|
||||
fontSize: 28,
|
||||
fontWeight: '800',
|
||||
marginBottom: 4,
|
||||
},
|
||||
statusDesc: {
|
||||
fontSize: 14,
|
||||
color: '#6B7280',
|
||||
maxWidth: 200,
|
||||
},
|
||||
hrvValueBox: {
|
||||
alignItems: 'flex-end',
|
||||
},
|
||||
hrvValueLabel: {
|
||||
fontSize: 12,
|
||||
color: '#9CA3AF',
|
||||
fontWeight: '600',
|
||||
marginBottom: 2,
|
||||
},
|
||||
hrvValue: {
|
||||
fontSize: 32,
|
||||
fontWeight: '800',
|
||||
color: '#111827',
|
||||
lineHeight: 36,
|
||||
},
|
||||
hrvUnit: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: '#6B7280',
|
||||
marginLeft: 2,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: '700',
|
||||
color: '#111827',
|
||||
marginBottom: 20,
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
chartContainer: {
|
||||
marginBottom: 32,
|
||||
@@ -158,6 +371,15 @@ const styles = StyleSheet.create({
|
||||
borderRadius: 8,
|
||||
overflow: 'hidden',
|
||||
marginBottom: 16,
|
||||
backgroundColor: '#F3F4F6',
|
||||
},
|
||||
progressBarContainer: {
|
||||
flexDirection: 'row',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
progressSegment: {
|
||||
height: '100%',
|
||||
},
|
||||
gradientBar: {
|
||||
flex: 1,
|
||||
@@ -171,96 +393,102 @@ const styles = StyleSheet.create({
|
||||
alignItems: 'center',
|
||||
},
|
||||
legendDot: {
|
||||
width: 12,
|
||||
height: 12,
|
||||
borderRadius: 6,
|
||||
marginRight: 6,
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: 5,
|
||||
marginRight: 8,
|
||||
},
|
||||
legendText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: '#374151',
|
||||
fontSize: 13,
|
||||
fontWeight: '500',
|
||||
color: '#4B5563',
|
||||
},
|
||||
statsCard: {
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderRadius: 16,
|
||||
padding: 20,
|
||||
borderRadius: 20,
|
||||
padding: 24,
|
||||
marginBottom: 32,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: {
|
||||
width: 0,
|
||||
height: 2,
|
||||
},
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 8,
|
||||
elevation: 2,
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.04,
|
||||
shadowRadius: 12,
|
||||
elevation: 3,
|
||||
},
|
||||
statsRow: {
|
||||
flexDirection: 'row',
|
||||
gap: 20,
|
||||
marginBottom: 24,
|
||||
gap: 24,
|
||||
marginBottom: 32,
|
||||
},
|
||||
statItem: {
|
||||
flex: 1,
|
||||
},
|
||||
statTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
marginBottom: 8,
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
marginBottom: 12,
|
||||
},
|
||||
statPercentage: {
|
||||
fontSize: 36,
|
||||
fontSize: 32,
|
||||
fontWeight: '800',
|
||||
color: '#111827',
|
||||
marginBottom: 4,
|
||||
marginBottom: 8,
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
statDetails: {
|
||||
marginBottom: 4,
|
||||
marginBottom: 8,
|
||||
},
|
||||
statRange: {
|
||||
fontSize: 14,
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
color: '#DC2626',
|
||||
backgroundColor: '#FEE2E2',
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 3,
|
||||
borderRadius: 10,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 6,
|
||||
alignSelf: 'flex-start',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
statCount: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
fontSize: 13,
|
||||
fontWeight: '500',
|
||||
color: '#6B7280',
|
||||
},
|
||||
bottomContainer: {
|
||||
paddingHorizontal: 20,
|
||||
paddingBottom: 34,
|
||||
paddingBottom: Platform.OS === 'ios' ? 34 : 20,
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
continueButton: {
|
||||
borderRadius: 25,
|
||||
borderRadius: 28,
|
||||
overflow: 'hidden',
|
||||
marginBottom: 8,
|
||||
marginBottom: 12,
|
||||
shadowColor: '#8B5CF6',
|
||||
shadowOffset: { width: 0, height: 8 },
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 16,
|
||||
elevation: 8,
|
||||
},
|
||||
glassButton: {
|
||||
paddingVertical: 18,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexDirection: 'row',
|
||||
borderRadius: 28,
|
||||
},
|
||||
buttonGradient: {
|
||||
paddingVertical: 18,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
buttonBackground: {
|
||||
backgroundColor: Colors.light.accentGreen, // 应用主色调
|
||||
paddingVertical: 18,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexDirection: 'row',
|
||||
},
|
||||
buttonText: {
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
color: '#192126', // 主色调上的文字颜色
|
||||
color: '#FFFFFF',
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
homeIndicator: {
|
||||
width: 134,
|
||||
height: 5,
|
||||
backgroundColor: '#000',
|
||||
backgroundColor: Platform.OS === 'ios' ? 'rgba(0, 0, 0, 0.3)' : '#000',
|
||||
borderRadius: 3,
|
||||
alignSelf: 'center',
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user