Files
digital-pilates/app/sleep-detail.tsx
richarjiang bca6670390 Add Chinese translations for medication management and personal settings
- Introduced new translation files for medication, personal, and weight management in Chinese.
- Updated the main index file to include the new translation modules.
- Enhanced the medication type definitions to include 'ointment'.
- Refactored workout type labels to utilize i18n for better localization support.
- Improved sleep quality descriptions and recommendations with i18n integration.
2025-11-28 17:29:51 +08:00

649 lines
19 KiB
TypeScript

import { InfoModal, type SleepDetailData } from '@/components/sleep/InfoModal';
import { SleepStagesInfoModal } from '@/components/sleep/SleepStagesInfoModal';
import { SleepStageTimeline } from '@/components/sleep/SleepStageTimeline';
import { HeaderBar } from '@/components/ui/HeaderBar';
import { useColorScheme } from '@/hooks/useColorScheme';
import { useI18n } from '@/hooks/useI18n';
import {
fetchCompleteSleepData,
formatSleepTime,
getSleepStageColor,
SleepStage,
type CompleteSleepData
} from '@/utils/sleepHealthKit';
import { Ionicons } from '@expo/vector-icons';
import dayjs from 'dayjs';
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
import { Image } from 'expo-image';
import { LinearGradient } from 'expo-linear-gradient';
import { router, useLocalSearchParams } from 'expo-router';
import React, { useCallback, useEffect, useState } from 'react';
import {
ActivityIndicator,
Dimensions,
ScrollView,
StatusBar,
StyleSheet,
Text,
TouchableOpacity,
View
} from 'react-native';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
const { width } = Dimensions.get('window');
const HERO_HEIGHT = width * 0.76;
export default function SleepDetailScreen() {
const { t } = useI18n();
const insets = useSafeAreaInsets();
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
const [sleepData, setSleepData] = useState<CompleteSleepData | null>(null);
const [loading, setLoading] = useState(true);
// 从导航参数获取日期,如果没有则使用今天
const { date: dateParam } = useLocalSearchParams<{ date?: string }>();
const [selectedDate] = useState(() => {
if (dateParam) {
return dayjs(dateParam).toDate();
}
return dayjs().toDate();
});
const [infoModal, setInfoModal] = useState<{ visible: boolean; title: string; type: 'sleep-time' | 'sleep-quality' | null }>({
visible: false,
title: '',
type: null
});
const [sleepStagesModal, setSleepStagesModal] = useState({
visible: false
});
const loadSleepData = useCallback(async () => {
try {
setLoading(true);
const data = await fetchCompleteSleepData(selectedDate);
setSleepData(data);
} catch (error) {
console.error('Failed to load sleep data:', error);
} finally {
setLoading(false);
}
}, [selectedDate]);
useEffect(() => {
loadSleepData();
}, [loadSleepData]);
// 如果没有数据,使用默认数据结构
const displayData: CompleteSleepData = sleepData || {
sleepScore: 0,
totalSleepTime: 0,
sleepQualityPercentage: 0,
bedtime: '',
wakeupTime: '',
timeInBed: 0,
sleepStages: [],
rawSleepSamples: [],
averageHeartRate: null,
sleepHeartRateData: [],
sleepEfficiency: 0,
qualityDescription: t('sleepDetail.noData'),
recommendation: t('sleepDetail.noDataRecommendation')
};
const formatDateTitle = (date: Date) => {
if (dayjs(date).isSame(dayjs(), 'day')) {
return t('sleepDetail.today');
}
return dayjs(date).format('M月D日');
};
const getScoreColor = (score: number) => {
if (score >= 85) return '#10B981'; // Green
if (score >= 70) return '#3B82F6'; // Blue
if (score >= 60) return '#F59E0B'; // Yellow
};
// 加载状态
if (loading) {
return (
<SafeAreaView style={[styles.safeArea, { backgroundColor: '#f3f4fb' }]}>
<HeaderBar title={formatDateTitle(selectedDate)} onBack={() => router.back()} withSafeTop transparent={false} />
<View style={styles.missingContainer}>
<ActivityIndicator color="#5E8BFF" size="large" />
<Text style={styles.missingText}>{t('sleepDetail.loading')}</Text>
</View>
</SafeAreaView>
);
}
return (
<View style={styles.container}>
<StatusBar barStyle="light-content" />
{/* 顶部导航覆盖层 */}
<View pointerEvents="box-none" style={[styles.headerOverlay, { paddingTop: insets.top }]}>
<HeaderBar
title=""
tone="light"
transparent
withSafeTop={false}
right={
<View style={styles.headerButtons}>
{isLiquidGlassAvailable() ? (
<TouchableOpacity
onPress={() => {}}
activeOpacity={0.7}
>
<GlassView
style={styles.iconButton}
glassEffectStyle="clear"
tintColor="rgba(255, 255, 255, 0.3)"
isInteractive={true}
>
<Ionicons name="share-social-outline" size={20} color="#ffffff" />
</GlassView>
</TouchableOpacity>
) : (
<TouchableOpacity
style={[styles.iconButton, styles.fallbackIconButton]}
activeOpacity={0.7}
>
<Ionicons name="share-social-outline" size={20} color="#ffffff" />
</TouchableOpacity>
)}
</View>
}
/>
</View>
<ScrollView
style={styles.scrollView}
bounces={true}
showsVerticalScrollIndicator={false}
contentContainerStyle={[
styles.scrollContent,
{ paddingBottom: 40 + insets.bottom },
]}
>
{/* Hero 区域 */}
<View style={styles.heroContainer}>
<Image
source='https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/challenge/sleep-pig.jpeg'
style={styles.heroImage}
cachePolicy={'memory-disk'}
/>
<LinearGradient
colors={['rgba(21, 23, 44, 0.4)', 'rgba(21, 23, 44, 0.1)', '#f3f4fb']}
style={StyleSheet.absoluteFillObject}
locations={[0, 0.6, 1]}
/>
<LinearGradient
colors={['transparent', '#f3f4fb']}
style={[StyleSheet.absoluteFillObject, { top: '60%' }]}
locations={[0, 1]}
/>
</View>
{/* 头部文本块 */}
<View style={styles.headerTextBlock}>
<View style={styles.scoreCircle}>
<Text style={[styles.scoreValue, { color: getScoreColor(displayData.sleepScore) }]}>
{displayData.sleepScore}
</Text>
<Text style={styles.scoreLabel}>{formatDateTitle(selectedDate)}{t('sleepDetail.sleepScore')}</Text>
</View>
<Text style={styles.summary}>{displayData.qualityDescription}</Text>
<Text style={styles.recommendation}>{displayData.recommendation}</Text>
</View>
{/* 核心数据卡片 */}
<View style={styles.detailCard}>
{/* 睡眠时长 */}
<View style={styles.detailRow}>
<View style={styles.detailIconWrapper}>
<Ionicons name="moon" size={22} color="#5E8BFF" />
</View>
<View style={styles.detailTextWrapper}>
<View style={styles.detailHeader}>
<Text style={styles.detailLabel}>{t('sleepDetail.sleepDuration')}</Text>
<TouchableOpacity
onPress={() => setInfoModal({ visible: true, title: t('sleepDetail.infoModalTitles.sleepTime'), type: 'sleep-time' })}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<Ionicons name="information-circle-outline" size={16} color="#A0AEC0" />
</TouchableOpacity>
</View>
<Text style={styles.detailValue}>
{displayData.totalSleepTime > 0 ? formatSleepTime(displayData.totalSleepTime) : '--'}
</Text>
</View>
</View>
{/* 分割线 */}
<View style={styles.divider} />
{/* 睡眠质量 */}
<View style={styles.detailRow}>
<View style={[styles.detailIconWrapper, { backgroundColor: '#EEF2FF' }]}>
<Ionicons name="star" size={22} color="#6B6CFF" />
</View>
<View style={styles.detailTextWrapper}>
<View style={styles.detailHeader}>
<Text style={styles.detailLabel}>{t('sleepDetail.sleepQuality')}</Text>
<TouchableOpacity
onPress={() => setInfoModal({ visible: true, title: t('sleepDetail.infoModalTitles.sleepQuality'), type: 'sleep-quality' })}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<Ionicons name="information-circle-outline" size={16} color="#A0AEC0" />
</TouchableOpacity>
</View>
<Text style={styles.detailValue}>
{displayData.sleepQualityPercentage > 0 ? `${displayData.sleepQualityPercentage}%` : '--%'}
</Text>
</View>
</View>
</View>
{/* 睡眠阶段时间轴 */}
<View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}>{t('sleepDetail.sleepStages')}</Text>
<TouchableOpacity onPress={() => setSleepStagesModal({ visible: true })}>
<Text style={styles.sectionAction}>{t('sleepDetail.learnMore')}</Text>
</TouchableOpacity>
</View>
<View style={styles.timelineCard}>
<SleepStageTimeline
sleepSamples={displayData.rawSleepSamples}
bedtime={displayData.bedtime}
wakeupTime={displayData.wakeupTime}
hideHeader
style={styles.timelineInner}
/>
</View>
{/* 睡眠阶段统计网格 */}
<View style={styles.stagesGridContainer}>
{(() => {
let stagesToDisplay;
if (displayData.sleepStages.length > 0) {
const existingStages = new Map(displayData.sleepStages.map(s => [s.stage, s]));
stagesToDisplay = [
existingStages.get(SleepStage.Awake) || { stage: SleepStage.Awake, duration: 0, percentage: 0 },
existingStages.get(SleepStage.REM) || { stage: SleepStage.REM, duration: 0, percentage: 0 },
existingStages.get(SleepStage.Core) || { stage: SleepStage.Core, duration: 0, percentage: 0 },
existingStages.get(SleepStage.Deep) || { stage: SleepStage.Deep, duration: 0, percentage: 0 }
];
} else {
stagesToDisplay = [
{ stage: SleepStage.Awake, duration: 0, percentage: 0 },
{ stage: SleepStage.REM, duration: 0, percentage: 0 },
{ stage: SleepStage.Core, duration: 0, percentage: 0 },
{ stage: SleepStage.Deep, duration: 0, percentage: 0 }
];
}
return stagesToDisplay;
})().map((stageData, index) => {
const getStageName = (stage: SleepStage) => {
switch (stage) {
case SleepStage.Awake: return t('sleepDetail.awake');
case SleepStage.REM: return t('sleepDetail.rem');
case SleepStage.Core: return t('sleepDetail.core');
case SleepStage.Deep: return t('sleepDetail.deep');
default: return t('sleepDetail.unknown');
}
};
const stageColor = getSleepStageColor(stageData.stage);
return (
<View key={index} style={styles.stageCard}>
<View style={styles.stageHeader}>
<View style={[styles.stageDot, { backgroundColor: stageColor }]} />
<Text style={[styles.stageTitle, { color: stageColor }]}>
{getStageName(stageData.stage)}
</Text>
</View>
<Text style={styles.stageValue}>
{formatSleepTime(stageData.duration)}
</Text>
<View style={styles.stageProgressBg}>
<View
style={[
styles.stageProgressFill,
{
width: `${Math.min(100, stageData.percentage)}%`,
backgroundColor: stageColor
}
]}
/>
</View>
<Text style={styles.stagePercentage}>
{stageData.percentage}%
</Text>
</View>
);
})}
</View>
{/* 原始数据列表 (如果有大量数据) */}
{sleepData && sleepData.rawSleepSamples && sleepData.rawSleepSamples.length > 0 && (
<View style={styles.rawSamplesCard}>
<View style={styles.rawSamplesHeader}>
<Text style={styles.rawSamplesTitle}>
{t('sleepDetail.rawData')} ({sleepData.rawSleepSamples.length})
</Text>
<Ionicons name="list-outline" size={20} color="#6f7ba7" />
</View>
{/* 这里可以考虑是否真的需要显示长列表,或者只显示摘要 */}
<Text style={styles.rawSamplesSubtitle}>
{t('sleepDetail.rawDataDescription', { count: sleepData.rawSleepSamples.length })}
</Text>
</View>
)}
</ScrollView>
{/* Modals */}
{infoModal.type && (
<InfoModal
visible={infoModal.visible}
onClose={() => setInfoModal({ ...infoModal, visible: false })}
title={infoModal.title}
type={infoModal.type}
sleepData={displayData as SleepDetailData}
/>
)}
<SleepStagesInfoModal
visible={sleepStagesModal.visible}
onClose={() => setSleepStagesModal({ visible: false })}
/>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f3f4fb',
},
safeArea: {
flex: 1,
},
headerOverlay: {
position: 'absolute',
left: 0,
right: 0,
top: 0,
zIndex: 20,
},
heroContainer: {
height: HERO_HEIGHT,
width: '100%',
overflow: 'hidden',
position: 'absolute',
top: 0,
},
heroImage: {
width: '100%',
height: '100%',
},
scrollView: {
flex: 1,
},
scrollContent: {
paddingBottom: 40,
},
headerTextBlock: {
paddingHorizontal: 24,
marginTop: HERO_HEIGHT - 60,
alignItems: 'center',
},
scoreCircle: {
alignItems: 'center',
justifyContent: 'center',
},
scoreValue: {
fontSize: 48,
fontWeight: '800',
fontFamily: 'AliBold',
lineHeight: 56,
textShadowColor: 'rgba(255, 255, 255, 0.5)',
textShadowOffset: { width: 0, height: 2 },
textShadowRadius: 4,
},
scoreLabel: {
fontSize: 14,
color: '#596095',
marginTop: 4,
fontFamily: 'AliRegular',
letterSpacing: 0.5,
},
summary: {
marginTop: 16,
fontSize: 18,
fontWeight: '600',
color: '#1c1f3a',
textAlign: 'center',
fontFamily: 'AliBold',
lineHeight: 24,
},
recommendation: {
marginTop: 8,
fontSize: 14,
lineHeight: 20,
color: '#7080b4',
textAlign: 'center',
fontFamily: 'AliRegular',
paddingHorizontal: 10,
},
detailCard: {
marginTop: 28,
marginHorizontal: 20,
padding: 20,
borderRadius: 28,
backgroundColor: '#ffffff',
shadowColor: 'rgba(30, 41, 59, 0.1)',
shadowOpacity: 0.2,
shadowRadius: 20,
shadowOffset: { width: 0, height: 10 },
elevation: 8,
},
detailRow: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 12,
},
detailIconWrapper: {
width: 46,
height: 46,
borderRadius: 23,
backgroundColor: '#EEF0FF',
alignItems: 'center',
justifyContent: 'center',
},
detailTextWrapper: {
marginLeft: 16,
flex: 1,
},
detailHeader: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
},
detailLabel: {
fontSize: 14,
color: '#6f7ba7',
fontFamily: 'AliRegular',
},
detailValue: {
fontSize: 20,
fontWeight: '700',
color: '#1c1f3a',
marginTop: 4,
fontFamily: 'AliBold',
},
divider: {
height: 1,
backgroundColor: '#F0F2F9',
marginVertical: 4,
marginLeft: 62, // align with text
},
sectionHeader: {
marginTop: 32,
marginHorizontal: 24,
marginBottom: 16,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
sectionTitle: {
fontSize: 18,
fontWeight: '700',
color: '#1c1f3a',
fontFamily: 'AliBold',
},
sectionAction: {
fontSize: 13,
fontWeight: '600',
color: '#5F6BF0',
fontFamily: 'AliBold',
},
timelineCard: {
marginHorizontal: 20,
borderRadius: 24,
backgroundColor: '#ffffff',
paddingVertical: 8,
paddingHorizontal: 4,
shadowColor: 'rgba(30, 41, 59, 0.08)',
shadowOpacity: 0.15,
shadowRadius: 16,
shadowOffset: { width: 0, height: 8 },
elevation: 4,
overflow: 'hidden',
},
timelineInner: {
backgroundColor: 'transparent',
shadowOpacity: 0,
elevation: 0,
padding: 0,
marginBottom: 0,
marginHorizontal: 0,
},
stagesGridContainer: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 12,
paddingHorizontal: 20,
marginTop: 20,
},
stageCard: {
width: (width - 52) / 2, // 20*2 margin + 12 gap
backgroundColor: '#ffffff',
borderRadius: 20,
padding: 16,
shadowColor: 'rgba(30, 41, 59, 0.06)',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.1,
shadowRadius: 12,
elevation: 3,
},
stageHeader: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 12,
},
stageDot: {
width: 8,
height: 8,
borderRadius: 4,
marginRight: 8,
},
stageTitle: {
fontSize: 13,
fontWeight: '600',
fontFamily: 'AliBold',
},
stageValue: {
fontSize: 18,
fontWeight: '700',
color: '#1c1f3a',
fontFamily: 'AliBold',
marginBottom: 8,
},
stageProgressBg: {
height: 4,
backgroundColor: '#F0F2F9',
borderRadius: 2,
marginBottom: 8,
overflow: 'hidden',
},
stageProgressFill: {
height: '100%',
borderRadius: 2,
},
stagePercentage: {
fontSize: 12,
color: '#6f7ba7',
fontFamily: 'AliRegular',
},
rawSamplesCard: {
marginTop: 24,
marginHorizontal: 20,
padding: 20,
borderRadius: 24,
backgroundColor: '#ffffff',
opacity: 0.8,
},
rawSamplesHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 8,
},
rawSamplesTitle: {
fontSize: 15,
fontWeight: '600',
color: '#1c1f3a',
fontFamily: 'AliBold',
},
rawSamplesSubtitle: {
fontSize: 12,
color: '#6f7ba7',
fontFamily: 'AliRegular',
},
missingContainer: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: 32,
},
missingText: {
fontSize: 16,
color: '#6f7ba7',
marginTop: 16,
fontFamily: 'AliRegular',
},
headerButtons: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
},
iconButton: {
width: 40,
height: 40,
borderRadius: 20,
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
},
fallbackIconButton: {
backgroundColor: 'rgba(255, 255, 255, 0.2)',
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.3)',
},
});