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',
|
||||
},
|
||||
|
||||
@@ -15,6 +15,7 @@ export function StressMeter({ curDate }: StressMeterProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [hrvValue, setHrvValue] = useState(0)
|
||||
const [updateTime, setUpdateTime] = useState<Date>(new Date())
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
@@ -32,6 +33,9 @@ export function StressMeter({ curDate }: StressMeterProps) {
|
||||
|
||||
if (result.hrvData) {
|
||||
setHrvValue(Math.round(result.hrvData.value));
|
||||
if (result.hrvData.recordedAt) {
|
||||
setUpdateTime(new Date(result.hrvData.recordedAt));
|
||||
}
|
||||
console.log(`StressMeter: Using ${result.message}, HRV value: ${result.hrvData.value}ms`);
|
||||
} else {
|
||||
console.log('StressMeter: No HRV data obtained');
|
||||
@@ -92,7 +96,7 @@ export function StressMeter({ curDate }: StressMeterProps) {
|
||||
{/* 渐变背景进度条 */}
|
||||
<View style={[styles.progressBar, { width: '100%' }]}>
|
||||
<LinearGradient
|
||||
colors={['#EF4444', '#FCD34D', '#10B981']}
|
||||
colors={['#10B981', '#FCD34D', '#EF4444']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 0 }}
|
||||
style={styles.gradientBar}
|
||||
@@ -110,7 +114,7 @@ export function StressMeter({ curDate }: StressMeterProps) {
|
||||
visible={showStressModal}
|
||||
onClose={() => setShowStressModal(false)}
|
||||
hrvValue={hrvValue}
|
||||
updateTime={new Date()}
|
||||
updateTime={updateTime}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
132
components/health/HealthProgressRing.tsx
Normal file
132
components/health/HealthProgressRing.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { Animated, Easing, StyleSheet, Text, View } from 'react-native';
|
||||
import Svg, { Circle, Defs, LinearGradient, Stop } from 'react-native-svg';
|
||||
|
||||
const AnimatedCircle = Animated.createAnimatedComponent(Circle);
|
||||
|
||||
export type HealthProgressRingProps = {
|
||||
progress: number; // 0-100
|
||||
size?: number;
|
||||
strokeWidth?: number;
|
||||
gradientColors?: string[];
|
||||
label?: string;
|
||||
suffix?: string;
|
||||
title: string;
|
||||
};
|
||||
|
||||
export function HealthProgressRing({
|
||||
progress,
|
||||
size = 80,
|
||||
strokeWidth = 8,
|
||||
gradientColors = ['#5B4CFF', '#9B8AFB'],
|
||||
label,
|
||||
suffix = '%',
|
||||
title,
|
||||
}: HealthProgressRingProps) {
|
||||
const animatedProgress = useRef(new Animated.Value(0)).current;
|
||||
const radius = (size - strokeWidth) / 2;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
const center = size / 2;
|
||||
|
||||
useEffect(() => {
|
||||
Animated.timing(animatedProgress, {
|
||||
toValue: progress,
|
||||
duration: 1000,
|
||||
easing: Easing.out(Easing.cubic),
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
}, [progress]);
|
||||
|
||||
const strokeDashoffset = animatedProgress.interpolate({
|
||||
inputRange: [0, 100],
|
||||
outputRange: [circumference, 0],
|
||||
extrapolate: 'clamp',
|
||||
});
|
||||
|
||||
const gradientId = useRef(`grad-${Math.random().toString(36).substr(2, 9)}`).current;
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={{ width: size, height: size, alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Svg width={size} height={size}>
|
||||
<Defs>
|
||||
<LinearGradient id={gradientId} x1="0" y1="0" x2="1" y2="1">
|
||||
<Stop offset="0" stopColor={gradientColors[0]} stopOpacity="1" />
|
||||
<Stop offset="1" stopColor={gradientColors[1]} stopOpacity="1" />
|
||||
</LinearGradient>
|
||||
</Defs>
|
||||
|
||||
{/* Background Circle */}
|
||||
<Circle
|
||||
cx={center}
|
||||
cy={center}
|
||||
r={radius}
|
||||
stroke="#F3F4F6"
|
||||
strokeWidth={strokeWidth}
|
||||
fill="none"
|
||||
/>
|
||||
|
||||
{/* Progress Circle */}
|
||||
<AnimatedCircle
|
||||
cx={center}
|
||||
cy={center}
|
||||
r={radius}
|
||||
stroke={`url(#${gradientId})`}
|
||||
strokeWidth={strokeWidth}
|
||||
fill="none"
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={strokeDashoffset}
|
||||
strokeLinecap="round"
|
||||
transform={`rotate(-90 ${center} ${center})`}
|
||||
/>
|
||||
</Svg>
|
||||
|
||||
<View style={styles.centerContent}>
|
||||
<View style={styles.valueContainer}>
|
||||
<Text style={styles.valueText}>{label ?? progress}</Text>
|
||||
<Text style={styles.suffixText}>{suffix}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Text style={styles.titleText}>{title}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
centerContent: {
|
||||
position: 'absolute',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
valueContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-end',
|
||||
},
|
||||
valueText: {
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
color: '#1F2937',
|
||||
fontFamily: 'AliBold',
|
||||
lineHeight: 24,
|
||||
},
|
||||
suffixText: {
|
||||
fontSize: 12,
|
||||
color: '#6B7280',
|
||||
fontWeight: '500',
|
||||
marginLeft: 1,
|
||||
marginBottom: 3,
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
titleText: {
|
||||
marginTop: 8,
|
||||
fontSize: 14,
|
||||
color: '#4B5563', // gray-600
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
});
|
||||
161
components/health/tabs/BasicInfoTab.tsx
Normal file
161
components/health/tabs/BasicInfoTab.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
import { ROUTES } from '@/constants/Routes';
|
||||
import { useI18n } from '@/hooks/useI18n';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useRouter } from 'expo-router';
|
||||
import React from 'react';
|
||||
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
|
||||
type BasicInfoTabProps = {
|
||||
healthData: {
|
||||
bmi: string;
|
||||
height: string;
|
||||
weight: string;
|
||||
waist: string;
|
||||
};
|
||||
};
|
||||
|
||||
export function BasicInfoTab({ healthData }: BasicInfoTabProps) {
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
|
||||
const handleHeightWeightPress = () => {
|
||||
router.push(ROUTES.PROFILE_EDIT);
|
||||
};
|
||||
|
||||
const handleWaistPress = () => {
|
||||
router.push('/circumference-detail');
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.card}>
|
||||
<Text style={styles.cardTitle}>{t('health.tabs.healthProfile.basicInfoCard.title')}</Text>
|
||||
<View style={styles.metricsGrid}>
|
||||
{/* BMI - Highlighted */}
|
||||
<View style={styles.metricItemMain}>
|
||||
<Text style={styles.metricLabelMain}>{t('health.tabs.healthProfile.basicInfoCard.bmi')}</Text>
|
||||
<Text style={styles.metricValueMain}>
|
||||
{healthData.bmi === '--' ? t('health.tabs.healthProfile.basicInfoCard.noData') : healthData.bmi}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Height - Clickable */}
|
||||
<TouchableOpacity
|
||||
style={styles.metricItem}
|
||||
onPress={handleHeightWeightPress}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View style={styles.metricHeaderSmall}>
|
||||
<Text style={styles.metricValue}>{healthData.height}</Text>
|
||||
<Ionicons name="chevron-forward" size={12} color="#9CA3AF" />
|
||||
</View>
|
||||
<Text style={styles.metricLabel}>
|
||||
{t('health.tabs.healthProfile.basicInfoCard.height')}/{t('health.tabs.healthProfile.basicInfoCard.heightUnit')}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Weight - Clickable */}
|
||||
<TouchableOpacity
|
||||
style={styles.metricItem}
|
||||
onPress={handleHeightWeightPress}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View style={styles.metricHeaderSmall}>
|
||||
<Text style={styles.metricValue}>{healthData.weight}</Text>
|
||||
<Ionicons name="chevron-forward" size={12} color="#9CA3AF" />
|
||||
</View>
|
||||
<Text style={styles.metricLabel}>
|
||||
{t('health.tabs.healthProfile.basicInfoCard.weight')}/{t('health.tabs.healthProfile.basicInfoCard.weightUnit')}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Waist - Clickable */}
|
||||
<TouchableOpacity
|
||||
style={styles.metricItem}
|
||||
onPress={handleWaistPress}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View style={styles.metricHeaderSmall}>
|
||||
<Text style={styles.metricValue}>{healthData.waist}</Text>
|
||||
<Ionicons name="chevron-forward" size={12} color="#9CA3AF" />
|
||||
</View>
|
||||
<Text style={styles.metricLabel}>
|
||||
{t('health.tabs.healthProfile.basicInfoCard.waist')}/{t('health.tabs.healthProfile.basicInfoCard.waistUnit')}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderRadius: 20,
|
||||
padding: 20,
|
||||
marginBottom: 16,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.03,
|
||||
shadowRadius: 6,
|
||||
elevation: 1,
|
||||
},
|
||||
cardTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold',
|
||||
color: '#1F2937',
|
||||
marginBottom: 16,
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
metricsGrid: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
metricItemMain: {
|
||||
flex: 1.5,
|
||||
backgroundColor: '#F5F3FF',
|
||||
borderRadius: 12,
|
||||
padding: 12,
|
||||
marginRight: 12,
|
||||
alignItems: 'center',
|
||||
},
|
||||
metricHeader: {
|
||||
flexDirection: 'row',
|
||||
gap: 2,
|
||||
marginBottom: 8,
|
||||
},
|
||||
metricLabelMain: {
|
||||
fontSize: 14,
|
||||
color: '#5B4CFF',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 4,
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
metricValueMain: {
|
||||
fontSize: 16,
|
||||
color: '#5B4CFF',
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
metricItem: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
},
|
||||
metricHeaderSmall: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 8,
|
||||
gap: 2,
|
||||
},
|
||||
metricLabel: {
|
||||
fontSize: 11,
|
||||
color: '#6B7280',
|
||||
marginBottom: 4,
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
metricValue: {
|
||||
fontSize: 14,
|
||||
color: '#1F2937',
|
||||
fontWeight: '600',
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
});
|
||||
49
components/health/tabs/CheckupRecordsTab.tsx
Normal file
49
components/health/tabs/CheckupRecordsTab.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import React from 'react';
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
|
||||
export function CheckupRecordsTab() {
|
||||
return (
|
||||
<View style={styles.card}>
|
||||
<View style={styles.emptyState}>
|
||||
<Ionicons name="clipboard-outline" size={48} color="#E5E7EB" />
|
||||
<Text style={styles.emptyText}>暂无体检记录</Text>
|
||||
<Text style={styles.emptySubtext}>记录并追踪您的体检数据变化</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderRadius: 20,
|
||||
padding: 40,
|
||||
marginBottom: 16,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.03,
|
||||
shadowRadius: 6,
|
||||
elevation: 1,
|
||||
minHeight: 200,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
emptyState: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
emptyText: {
|
||||
marginTop: 16,
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: '#374151',
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
emptySubtext: {
|
||||
marginTop: 8,
|
||||
fontSize: 13,
|
||||
color: '#9CA3AF',
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
});
|
||||
785
components/health/tabs/HealthHistoryTab.tsx
Normal file
785
components/health/tabs/HealthHistoryTab.tsx
Normal file
@@ -0,0 +1,785 @@
|
||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||
import { HealthHistoryCategory } from '@/services/healthProfile';
|
||||
import {
|
||||
HistoryItemDetail,
|
||||
fetchHealthHistory,
|
||||
saveHealthHistoryCategory,
|
||||
selectHealthLoading,
|
||||
selectHistoryData,
|
||||
updateHistoryData,
|
||||
} from '@/store/healthSlice';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import dayjs from 'dayjs';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
KeyboardAvoidingView,
|
||||
Modal,
|
||||
Platform,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import DateTimePickerModal from 'react-native-modal-datetime-picker';
|
||||
import { palette } from '../../../constants/Colors';
|
||||
|
||||
// Translation Keys for Recommendations
|
||||
const RECOMMENDATION_KEYS: Record<string, string[]> = {
|
||||
allergy: ['penicillin', 'sulfonamides', 'peanuts', 'seafood', 'pollen', 'dustMites', 'alcohol', 'mango'],
|
||||
disease: ['hypertension', 'diabetes', 'asthma', 'heartDisease', 'gastritis', 'migraine'],
|
||||
surgery: ['appendectomy', 'cesareanSection', 'tonsillectomy', 'fractureRepair', 'none'],
|
||||
familyDisease: ['hypertension', 'diabetes', 'cancer', 'heartDisease', 'stroke', 'alzheimers'],
|
||||
};
|
||||
|
||||
interface HistoryItemProps {
|
||||
title: string;
|
||||
categoryKey: string;
|
||||
data: {
|
||||
hasHistory: boolean | null;
|
||||
items: HistoryItemDetail[];
|
||||
};
|
||||
onPress?: () => void;
|
||||
}
|
||||
|
||||
function HistoryItem({ title, categoryKey, data, onPress }: HistoryItemProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const translateItemName = (name: string) => {
|
||||
const keys = RECOMMENDATION_KEYS[categoryKey];
|
||||
if (keys && keys.includes(name)) {
|
||||
return t(`health.tabs.healthProfile.history.recommendationItems.${categoryKey}.${name}`);
|
||||
}
|
||||
return name;
|
||||
};
|
||||
|
||||
const hasItems = data.hasHistory === true && data.items.length > 0;
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[styles.itemContainer, hasItems && styles.itemContainerWithList]}
|
||||
onPress={onPress}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
{/* Header Row */}
|
||||
<View style={styles.itemHeader}>
|
||||
<View style={styles.itemLeft}>
|
||||
<LinearGradient
|
||||
colors={[palette.purple[400], palette.purple[600]]}
|
||||
style={styles.indicator}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 0, y: 1 }}
|
||||
/>
|
||||
<Text style={styles.itemTitle}>{title}</Text>
|
||||
</View>
|
||||
|
||||
{!hasItems && (
|
||||
<Text style={[
|
||||
styles.itemStatus,
|
||||
(data.hasHistory === true && data.items.length === 0) || data.hasHistory === false ? styles.itemStatusActive : null
|
||||
]}>
|
||||
{data.hasHistory === null
|
||||
? t('health.tabs.healthProfile.history.pending')
|
||||
: data.hasHistory === false
|
||||
? t('health.tabs.healthProfile.history.modal.none')
|
||||
: t('health.tabs.healthProfile.history.modal.yesNoDetails')}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* List of Items */}
|
||||
{hasItems && (
|
||||
<View style={styles.subListContainer}>
|
||||
{data.items.map(item => (
|
||||
<View key={item.id} style={styles.subItemRow}>
|
||||
<View style={styles.subItemDot} />
|
||||
<Text style={styles.subItemName}>{translateItemName(item.name)}</Text>
|
||||
{item.date && (
|
||||
<Text style={styles.subItemDate}>
|
||||
{dayjs(item.date).format('YYYY-MM-DD')}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
export function HealthHistoryTab() {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
// 从 Redux store 获取健康史数据和加载状态
|
||||
const historyData = useAppSelector(selectHistoryData);
|
||||
const isLoading = useAppSelector(selectHealthLoading);
|
||||
|
||||
// Modal State
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [currentType, setCurrentType] = useState<string | null>(null);
|
||||
const [tempHasHistory, setTempHasHistory] = useState<boolean | null>(null);
|
||||
const [tempItems, setTempItems] = useState<HistoryItemDetail[]>([]);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
// Date Picker State
|
||||
const [isDatePickerVisible, setDatePickerVisibility] = useState(false);
|
||||
const [currentEditingId, setCurrentEditingId] = useState<string | null>(null);
|
||||
|
||||
// 初始化时从服务端获取健康史数据
|
||||
useEffect(() => {
|
||||
dispatch(fetchHealthHistory());
|
||||
}, [dispatch]);
|
||||
|
||||
const historyItems = [
|
||||
{ title: t('health.tabs.healthProfile.history.allergy'), key: 'allergy' },
|
||||
{ title: t('health.tabs.healthProfile.history.disease'), key: 'disease' },
|
||||
{ title: t('health.tabs.healthProfile.history.surgery'), key: 'surgery' },
|
||||
{ title: t('health.tabs.healthProfile.history.familyDisease'), key: 'familyDisease' },
|
||||
];
|
||||
|
||||
// Helper to translate item (try to find key, fallback to item itself)
|
||||
const translateItem = (type: string, item: string) => {
|
||||
// Check if item is a predefined key
|
||||
const keys = RECOMMENDATION_KEYS[type];
|
||||
if (keys && keys.includes(item)) {
|
||||
return t(`health.tabs.healthProfile.history.recommendationItems.${type}.${item}`);
|
||||
}
|
||||
// Fallback for manual input
|
||||
return item;
|
||||
};
|
||||
|
||||
// Open Modal
|
||||
const handleItemPress = (key: string) => {
|
||||
setCurrentType(key);
|
||||
const currentData = historyData[key];
|
||||
setTempHasHistory(currentData.hasHistory);
|
||||
// Deep copy items to avoid reference issues
|
||||
setTempItems(currentData.items.map(item => ({ ...item })));
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
// Close Modal
|
||||
const handleCloseModal = () => {
|
||||
setModalVisible(false);
|
||||
setCurrentType(null);
|
||||
};
|
||||
|
||||
// Save Data
|
||||
const handleSave = async () => {
|
||||
if (currentType) {
|
||||
// Filter out empty items
|
||||
const validItems = tempItems.filter(item => item.name.trim() !== '');
|
||||
|
||||
// If "No" history is selected, clear items
|
||||
const finalItems = tempHasHistory === false ? [] : validItems;
|
||||
|
||||
setIsSaving(true);
|
||||
|
||||
try {
|
||||
// 先乐观更新本地状态
|
||||
dispatch(updateHistoryData({
|
||||
type: currentType,
|
||||
data: {
|
||||
hasHistory: tempHasHistory,
|
||||
items: finalItems,
|
||||
},
|
||||
}));
|
||||
|
||||
// 同步到服务端
|
||||
await dispatch(saveHealthHistoryCategory({
|
||||
category: currentType as HealthHistoryCategory,
|
||||
data: {
|
||||
hasHistory: tempHasHistory ?? false,
|
||||
items: finalItems.map(item => ({
|
||||
name: item.name,
|
||||
date: item.date ? dayjs(item.date).format('YYYY-MM-DD') : undefined,
|
||||
isRecommendation: item.isRecommendation,
|
||||
})),
|
||||
},
|
||||
})).unwrap();
|
||||
|
||||
handleCloseModal();
|
||||
} catch (error: any) {
|
||||
// 如果保存失败,显示错误提示(本地数据已更新,下次打开会从服务端同步)
|
||||
Alert.alert(
|
||||
t('health.tabs.healthProfile.history.modal.saveError') || '保存失败',
|
||||
error?.message || '请稍后重试',
|
||||
[{ text: t('health.tabs.healthProfile.history.modal.ok') || '确定' }]
|
||||
);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Add Item (Manual or Recommendation)
|
||||
const addItem = (name: string = '', isRecommendation: boolean = false) => {
|
||||
// Avoid duplicates for recommendations if already exists
|
||||
if (isRecommendation && tempItems.some(item => item.name === name)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newItem: HistoryItemDetail = {
|
||||
id: Date.now().toString() + Math.random().toString(),
|
||||
name,
|
||||
isRecommendation
|
||||
};
|
||||
setTempItems([...tempItems, newItem]);
|
||||
};
|
||||
|
||||
// Remove Item
|
||||
const removeItem = (id: string) => {
|
||||
setTempItems(tempItems.filter(item => item.id !== id));
|
||||
};
|
||||
|
||||
// Update Item Name
|
||||
const updateItemName = (id: string, text: string) => {
|
||||
setTempItems(tempItems.map(item =>
|
||||
item.id === id ? { ...item, name: text } : item
|
||||
));
|
||||
};
|
||||
|
||||
// Date Picker Handlers
|
||||
const showDatePicker = (id: string) => {
|
||||
setCurrentEditingId(id);
|
||||
setDatePickerVisibility(true);
|
||||
};
|
||||
|
||||
const hideDatePicker = () => {
|
||||
setDatePickerVisibility(false);
|
||||
setCurrentEditingId(null);
|
||||
};
|
||||
|
||||
const handleConfirmDate = (date: Date) => {
|
||||
if (currentEditingId) {
|
||||
setTempItems(tempItems.map(item =>
|
||||
item.id === currentEditingId ? { ...item, date: date.toISOString() } : item
|
||||
));
|
||||
}
|
||||
hideDatePicker();
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{/* Glow effect background */}
|
||||
<View style={styles.glowContainer}>
|
||||
<View style={styles.glow} />
|
||||
</View>
|
||||
|
||||
<View style={styles.card}>
|
||||
{/* Header */}
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.headerTitle}>{t('health.tabs.healthProfile.healthHistory')}</Text>
|
||||
{isLoading && <ActivityIndicator size="small" color={palette.purple[500]} />}
|
||||
</View>
|
||||
|
||||
{/* List */}
|
||||
<View style={styles.list}>
|
||||
{historyItems.map((item) => (
|
||||
<HistoryItem
|
||||
key={item.key}
|
||||
title={item.title}
|
||||
categoryKey={item.key}
|
||||
data={historyData[item.key]}
|
||||
onPress={() => handleItemPress(item.key)}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Edit Modal */}
|
||||
<Modal
|
||||
animationType="fade"
|
||||
transparent={true}
|
||||
visible={modalVisible}
|
||||
onRequestClose={handleCloseModal}
|
||||
>
|
||||
<KeyboardAvoidingView
|
||||
style={styles.modalOverlay}
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
>
|
||||
<View style={styles.modalContent}>
|
||||
{/* Modal Header */}
|
||||
<View style={styles.modalHeader}>
|
||||
<Text style={styles.modalTitle}>
|
||||
{currentType ? t(`health.tabs.healthProfile.history.${currentType}`) : ''}
|
||||
</Text>
|
||||
<TouchableOpacity onPress={handleCloseModal} style={styles.closeButton}>
|
||||
<Ionicons name="close" size={24} color={palette.gray[400]} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<ScrollView showsVerticalScrollIndicator={false}>
|
||||
{/* Question: Do you have history? */}
|
||||
<Text style={styles.questionText}>
|
||||
{t('health.tabs.healthProfile.history.modal.question', {
|
||||
type: currentType ? t(`health.tabs.healthProfile.history.${currentType}`) : ''
|
||||
})}
|
||||
</Text>
|
||||
|
||||
<View style={styles.radioGroup}>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.radioButton,
|
||||
tempHasHistory === true && styles.radioButtonActive
|
||||
]}
|
||||
onPress={() => setTempHasHistory(true)}
|
||||
>
|
||||
<Text style={[
|
||||
styles.radioText,
|
||||
tempHasHistory === true && styles.radioTextActive
|
||||
]}>{t('health.tabs.healthProfile.history.modal.yes')}</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.radioButton,
|
||||
tempHasHistory === false && styles.radioButtonActive
|
||||
]}
|
||||
onPress={() => setTempHasHistory(false)}
|
||||
>
|
||||
<Text style={[
|
||||
styles.radioText,
|
||||
tempHasHistory === false && styles.radioTextActive
|
||||
]}>{t('health.tabs.healthProfile.history.modal.no')}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Conditional Content */}
|
||||
{tempHasHistory === true && currentType && (
|
||||
<View style={styles.detailsContainer}>
|
||||
|
||||
{/* Recommendations */}
|
||||
{RECOMMENDATION_KEYS[currentType] && (
|
||||
<View style={styles.recommendationContainer}>
|
||||
<Text style={styles.sectionLabel}>{t('health.tabs.healthProfile.history.modal.recommendations')}</Text>
|
||||
<View style={styles.tagsContainer}>
|
||||
{RECOMMENDATION_KEYS[currentType].map((tagKey, index) => (
|
||||
<TouchableOpacity
|
||||
key={index}
|
||||
style={styles.tag}
|
||||
onPress={() => addItem(tagKey, true)}
|
||||
>
|
||||
<Text style={styles.tagText}>
|
||||
{t(`health.tabs.healthProfile.history.recommendationItems.${currentType}.${tagKey}`)}
|
||||
</Text>
|
||||
<Ionicons name="add" size={16} color={palette.gray[600]} style={{ marginLeft: 4 }} />
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* History List Items */}
|
||||
<View style={styles.listContainer}>
|
||||
<Text style={styles.sectionLabel}>{t('health.tabs.healthProfile.history.modal.addDetails')}</Text>
|
||||
|
||||
{tempItems.map((item) => (
|
||||
<View key={item.id} style={styles.listItemCard}>
|
||||
<View style={styles.listItemHeader}>
|
||||
<TextInput
|
||||
style={styles.listItemNameInput}
|
||||
placeholder={t('health.tabs.healthProfile.history.modal.namePlaceholder')}
|
||||
placeholderTextColor={palette.gray[300]}
|
||||
value={item.isRecommendation ? translateItem(currentType!, item.name) : item.name}
|
||||
onChangeText={(text) => updateItemName(item.id, text)}
|
||||
editable={!item.isRecommendation}
|
||||
/>
|
||||
<TouchableOpacity onPress={() => removeItem(item.id)} style={styles.deleteButton}>
|
||||
<Ionicons name="trash-outline" size={20} color={palette.error[500]} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.datePickerTrigger}
|
||||
onPress={() => showDatePicker(item.id)}
|
||||
>
|
||||
<Ionicons name="calendar-outline" size={18} color={palette.purple[500]} />
|
||||
<Text style={[
|
||||
styles.dateText,
|
||||
!item.date && styles.placeholderText
|
||||
]}>
|
||||
{item.date
|
||||
? dayjs(item.date).format('YYYY-MM-DD')
|
||||
: t('health.tabs.healthProfile.history.modal.selectDate')}
|
||||
</Text>
|
||||
<Ionicons name="chevron-down" size={14} color={palette.gray[400]} style={{ marginLeft: 'auto' }} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
))}
|
||||
|
||||
{/* Add Button */}
|
||||
<TouchableOpacity style={styles.addItemButton} onPress={() => addItem()}>
|
||||
<Ionicons name="add-circle" size={20} color={palette.purple[500]} />
|
||||
<Text style={styles.addItemText}>{t('health.tabs.healthProfile.history.modal.addItem')}</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
</View>
|
||||
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
|
||||
{/* Save Button */}
|
||||
<View style={styles.modalFooter}>
|
||||
<TouchableOpacity
|
||||
style={[styles.saveButton, isSaving && styles.saveButtonDisabled]}
|
||||
onPress={handleSave}
|
||||
disabled={isSaving}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={isSaving ? [palette.gray[300], palette.gray[400]] : [palette.purple[500], palette.purple[700]]}
|
||||
style={styles.saveButtonGradient}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 0 }}
|
||||
>
|
||||
{isSaving ? (
|
||||
<ActivityIndicator size="small" color="#FFFFFF" />
|
||||
) : (
|
||||
<Text style={styles.saveButtonText}>{t('health.tabs.healthProfile.history.modal.save')}</Text>
|
||||
)}
|
||||
</LinearGradient>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
</View>
|
||||
|
||||
<DateTimePickerModal
|
||||
isVisible={isDatePickerVisible}
|
||||
mode="date"
|
||||
onConfirm={handleConfirmDate}
|
||||
onCancel={hideDatePicker}
|
||||
maximumDate={new Date()} // Cannot select future date for history
|
||||
confirmTextIOS={t('health.tabs.healthProfile.history.modal.save')} // Reuse save
|
||||
cancelTextIOS={t('health.tabs.healthProfile.history.modal.none') === 'None' ? 'Cancel' : '取消'} // Fallback
|
||||
/>
|
||||
</KeyboardAvoidingView>
|
||||
</Modal>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
marginBottom: 16,
|
||||
position: 'relative',
|
||||
},
|
||||
glowContainer: {
|
||||
position: 'absolute',
|
||||
top: 20,
|
||||
left: 20,
|
||||
right: 20,
|
||||
bottom: 20,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: -1,
|
||||
},
|
||||
glow: {
|
||||
width: '90%',
|
||||
height: '90%',
|
||||
backgroundColor: palette.purple[200],
|
||||
opacity: 0.3,
|
||||
borderRadius: 40,
|
||||
transform: [{ scale: 1.05 }],
|
||||
shadowColor: palette.purple[500],
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: 0.4,
|
||||
shadowRadius: 20,
|
||||
},
|
||||
card: {
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderRadius: 24,
|
||||
padding: 20,
|
||||
shadowColor: palette.purple[100],
|
||||
shadowOffset: { width: 0, height: 8 },
|
||||
shadowOpacity: 0.6,
|
||||
shadowRadius: 24,
|
||||
elevation: 4,
|
||||
borderWidth: 1,
|
||||
borderColor: '#F5F3FF',
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 12,
|
||||
paddingHorizontal: 4,
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: 18,
|
||||
fontFamily: 'AliBold',
|
||||
color: palette.gray[900],
|
||||
fontWeight: '600',
|
||||
},
|
||||
list: {
|
||||
backgroundColor: '#FFFFFF',
|
||||
},
|
||||
itemContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingVertical: 16,
|
||||
paddingHorizontal: 4,
|
||||
},
|
||||
itemContainerWithList: {
|
||||
flexDirection: 'column',
|
||||
alignItems: 'stretch',
|
||||
justifyContent: 'flex-start',
|
||||
},
|
||||
itemHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
width: '100%',
|
||||
},
|
||||
itemLeft: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
indicator: {
|
||||
width: 4,
|
||||
height: 14,
|
||||
borderRadius: 2,
|
||||
marginRight: 12,
|
||||
},
|
||||
itemTitle: {
|
||||
fontSize: 16,
|
||||
color: palette.gray[700],
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
itemStatus: {
|
||||
fontSize: 14,
|
||||
color: palette.gray[300],
|
||||
fontFamily: 'AliRegular',
|
||||
textAlign: 'right',
|
||||
maxWidth: 150,
|
||||
},
|
||||
itemStatusActive: {
|
||||
color: palette.purple[600],
|
||||
fontWeight: '500',
|
||||
},
|
||||
subListContainer: {
|
||||
marginTop: 12,
|
||||
paddingLeft: 16, // Align with title (4px indicator + 12px margin)
|
||||
},
|
||||
subItemRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingVertical: 6,
|
||||
},
|
||||
subItemDot: {
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: 3,
|
||||
backgroundColor: palette.purple[300],
|
||||
marginRight: 8,
|
||||
},
|
||||
subItemName: {
|
||||
flex: 1,
|
||||
fontSize: 15,
|
||||
color: palette.gray[800],
|
||||
fontFamily: 'AliRegular',
|
||||
fontWeight: '500',
|
||||
},
|
||||
subItemDate: {
|
||||
fontSize: 13,
|
||||
color: palette.gray[400],
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
// Modal Styles
|
||||
modalOverlay: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: 20,
|
||||
},
|
||||
modalContent: {
|
||||
width: '100%',
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderRadius: 24,
|
||||
padding: 24,
|
||||
maxHeight: '85%', // Increased height
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 10 },
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 20,
|
||||
elevation: 10,
|
||||
},
|
||||
modalHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 20,
|
||||
},
|
||||
modalTitle: {
|
||||
fontSize: 20,
|
||||
fontFamily: 'AliBold',
|
||||
color: palette.gray[900],
|
||||
fontWeight: '600',
|
||||
},
|
||||
closeButton: {
|
||||
padding: 4,
|
||||
},
|
||||
questionText: {
|
||||
fontSize: 16,
|
||||
color: palette.gray[700],
|
||||
marginBottom: 12,
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
radioGroup: {
|
||||
flexDirection: 'row',
|
||||
marginBottom: 24,
|
||||
},
|
||||
radioButton: {
|
||||
flex: 1,
|
||||
paddingVertical: 12,
|
||||
borderWidth: 1,
|
||||
borderColor: palette.gray[200],
|
||||
borderRadius: 12,
|
||||
alignItems: 'center',
|
||||
marginRight: 8,
|
||||
},
|
||||
radioButtonActive: {
|
||||
backgroundColor: palette.purple[50],
|
||||
borderColor: palette.purple[500],
|
||||
},
|
||||
radioText: {
|
||||
fontSize: 16,
|
||||
color: palette.gray[600],
|
||||
fontWeight: '500',
|
||||
},
|
||||
radioTextActive: {
|
||||
color: palette.purple[600],
|
||||
fontWeight: '600',
|
||||
},
|
||||
detailsContainer: {
|
||||
marginTop: 4,
|
||||
},
|
||||
sectionLabel: {
|
||||
fontSize: 14,
|
||||
color: palette.gray[500],
|
||||
marginBottom: 12,
|
||||
marginTop: 8,
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
recommendationContainer: {
|
||||
marginBottom: 20,
|
||||
},
|
||||
tagsContainer: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: 10,
|
||||
},
|
||||
tag: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 8,
|
||||
borderRadius: 20,
|
||||
backgroundColor: '#F5F7FA',
|
||||
},
|
||||
tagText: {
|
||||
fontSize: 14,
|
||||
color: palette.gray[600],
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
listContainer: {
|
||||
marginTop: 8,
|
||||
},
|
||||
listItemCard: {
|
||||
backgroundColor: '#F9FAFB',
|
||||
borderRadius: 16,
|
||||
padding: 16,
|
||||
marginBottom: 12,
|
||||
borderWidth: 1,
|
||||
borderColor: palette.gray[100],
|
||||
},
|
||||
listItemHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 12,
|
||||
},
|
||||
listItemNameInput: {
|
||||
flex: 1,
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: palette.gray[900],
|
||||
fontFamily: 'AliBold',
|
||||
padding: 0,
|
||||
},
|
||||
deleteButton: {
|
||||
padding: 4,
|
||||
},
|
||||
datePickerTrigger: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#FFFFFF',
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 10,
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
borderColor: palette.gray[200],
|
||||
},
|
||||
dateText: {
|
||||
marginLeft: 8,
|
||||
fontSize: 14,
|
||||
color: palette.gray[900],
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
placeholderText: {
|
||||
color: palette.gray[400],
|
||||
},
|
||||
addItemButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: 12,
|
||||
borderWidth: 1,
|
||||
borderColor: palette.purple[200],
|
||||
borderRadius: 12,
|
||||
borderStyle: 'dashed',
|
||||
backgroundColor: palette.purple[25],
|
||||
marginTop: 4,
|
||||
marginBottom: 20,
|
||||
},
|
||||
addItemText: {
|
||||
marginLeft: 8,
|
||||
fontSize: 14,
|
||||
color: palette.purple[600],
|
||||
fontWeight: '500',
|
||||
},
|
||||
modalFooter: {
|
||||
marginTop: 8,
|
||||
},
|
||||
saveButton: {
|
||||
borderRadius: 16,
|
||||
overflow: 'hidden',
|
||||
shadowColor: palette.purple[500],
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 8,
|
||||
elevation: 4,
|
||||
},
|
||||
saveButtonDisabled: {
|
||||
shadowOpacity: 0,
|
||||
},
|
||||
saveButtonGradient: {
|
||||
paddingVertical: 14,
|
||||
alignItems: 'center',
|
||||
},
|
||||
saveButtonText: {
|
||||
fontSize: 16,
|
||||
color: '#FFFFFF',
|
||||
fontWeight: '600',
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
});
|
||||
49
components/health/tabs/MedicalRecordsTab.tsx
Normal file
49
components/health/tabs/MedicalRecordsTab.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import React from 'react';
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
|
||||
export function MedicalRecordsTab() {
|
||||
return (
|
||||
<View style={styles.card}>
|
||||
<View style={styles.emptyState}>
|
||||
<Ionicons name="folder-open-outline" size={48} color="#E5E7EB" />
|
||||
<Text style={styles.emptyText}>暂无就医资料</Text>
|
||||
<Text style={styles.emptySubtext}>上传您的病历、处方单等资料</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderRadius: 20,
|
||||
padding: 40,
|
||||
marginBottom: 16,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.03,
|
||||
shadowRadius: 6,
|
||||
elevation: 1,
|
||||
minHeight: 200,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
emptyState: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
emptyText: {
|
||||
marginTop: 16,
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: '#374151',
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
emptySubtext: {
|
||||
marginTop: 8,
|
||||
fontSize: 13,
|
||||
color: '#9CA3AF',
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import { Ionicons } from '@expo/vector-icons';
|
||||
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
|
||||
import { Image } from 'expo-image';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { useRouter } from 'expo-router';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
@@ -20,18 +21,26 @@ import {
|
||||
TouchableOpacity,
|
||||
View
|
||||
} from 'react-native';
|
||||
import Svg, { Circle, Path } from 'react-native-svg';
|
||||
import Svg, { Circle, Defs, Path, Stop, LinearGradient as SvgLinearGradient } from 'react-native-svg';
|
||||
import { WeightProgressBar } from './WeightProgressBar';
|
||||
|
||||
const { width: screenWidth } = Dimensions.get('window');
|
||||
const CARD_WIDTH = screenWidth - 40; // Subtract left and right margins
|
||||
const CHART_WIDTH = CARD_WIDTH - 36; // Subtract card padding
|
||||
const CHART_HEIGHT = 60;
|
||||
const CARD_WIDTH = screenWidth - 40;
|
||||
const CHART_WIDTH = CARD_WIDTH - 36;
|
||||
const CHART_HEIGHT = 70;
|
||||
const PADDING = 10;
|
||||
|
||||
// 主题色
|
||||
const THEME_PRIMARY = '#4F5BD5';
|
||||
const THEME_SECONDARY = '#6B6CFF';
|
||||
const THEME_SUCCESS = '#22C55E';
|
||||
const THEME_TEXT_PRIMARY = '#1c1f3a';
|
||||
const THEME_TEXT_SECONDARY = '#6f7ba7';
|
||||
|
||||
export function WeightHistoryCard() {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useAppDispatch();
|
||||
const router = useRouter();
|
||||
const userProfile = useAppSelector((s) => s.user.profile);
|
||||
const weightHistory = useAppSelector((s) => s.user.weightHistory);
|
||||
|
||||
@@ -44,7 +53,6 @@ export function WeightHistoryCard() {
|
||||
|
||||
const hasWeight = userProfile?.weight && parseFloat(userProfile.weight) > 0;
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoggedIn) {
|
||||
loadWeightHistory();
|
||||
@@ -59,7 +67,8 @@ export function WeightHistoryCard() {
|
||||
}
|
||||
};
|
||||
|
||||
const navigateToCoach = () => {
|
||||
// 点击添加按钮 - 需要登录
|
||||
const handleAddWeight = () => {
|
||||
pushIfAuthedElseLogin(ROUTES.WEIGHT_RECORDS);
|
||||
};
|
||||
|
||||
@@ -67,85 +76,97 @@ export function WeightHistoryCard() {
|
||||
setShowBMIModal(false);
|
||||
};
|
||||
|
||||
// 点击卡片 - 直接跳转,不需要登录
|
||||
const navigateToWeightRecords = () => {
|
||||
pushIfAuthedElseLogin(ROUTES.WEIGHT_RECORDS);
|
||||
router.push(ROUTES.WEIGHT_RECORDS);
|
||||
};
|
||||
|
||||
|
||||
// Process weight history data
|
||||
const sortedHistory = [...weightHistory]
|
||||
.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime())
|
||||
.slice(-7); // Show only the last 7 records
|
||||
.slice(-7);
|
||||
|
||||
// return (
|
||||
// <TouchableOpacity style={styles.card} onPress={navigateToWeightRecords} activeOpacity={0.8}>
|
||||
// <View style={styles.cardHeader}>
|
||||
// <Text style={styles.cardTitle}>{t('statistics.components.weight.title')}</Text>
|
||||
// </View>
|
||||
// 是否有数据
|
||||
const hasData = sortedHistory.length > 0;
|
||||
|
||||
// <View style={styles.emptyContent}>
|
||||
// <Text style={styles.emptyDescription}>
|
||||
// No weight records yet, click the button below to start recording
|
||||
// </Text>
|
||||
// <TouchableOpacity
|
||||
// style={styles.recordButton}
|
||||
// onPress={(e) => {
|
||||
// e.stopPropagation();
|
||||
// navigateToCoach();
|
||||
// }}
|
||||
// activeOpacity={0.8}
|
||||
// >
|
||||
// <Ionicons name="add" size={18} color="#FFFFFF" />
|
||||
// <Text style={styles.recordButtonText}>{t('statistics.components.weight.addButton')}</Text>
|
||||
// </TouchableOpacity>
|
||||
// </View>
|
||||
// </TouchableOpacity>
|
||||
// );
|
||||
// }
|
||||
// 计算减重进度
|
||||
const currentWeight = userProfile?.weight ? parseFloat(userProfile.weight) : 0;
|
||||
const initialWeight = userProfile?.initialWeight
|
||||
? parseFloat(userProfile.initialWeight)
|
||||
: (sortedHistory.length > 0 ? parseFloat(sortedHistory[0].weight) : 0);
|
||||
const targetWeight = userProfile?.targetWeight ? parseFloat(userProfile.targetWeight) : 0;
|
||||
|
||||
// 计算进度百分比
|
||||
const hasTargetWeight = targetWeight > 0 && initialWeight > targetWeight;
|
||||
const totalToLose = initialWeight - targetWeight;
|
||||
const actualLost = initialWeight - currentWeight;
|
||||
const weightProgress = hasTargetWeight && totalToLose > 0 ? actualLost / totalToLose : 0;
|
||||
|
||||
// Generate chart data
|
||||
const weights = sortedHistory.map(item => parseFloat(item.weight));
|
||||
const minWeight = Math.min(...weights);
|
||||
const maxWeight = Math.max(...weights);
|
||||
const weights = hasData ? sortedHistory.map(item => parseFloat(item.weight)) : [];
|
||||
const minWeight = hasData ? Math.min(...weights) : 0;
|
||||
const maxWeight = hasData ? Math.max(...weights) : 0;
|
||||
const weightRange = maxWeight - minWeight || 1;
|
||||
|
||||
const points = sortedHistory.map((item, index) => {
|
||||
const points = hasData ? sortedHistory.map((item, index) => {
|
||||
const x = PADDING + (index / Math.max(sortedHistory.length - 1, 1)) * (CHART_WIDTH - 2 * PADDING);
|
||||
const normalizedWeight = (parseFloat(item.weight) - minWeight) / weightRange;
|
||||
// Reduce top margin, compress whitespace
|
||||
const y = PADDING + 8 + (1 - normalizedWeight) * (CHART_HEIGHT - 2 * PADDING - 16);
|
||||
const y = PADDING + 10 + (1 - normalizedWeight) * (CHART_HEIGHT - 2 * PADDING - 20);
|
||||
return { x, y, weight: item.weight, date: item.createdAt };
|
||||
});
|
||||
}) : [];
|
||||
|
||||
// Generate path
|
||||
const pathData = points.map((point, index) => {
|
||||
if (index === 0) return `M ${point.x} ${point.y}`;
|
||||
return `L ${point.x} ${point.y}`;
|
||||
}).join(' ');
|
||||
// 生成平滑曲线路径(使用贝塞尔曲线)
|
||||
const generateSmoothPath = (pts: typeof points) => {
|
||||
if (pts.length === 0) return '';
|
||||
if (pts.length === 1) return `M ${pts[0].x} ${pts[0].y}`;
|
||||
|
||||
// If there's only one data point, display as a horizontal line
|
||||
const singlePointPath = points.length === 1 ?
|
||||
`M ${PADDING} ${points[0].y} L ${CHART_WIDTH - PADDING} ${points[0].y}` :
|
||||
pathData;
|
||||
let path = `M ${pts[0].x} ${pts[0].y}`;
|
||||
|
||||
for (let i = 0; i < pts.length - 1; i++) {
|
||||
const p0 = pts[Math.max(0, i - 1)];
|
||||
const p1 = pts[i];
|
||||
const p2 = pts[i + 1];
|
||||
const p3 = pts[Math.min(pts.length - 1, i + 2)];
|
||||
|
||||
const cp1x = p1.x + (p2.x - p0.x) / 6;
|
||||
const cp1y = p1.y + (p2.y - p0.y) / 6;
|
||||
const cp2x = p2.x - (p3.x - p1.x) / 6;
|
||||
const cp2y = p2.y - (p3.y - p1.y) / 6;
|
||||
|
||||
path += ` C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${p2.x} ${p2.y}`;
|
||||
}
|
||||
|
||||
return path;
|
||||
};
|
||||
|
||||
const smoothPath = generateSmoothPath(points);
|
||||
const singlePointPath = points.length === 1
|
||||
? `M ${PADDING} ${points[0].y} L ${CHART_WIDTH - PADDING} ${points[0].y}`
|
||||
: smoothPath;
|
||||
|
||||
// 空状态下的占位曲线路径(水平虚线效果)
|
||||
const emptyLinePath = `M ${PADDING} ${CHART_HEIGHT / 2} L ${CHART_WIDTH - PADDING} ${CHART_HEIGHT / 2}`;
|
||||
|
||||
return (
|
||||
<TouchableOpacity style={styles.card} onPress={navigateToWeightRecords} activeOpacity={0.8}>
|
||||
<View style={styles.cardHeader}>
|
||||
<Image
|
||||
source={require('@/assets/images/icons/icon-weight.png')}
|
||||
style={styles.iconSquare}
|
||||
/>
|
||||
<View style={styles.iconContainer}>
|
||||
<Image
|
||||
source={require('@/assets/images/icons/icon-weight.png')}
|
||||
style={styles.iconSquare}
|
||||
/>
|
||||
</View>
|
||||
<Text style={styles.cardTitle}>{t('statistics.components.weight.title')}</Text>
|
||||
{isLgAvaliable ? (
|
||||
<TouchableOpacity
|
||||
onPress={(e) => {
|
||||
e.stopPropagation();
|
||||
navigateToCoach();
|
||||
handleAddWeight();
|
||||
}}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<GlassView style={styles.addButtonGlass}>
|
||||
<Ionicons name="add" size={18} color={Colors.light.primary} />
|
||||
<Ionicons name="add" size={18} color={THEME_PRIMARY} />
|
||||
</GlassView>
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
@@ -153,68 +174,125 @@ export function WeightHistoryCard() {
|
||||
style={styles.addButton}
|
||||
onPress={(e) => {
|
||||
e.stopPropagation();
|
||||
navigateToCoach();
|
||||
handleAddWeight();
|
||||
}}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Ionicons name="add" size={18} color={Colors.light.primary} />
|
||||
<Ionicons name="add" size={18} color={THEME_PRIMARY} />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Default chart display */}
|
||||
{sortedHistory.length > 0 && (
|
||||
<View style={styles.chartContainer}>
|
||||
<Svg width={CHART_WIDTH} height={CHART_HEIGHT + 15}>
|
||||
{/* Background grid lines */}
|
||||
{/* 当前体重显示 */}
|
||||
<View style={styles.currentWeightSection}>
|
||||
<View style={styles.weightValueContainer}>
|
||||
<Text style={styles.weightValue}>{hasWeight ? currentWeight.toFixed(1) : '--'}</Text>
|
||||
<Text style={styles.weightUnit}>kg</Text>
|
||||
</View>
|
||||
{sortedHistory.length > 1 && (
|
||||
<View style={[
|
||||
styles.changeTag,
|
||||
{ backgroundColor: actualLost >= 0 ? 'rgba(34, 197, 94, 0.1)' : 'rgba(255, 107, 107, 0.1)' }
|
||||
]}>
|
||||
<Ionicons
|
||||
name={actualLost >= 0 ? 'trending-down' : 'trending-up'}
|
||||
size={12}
|
||||
color={actualLost >= 0 ? THEME_SUCCESS : '#FF6B6B'}
|
||||
/>
|
||||
<Text style={[
|
||||
styles.changeText,
|
||||
{ color: actualLost >= 0 ? THEME_SUCCESS : '#FF6B6B' }
|
||||
]}>
|
||||
{actualLost >= 0 ? '-' : '+'}{Math.abs(actualLost).toFixed(1)}kg
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* More abstract line - reduce line width and display details */}
|
||||
{/* 图表显示 */}
|
||||
<View style={styles.chartContainer}>
|
||||
<Svg width={CHART_WIDTH} height={CHART_HEIGHT + 15}>
|
||||
<Defs>
|
||||
<SvgLinearGradient id="lineGradient" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<Stop offset="0%" stopColor={THEME_PRIMARY} stopOpacity="1" />
|
||||
<Stop offset="100%" stopColor={THEME_SECONDARY} stopOpacity="1" />
|
||||
</SvgLinearGradient>
|
||||
</Defs>
|
||||
|
||||
{hasData ? (
|
||||
<>
|
||||
{/* 平滑曲线 */}
|
||||
<Path
|
||||
d={singlePointPath}
|
||||
stroke="url(#lineGradient)"
|
||||
strokeWidth={2.5}
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
|
||||
{/* 数据点 */}
|
||||
{points.map((point, index) => {
|
||||
const isLastPoint = index === points.length - 1;
|
||||
|
||||
return (
|
||||
<React.Fragment key={index}>
|
||||
{/* 外圈光晕 */}
|
||||
{isLastPoint && (
|
||||
<Circle
|
||||
cx={point.x}
|
||||
cy={point.y}
|
||||
r={8}
|
||||
fill={THEME_PRIMARY}
|
||||
opacity={0.15}
|
||||
/>
|
||||
)}
|
||||
{/* 数据点 */}
|
||||
<Circle
|
||||
cx={point.x}
|
||||
cy={point.y}
|
||||
r={isLastPoint ? 4 : 2.5}
|
||||
fill={isLastPoint ? THEME_PRIMARY : THEME_SECONDARY}
|
||||
stroke={isLastPoint ? '#ffffff' : 'none'}
|
||||
strokeWidth={isLastPoint ? 2 : 0}
|
||||
/>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
) : (
|
||||
/* 空状态 - 虚线占位 */
|
||||
<Path
|
||||
d={singlePointPath}
|
||||
stroke={Colors.light.accentGreen}
|
||||
d={emptyLinePath}
|
||||
stroke="#E8EAF0"
|
||||
strokeWidth={2}
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
opacity={0.8}
|
||||
strokeDasharray="8,6"
|
||||
/>
|
||||
)}
|
||||
</Svg>
|
||||
|
||||
{/* Simplified data points - smaller and more refined */}
|
||||
{points.map((point, index) => {
|
||||
const isLastPoint = index === points.length - 1;
|
||||
|
||||
return (
|
||||
<React.Fragment key={index}>
|
||||
<Circle
|
||||
cx={point.x}
|
||||
cy={point.y}
|
||||
r={isLastPoint ? 3 : 2}
|
||||
fill={Colors.light.accentGreen}
|
||||
opacity={0.9}
|
||||
/>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
|
||||
|
||||
</Svg>
|
||||
|
||||
{/* Concise chart information */}
|
||||
<View style={styles.chartInfo}>
|
||||
<View style={styles.infoItem}>
|
||||
<Text style={styles.infoLabel}>{userProfile.weight}kg</Text>
|
||||
</View>
|
||||
<View style={styles.infoItem}>
|
||||
<Text style={styles.infoLabel}>{sortedHistory.length}{t('statistics.components.weight.days')}</Text>
|
||||
</View>
|
||||
<View style={styles.infoItem}>
|
||||
<Text style={styles.infoLabel}>
|
||||
{minWeight.toFixed(1)}-{maxWeight.toFixed(1)}kg
|
||||
</Text>
|
||||
</View>
|
||||
{/* 图表信息 */}
|
||||
<View style={styles.chartInfo}>
|
||||
<View style={styles.infoItem}>
|
||||
<Text style={styles.infoLabel}>{hasData ? sortedHistory.length : '--'}{t('statistics.components.weight.days')}</Text>
|
||||
</View>
|
||||
<View style={styles.infoItem}>
|
||||
<Text style={styles.infoLabel}>
|
||||
{hasData ? `${minWeight.toFixed(1)}-${maxWeight.toFixed(1)}kg` : '--'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 减重进度条 - 始终显示 */}
|
||||
<WeightProgressBar
|
||||
progress={weightProgress}
|
||||
currentWeight={currentWeight}
|
||||
targetWeight={targetWeight}
|
||||
initialWeight={initialWeight}
|
||||
/>
|
||||
|
||||
{/* BMI information modal */}
|
||||
<Modal
|
||||
@@ -323,32 +401,38 @@ export function WeightHistoryCard() {
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderRadius: 22,
|
||||
padding: 16,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 8,
|
||||
elevation: 3,
|
||||
marginTop: 16
|
||||
borderRadius: 24,
|
||||
padding: 18,
|
||||
shadowColor: 'rgba(30, 41, 59, 0.08)',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.12,
|
||||
shadowRadius: 12,
|
||||
elevation: 4,
|
||||
marginTop: 16,
|
||||
},
|
||||
cardHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
iconSquare: {
|
||||
width: 14,
|
||||
height: 14,
|
||||
iconContainer: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 8,
|
||||
backgroundColor: 'rgba(79, 91, 213, 0.1)',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginRight: 4,
|
||||
marginRight: 10,
|
||||
},
|
||||
iconSquare: {
|
||||
width: 16,
|
||||
height: 16,
|
||||
tintColor: THEME_PRIMARY,
|
||||
},
|
||||
cardTitle: {
|
||||
fontSize: 14,
|
||||
color: '#192126',
|
||||
fontSize: 15,
|
||||
color: THEME_TEXT_PRIMARY,
|
||||
flex: 1,
|
||||
fontWeight: '600',
|
||||
fontWeight: '700',
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
headerButtons: {
|
||||
@@ -364,19 +448,56 @@ const styles = StyleSheet.create({
|
||||
justifyContent: 'center',
|
||||
},
|
||||
addButton: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 14,
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 16,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: 'rgba(79, 91, 213, 0.1)',
|
||||
},
|
||||
addButtonGlass: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 14,
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 16,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: 'rgba(147, 112, 219, 0.3)',
|
||||
backgroundColor: 'rgba(79, 91, 213, 0.15)',
|
||||
},
|
||||
currentWeightSection: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginTop: 12,
|
||||
gap: 12,
|
||||
},
|
||||
weightValueContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'baseline',
|
||||
},
|
||||
weightValue: {
|
||||
fontSize: 32,
|
||||
fontWeight: '800',
|
||||
color: THEME_TEXT_PRIMARY,
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
weightUnit: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: THEME_TEXT_SECONDARY,
|
||||
fontFamily: 'AliRegular',
|
||||
marginLeft: 4,
|
||||
},
|
||||
changeTag: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 12,
|
||||
gap: 4,
|
||||
},
|
||||
changeText: {
|
||||
fontSize: 12,
|
||||
fontWeight: '700',
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
emptyContent: {
|
||||
alignItems: 'center',
|
||||
@@ -384,12 +505,12 @@ const styles = StyleSheet.create({
|
||||
emptyTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
color: '#192126',
|
||||
color: THEME_TEXT_PRIMARY,
|
||||
marginBottom: 6,
|
||||
},
|
||||
emptyDescription: {
|
||||
fontSize: 14,
|
||||
color: '#687076',
|
||||
color: THEME_TEXT_SECONDARY,
|
||||
textAlign: 'center',
|
||||
marginBottom: 16,
|
||||
lineHeight: 20,
|
||||
@@ -397,14 +518,14 @@ const styles = StyleSheet.create({
|
||||
recordButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: Colors.light.accentGreen,
|
||||
backgroundColor: THEME_PRIMARY,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 10,
|
||||
borderRadius: 20,
|
||||
gap: 6,
|
||||
},
|
||||
recordButtonText: {
|
||||
color: '#192126',
|
||||
color: '#FFFFFF',
|
||||
fontSize: 14,
|
||||
fontWeight: '700',
|
||||
fontFamily: 'AliBold',
|
||||
@@ -418,20 +539,25 @@ const styles = StyleSheet.create({
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-around',
|
||||
width: '100%',
|
||||
marginTop: -14,
|
||||
},
|
||||
infoItem: {
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'rgba(79, 91, 213, 0.06)',
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 10,
|
||||
},
|
||||
infoLabel: {
|
||||
fontSize: 11,
|
||||
color: '#687076',
|
||||
color: THEME_TEXT_SECONDARY,
|
||||
fontWeight: '500',
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
infoValue: {
|
||||
fontSize: 14,
|
||||
fontWeight: '700',
|
||||
color: '#192126',
|
||||
color: THEME_TEXT_PRIMARY,
|
||||
},
|
||||
|
||||
// BMI modal styles
|
||||
@@ -556,7 +682,7 @@ const styles = StyleSheet.create({
|
||||
marginBottom: 8,
|
||||
},
|
||||
bmiModalButtonBackground: {
|
||||
backgroundColor: '#192126',
|
||||
backgroundColor: THEME_TEXT_PRIMARY,
|
||||
borderRadius: 16,
|
||||
paddingVertical: 16,
|
||||
alignItems: 'center',
|
||||
|
||||
278
components/weight/WeightProgressBar.tsx
Normal file
278
components/weight/WeightProgressBar.tsx
Normal file
@@ -0,0 +1,278 @@
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Animated,
|
||||
Easing,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
ViewStyle
|
||||
} from 'react-native';
|
||||
|
||||
// 主题色
|
||||
const THEME_PRIMARY = '#4F5BD5';
|
||||
const THEME_SECONDARY = '#6B6CFF';
|
||||
const THEME_SUCCESS = '#22C55E';
|
||||
const THEME_TEXT_SECONDARY = '#6f7ba7';
|
||||
|
||||
export interface WeightProgressBarProps {
|
||||
/** 进度值 0-1 */
|
||||
progress: number;
|
||||
/** 当前体重 */
|
||||
currentWeight: number;
|
||||
/** 目标体重 */
|
||||
targetWeight: number;
|
||||
/** 初始体重 */
|
||||
initialWeight: number;
|
||||
/** 容器样式 */
|
||||
style?: ViewStyle;
|
||||
/** 是否显示顶部分隔线,默认 true */
|
||||
showTopBorder?: boolean;
|
||||
}
|
||||
|
||||
export const WeightProgressBar: React.FC<WeightProgressBarProps> = ({
|
||||
progress,
|
||||
currentWeight,
|
||||
targetWeight,
|
||||
initialWeight,
|
||||
style,
|
||||
showTopBorder = true,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const animatedProgress = useRef(new Animated.Value(0)).current;
|
||||
const [barWidth, setBarWidth] = useState(0);
|
||||
|
||||
const clampedProgress = Math.min(1, Math.max(0, progress));
|
||||
const percent = Math.round(clampedProgress * 100);
|
||||
|
||||
// 判断是否有有效数据
|
||||
const hasInitialWeight = initialWeight > 0;
|
||||
const hasTargetWeight = targetWeight > 0;
|
||||
const hasCurrentWeight = currentWeight > 0;
|
||||
// 只要有初始体重和当前体重,就可以显示已减重量
|
||||
const canShowLost = hasInitialWeight && hasCurrentWeight;
|
||||
// 需要有目标体重才能显示距离目标和进度
|
||||
const canShowTarget = hasTargetWeight && hasCurrentWeight;
|
||||
|
||||
useEffect(() => {
|
||||
// 延迟 500ms 开始动画,避免页面刚进入时卡顿
|
||||
const timer = setTimeout(() => {
|
||||
Animated.timing(animatedProgress, {
|
||||
toValue: clampedProgress,
|
||||
duration: 800,
|
||||
easing: Easing.out(Easing.cubic),
|
||||
useNativeDriver: false,
|
||||
}).start();
|
||||
}, 800);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [clampedProgress]);
|
||||
|
||||
const fillWidth = animatedProgress.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: [0, barWidth],
|
||||
});
|
||||
|
||||
const sliderPosition = animatedProgress.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: [-12, barWidth - 12],
|
||||
});
|
||||
|
||||
const weightLost = initialWeight - currentWeight;
|
||||
const weightToGo = currentWeight - targetWeight;
|
||||
|
||||
return (
|
||||
<View style={[
|
||||
styles.container,
|
||||
showTopBorder && styles.topBorder,
|
||||
style
|
||||
]}>
|
||||
{/* 进度信息 */}
|
||||
<View style={styles.infoRow}>
|
||||
<View style={styles.infoItem}>
|
||||
<Text style={styles.infoLabel}>{t('statistics.components.weight.progress.lost')}</Text>
|
||||
<Text style={[styles.infoValue, { color: canShowLost && weightLost >= 0 ? THEME_SUCCESS : (canShowLost ? '#FF6B6B' : THEME_TEXT_SECONDARY) }]}>
|
||||
{canShowLost ? `${weightLost >= 0 ? '-' : '+'}${Math.abs(weightLost).toFixed(1)}kg` : '--'}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.percentContainer}>
|
||||
<Text style={styles.percentValue}>{percent}</Text>
|
||||
<Text style={styles.percentSymbol}>%</Text>
|
||||
</View>
|
||||
<View style={[styles.infoItem, { alignItems: 'flex-end' }]}>
|
||||
<Text style={styles.infoLabel}>{t('statistics.components.weight.progress.toGo')}</Text>
|
||||
<Text style={[styles.infoValue, { color: THEME_PRIMARY }]}>
|
||||
{canShowTarget ? `${weightToGo > 0 ? weightToGo.toFixed(1) : '0'}kg` : '--'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 进度条 */}
|
||||
<View
|
||||
style={styles.trackContainer}
|
||||
onLayout={(e) => setBarWidth(e.nativeEvent.layout.width)}
|
||||
>
|
||||
{/* 背景轨道 */}
|
||||
<View style={styles.track} />
|
||||
|
||||
{/* 填充进度 */}
|
||||
<Animated.View style={[styles.fill, { width: fillWidth }]}>
|
||||
<LinearGradient
|
||||
colors={[THEME_PRIMARY, THEME_SECONDARY]}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 0 }}
|
||||
style={StyleSheet.absoluteFillObject}
|
||||
/>
|
||||
</Animated.View>
|
||||
|
||||
{/* 滑块 - 圆角矩形 */}
|
||||
<Animated.View style={[styles.slider, { left: sliderPosition }]}>
|
||||
<LinearGradient
|
||||
colors={['#ffffff', '#f8f9fc']}
|
||||
style={styles.sliderInner}
|
||||
>
|
||||
<View style={styles.sliderLine} />
|
||||
</LinearGradient>
|
||||
</Animated.View>
|
||||
</View>
|
||||
|
||||
{/* 起止标签 */}
|
||||
<View style={styles.labelRow}>
|
||||
<Text style={styles.labelText}>{hasInitialWeight ? `${initialWeight.toFixed(1)}kg` : '--'}</Text>
|
||||
<View style={styles.targetBadge}>
|
||||
<Ionicons name="flag" size={10} color={THEME_PRIMARY} />
|
||||
<Text style={styles.targetText}>{hasTargetWeight ? `${targetWeight.toFixed(1)}kg` : '--'}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
marginTop: 12,
|
||||
paddingTop: 10,
|
||||
marginLeft:12,
|
||||
marginRight: 12
|
||||
},
|
||||
topBorder: {
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: 'rgba(0,0,0,0.04)',
|
||||
},
|
||||
infoRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 8,
|
||||
},
|
||||
infoItem: {
|
||||
flex: 1,
|
||||
},
|
||||
infoLabel: {
|
||||
fontSize: 11,
|
||||
color: THEME_TEXT_SECONDARY,
|
||||
fontFamily: 'AliRegular',
|
||||
marginBottom: 2,
|
||||
},
|
||||
infoValue: {
|
||||
fontSize: 14,
|
||||
fontWeight: '700',
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
percentContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'baseline',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
percentValue: {
|
||||
fontSize: 24,
|
||||
fontWeight: '800',
|
||||
color: THEME_PRIMARY,
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
percentSymbol: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
color: THEME_PRIMARY,
|
||||
fontFamily: 'AliBold',
|
||||
marginLeft: 2,
|
||||
},
|
||||
trackContainer: {
|
||||
height: 8,
|
||||
position: 'relative',
|
||||
marginBottom: 8,
|
||||
},
|
||||
track: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: '#E8EAF0',
|
||||
borderRadius: 4,
|
||||
},
|
||||
fill: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
borderRadius: 4,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
slider: {
|
||||
position: 'absolute',
|
||||
top: -8,
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: 8,
|
||||
shadowColor: THEME_PRIMARY,
|
||||
shadowOffset: { width: 0, height: 3 },
|
||||
shadowOpacity: 0.35,
|
||||
shadowRadius: 6,
|
||||
elevation: 6,
|
||||
},
|
||||
sliderInner: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
borderRadius: 8,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderWidth: 2.5,
|
||||
borderColor: THEME_PRIMARY,
|
||||
},
|
||||
sliderLine: {
|
||||
width: 8,
|
||||
height: 3,
|
||||
borderRadius: 1.5,
|
||||
backgroundColor: THEME_PRIMARY,
|
||||
},
|
||||
labelRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
},
|
||||
labelText: {
|
||||
fontSize: 11,
|
||||
color: THEME_TEXT_SECONDARY,
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
targetBadge: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'rgba(79, 91, 213, 0.1)',
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 3,
|
||||
borderRadius: 10,
|
||||
gap: 4,
|
||||
},
|
||||
targetText: {
|
||||
fontSize: 11,
|
||||
color: THEME_PRIMARY,
|
||||
fontWeight: '600',
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
});
|
||||
|
||||
export default WeightProgressBar;
|
||||
Reference in New Issue
Block a user