feat(auth): 为未登录用户添加登录引导界面
为目标页面、营养记录、食物添加等功能添加登录状态检查和引导界面,确保用户在未登录状态下能够获得清晰的登录提示和指引。 - 在目标页面添加精美的未登录引导界面,包含渐变背景和登录按钮 - 为食物记录相关组件添加登录状态检查,未登录时自动跳转登录页面 - 重构血氧饱和度卡片为独立数据获取,移除对外部数据依赖 - 移除个人页面的实验性SwiftUI组件,统一使用原生TouchableOpacity - 清理统计页面和营养记录页面的冗余代码和未使用变量
This commit is contained in:
@@ -109,6 +109,8 @@ export default function GoalsScreen() {
|
|||||||
const onRefresh = async () => {
|
const onRefresh = async () => {
|
||||||
setRefreshing(true);
|
setRefreshing(true);
|
||||||
try {
|
try {
|
||||||
|
if (!isLoggedIn) return
|
||||||
|
|
||||||
await loadTasks();
|
await loadTasks();
|
||||||
} finally {
|
} finally {
|
||||||
setRefreshing(false);
|
setRefreshing(false);
|
||||||
@@ -117,6 +119,8 @@ export default function GoalsScreen() {
|
|||||||
|
|
||||||
// 加载更多任务
|
// 加载更多任务
|
||||||
const handleLoadMoreTasks = async () => {
|
const handleLoadMoreTasks = async () => {
|
||||||
|
if (!isLoggedIn) return
|
||||||
|
|
||||||
if (tasksPagination.hasMore && !tasksLoading) {
|
if (tasksPagination.hasMore && !tasksLoading) {
|
||||||
try {
|
try {
|
||||||
await dispatch(loadMoreTasks()).unwrap();
|
await dispatch(loadMoreTasks()).unwrap();
|
||||||
@@ -319,6 +323,61 @@ export default function GoalsScreen() {
|
|||||||
|
|
||||||
// 渲染空状态
|
// 渲染空状态
|
||||||
const renderEmptyState = () => {
|
const renderEmptyState = () => {
|
||||||
|
// 未登录状态下的引导
|
||||||
|
if (!isLoggedIn) {
|
||||||
|
return (
|
||||||
|
<View style={styles.emptyStateLogin}>
|
||||||
|
<LinearGradient
|
||||||
|
colors={['#F0F9FF', '#FEFEFE', '#F0F9FF']}
|
||||||
|
style={styles.emptyStateLoginBackground}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 1 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<View style={styles.emptyStateLoginContent}>
|
||||||
|
{/* 清新的图标设计 */}
|
||||||
|
<View style={styles.emptyStateLoginIconContainer}>
|
||||||
|
<LinearGradient
|
||||||
|
colors={[colorTokens.primary, '#9B8AFB']}
|
||||||
|
style={styles.emptyStateLoginIconGradient}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 1 }}
|
||||||
|
>
|
||||||
|
<MaterialIcons name="person-outline" size={32} color="#FFFFFF" />
|
||||||
|
</LinearGradient>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 主标题 */}
|
||||||
|
<Text style={[styles.emptyStateLoginTitle, { color: colorTokens.text }]}>
|
||||||
|
开启您的健康之旅
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* 副标题 */}
|
||||||
|
<Text style={[styles.emptyStateLoginSubtitle, { color: colorTokens.textSecondary }]}>
|
||||||
|
登录后即可创建个人目标,让我们一起建立健康的生活习惯
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* 登录按钮 */}
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.emptyStateLoginButton, { backgroundColor: colorTokens.primary }]}
|
||||||
|
onPress={() => pushIfAuthedElseLogin('/goals')}
|
||||||
|
>
|
||||||
|
<LinearGradient
|
||||||
|
colors={[colorTokens.primary, '#9B8AFB']}
|
||||||
|
style={styles.emptyStateLoginButtonGradient}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 1 }}
|
||||||
|
>
|
||||||
|
<Text style={styles.emptyStateLoginButtonText}>立即登录</Text>
|
||||||
|
<MaterialIcons name="arrow-forward" size={18} color="#FFFFFF" />
|
||||||
|
</LinearGradient>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 已登录但无任务的状态
|
||||||
let title = '暂无任务';
|
let title = '暂无任务';
|
||||||
let subtitle = '创建目标后,系统会自动生成相应的任务';
|
let subtitle = '创建目标后,系统会自动生成相应的任务';
|
||||||
|
|
||||||
@@ -710,6 +769,80 @@ const styles = StyleSheet.create({
|
|||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
lineHeight: 20,
|
lineHeight: 20,
|
||||||
},
|
},
|
||||||
|
// 未登录空状态样式
|
||||||
|
emptyStateLogin: {
|
||||||
|
flex: 1,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
paddingHorizontal: 24,
|
||||||
|
paddingVertical: 80,
|
||||||
|
position: 'relative',
|
||||||
|
},
|
||||||
|
emptyStateLoginBackground: {
|
||||||
|
position: 'absolute',
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
borderRadius: 24,
|
||||||
|
},
|
||||||
|
emptyStateLoginContent: {
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
zIndex: 1,
|
||||||
|
},
|
||||||
|
emptyStateLoginIconContainer: {
|
||||||
|
marginBottom: 24,
|
||||||
|
shadowColor: '#7A5AF8',
|
||||||
|
shadowOffset: { width: 0, height: 8 },
|
||||||
|
shadowOpacity: 0.15,
|
||||||
|
shadowRadius: 16,
|
||||||
|
elevation: 8,
|
||||||
|
},
|
||||||
|
emptyStateLoginIconGradient: {
|
||||||
|
width: 80,
|
||||||
|
height: 80,
|
||||||
|
borderRadius: 40,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
emptyStateLoginTitle: {
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: '700',
|
||||||
|
marginBottom: 12,
|
||||||
|
textAlign: 'center',
|
||||||
|
letterSpacing: -0.5,
|
||||||
|
},
|
||||||
|
emptyStateLoginSubtitle: {
|
||||||
|
fontSize: 16,
|
||||||
|
lineHeight: 24,
|
||||||
|
textAlign: 'center',
|
||||||
|
marginBottom: 32,
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
},
|
||||||
|
emptyStateLoginButton: {
|
||||||
|
borderRadius: 28,
|
||||||
|
shadowColor: '#7A5AF8',
|
||||||
|
shadowOffset: { width: 0, height: 4 },
|
||||||
|
shadowOpacity: 0.2,
|
||||||
|
shadowRadius: 12,
|
||||||
|
elevation: 6,
|
||||||
|
},
|
||||||
|
emptyStateLoginButtonGradient: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
paddingHorizontal: 32,
|
||||||
|
paddingVertical: 16,
|
||||||
|
borderRadius: 28,
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
emptyStateLoginButtonText: {
|
||||||
|
color: '#FFFFFF',
|
||||||
|
fontSize: 17,
|
||||||
|
fontWeight: '600',
|
||||||
|
letterSpacing: -0.2,
|
||||||
|
},
|
||||||
loadMoreContainer: {
|
loadMoreContainer: {
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
paddingVertical: 20,
|
paddingVertical: 20,
|
||||||
|
|||||||
@@ -9,8 +9,6 @@ import { DEFAULT_MEMBER_NAME, fetchActivityHistory, fetchMyProfile } from '@/sto
|
|||||||
import { getItem, setItem } from '@/utils/kvStore';
|
import { getItem, setItem } from '@/utils/kvStore';
|
||||||
import { log } from '@/utils/logger';
|
import { log } from '@/utils/logger';
|
||||||
import { getNotificationEnabled, setNotificationEnabled as saveNotificationEnabled } from '@/utils/userPreferences';
|
import { getNotificationEnabled, setNotificationEnabled as saveNotificationEnabled } from '@/utils/userPreferences';
|
||||||
import { Button, Host, Text as SwiftText } from '@expo/ui/swift-ui';
|
|
||||||
import { frame, glassEffect } from '@expo/ui/swift-ui/modifiers';
|
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { useFocusEffect } from '@react-navigation/native';
|
import { useFocusEffect } from '@react-navigation/native';
|
||||||
import { isLiquidGlassAvailable } from 'expo-glass-effect';
|
import { isLiquidGlassAvailable } from 'expo-glass-effect';
|
||||||
@@ -215,32 +213,9 @@ export default function PersonalScreen() {
|
|||||||
<Text style={styles.userName}>{displayName}</Text>
|
<Text style={styles.userName}>{displayName}</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
{isLgAvaliable ? <Host style={{
|
<TouchableOpacity style={styles.editButton} onPress={() => pushIfAuthedElseLogin('/profile/edit')}>
|
||||||
marginRight: 18,
|
|
||||||
}}>
|
|
||||||
<Button
|
|
||||||
variant='default'
|
|
||||||
onPress={() => {
|
|
||||||
pushIfAuthedElseLogin('/profile/edit')
|
|
||||||
}}
|
|
||||||
modifiers={[
|
|
||||||
frame({
|
|
||||||
width: 60,
|
|
||||||
height: 30,
|
|
||||||
}),
|
|
||||||
glassEffect({
|
|
||||||
glass: {
|
|
||||||
variant: 'regular',
|
|
||||||
interactive: true
|
|
||||||
}
|
|
||||||
})
|
|
||||||
]} >
|
|
||||||
<SwiftText size={14} color='black' weight={'medium'}>{isLoggedIn ? '编辑' : '登录'}</SwiftText>
|
|
||||||
</Button>
|
|
||||||
</Host> : <TouchableOpacity style={styles.editButton} onPress={() => pushIfAuthedElseLogin('/profile/edit')}>
|
|
||||||
<Text style={styles.editButtonText}>{isLoggedIn ? '编辑' : '登录'}</Text>
|
<Text style={styles.editButtonText}>{isLoggedIn ? '编辑' : '登录'}</Text>
|
||||||
</TouchableOpacity>}
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ import { fetchTodayWaterStats } from '@/store/waterSlice';
|
|||||||
import { getMonthDaysZh, getTodayIndexInMonth } from '@/utils/date';
|
import { getMonthDaysZh, getTodayIndexInMonth } from '@/utils/date';
|
||||||
import { fetchHealthDataForDate, testHRVDataFetch } from '@/utils/health';
|
import { fetchHealthDataForDate, testHRVDataFetch } from '@/utils/health';
|
||||||
import { getTestHealthData } from '@/utils/mockHealthData';
|
import { getTestHealthData } from '@/utils/mockHealthData';
|
||||||
import { calculateNutritionGoals } from '@/utils/nutrition';
|
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import { LinearGradient } from 'expo-linear-gradient';
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
import { debounce } from 'lodash';
|
import { debounce } from 'lodash';
|
||||||
@@ -92,8 +91,6 @@ export default function ExploreScreen() {
|
|||||||
const activeCalories = useMockData ? (mockData?.activeEnergyBurned ?? null) : (healthData?.activeEnergyBurned ?? null);
|
const activeCalories = useMockData ? (mockData?.activeEnergyBurned ?? null) : (healthData?.activeEnergyBurned ?? null);
|
||||||
const basalMetabolism: number | null = useMockData ? (mockData?.basalEnergyBurned ?? null) : (healthData?.basalEnergyBurned ?? null);
|
const basalMetabolism: number | null = useMockData ? (mockData?.basalEnergyBurned ?? null) : (healthData?.basalEnergyBurned ?? null);
|
||||||
|
|
||||||
const oxygenSaturation = useMockData ? (mockData?.oxygenSaturation ?? null) : (healthData?.oxygenSaturation ?? null);
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// 用于触发动画重置的 token(当日期或数据变化时更新)
|
// 用于触发动画重置的 token(当日期或数据变化时更新)
|
||||||
@@ -102,15 +99,6 @@ export default function ExploreScreen() {
|
|||||||
// 从 Redux 获取营养数据
|
// 从 Redux 获取营养数据
|
||||||
const nutritionSummary = useAppSelector(selectNutritionSummaryByDate(currentSelectedDateString));
|
const nutritionSummary = useAppSelector(selectNutritionSummaryByDate(currentSelectedDateString));
|
||||||
|
|
||||||
// 计算用户的营养目标
|
|
||||||
const nutritionGoals = useMemo(() => {
|
|
||||||
return calculateNutritionGoals({
|
|
||||||
weight: userProfile.weight,
|
|
||||||
height: userProfile.height,
|
|
||||||
birthDate: userProfile?.birthDate ? new Date(userProfile?.birthDate) : undefined,
|
|
||||||
gender: userProfile?.gender || undefined,
|
|
||||||
});
|
|
||||||
}, [userProfile]);
|
|
||||||
|
|
||||||
// 心情相关状态
|
// 心情相关状态
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
@@ -226,13 +214,6 @@ export default function ExploreScreen() {
|
|||||||
loadingRef.current.health = true;
|
loadingRef.current.health = true;
|
||||||
console.log('=== 开始HealthKit初始化流程 ===');
|
console.log('=== 开始HealthKit初始化流程 ===');
|
||||||
|
|
||||||
// const ok = await ensureHealthPermissions();
|
|
||||||
// if (!ok) {
|
|
||||||
// const errorMsg = '无法获取健康权限,请确保在真实iOS设备上运行并授权应用访问健康数据';
|
|
||||||
// console.warn(errorMsg);
|
|
||||||
// return;
|
|
||||||
// }
|
|
||||||
|
|
||||||
latestRequestKeyRef.current = requestKey;
|
latestRequestKeyRef.current = requestKey;
|
||||||
|
|
||||||
console.log('权限获取成功,开始获取健康数据...', derivedDate);
|
console.log('权限获取成功,开始获取健康数据...', derivedDate);
|
||||||
@@ -251,7 +232,6 @@ export default function ExploreScreen() {
|
|||||||
activeCalories: data.activeEnergyBurned,
|
activeCalories: data.activeEnergyBurned,
|
||||||
basalEnergyBurned: data.basalEnergyBurned,
|
basalEnergyBurned: data.basalEnergyBurned,
|
||||||
hrv: data.hrv,
|
hrv: data.hrv,
|
||||||
oxygenSaturation: data.oxygenSaturation,
|
|
||||||
heartRate: data.heartRate,
|
heartRate: data.heartRate,
|
||||||
activeEnergyBurned: data.activeEnergyBurned,
|
activeEnergyBurned: data.activeEnergyBurned,
|
||||||
activeCaloriesGoal: data.activeCaloriesGoal,
|
activeCaloriesGoal: data.activeCaloriesGoal,
|
||||||
@@ -358,14 +338,6 @@ export default function ExploreScreen() {
|
|||||||
loadAllData(currentSelectedDate);
|
loadAllData(currentSelectedDate);
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// 页面聚焦时的数据加载逻辑
|
|
||||||
// useFocusEffect(
|
|
||||||
// React.useCallback(() => {
|
|
||||||
// // 页面聚焦时加载数据,使用缓存机制避免频繁请求
|
|
||||||
// console.log('页面聚焦,检查是否需要刷新数据...');
|
|
||||||
// loadAllData(currentSelectedDate);
|
|
||||||
// }, [loadAllData, currentSelectedDate])
|
|
||||||
// );
|
|
||||||
|
|
||||||
// AppState 监听:应用从后台返回前台时的处理
|
// AppState 监听:应用从后台返回前台时的处理
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -487,16 +459,10 @@ export default function ExploreScreen() {
|
|||||||
{/* 营养摄入雷达图卡片 */}
|
{/* 营养摄入雷达图卡片 */}
|
||||||
<NutritionRadarCard
|
<NutritionRadarCard
|
||||||
nutritionSummary={nutritionSummary}
|
nutritionSummary={nutritionSummary}
|
||||||
nutritionGoals={nutritionGoals}
|
|
||||||
burnedCalories={(basalMetabolism || 0) + (activeCalories || 0)}
|
burnedCalories={(basalMetabolism || 0) + (activeCalories || 0)}
|
||||||
basalMetabolism={basalMetabolism || 0}
|
basalMetabolism={basalMetabolism || 0}
|
||||||
activeCalories={activeCalories || 0}
|
activeCalories={activeCalories || 0}
|
||||||
resetToken={animToken}
|
resetToken={animToken}
|
||||||
onMealPress={(mealType: 'breakfast' | 'lunch' | 'dinner' | 'snack') => {
|
|
||||||
console.log('选择餐次:', mealType);
|
|
||||||
// 这里可以导航到营养记录页面
|
|
||||||
pushIfAuthedElseLogin('/nutrition/records');
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<WeightHistoryCard />
|
<WeightHistoryCard />
|
||||||
@@ -575,9 +541,7 @@ export default function ExploreScreen() {
|
|||||||
{/* 血氧饱和度卡片 */}
|
{/* 血氧饱和度卡片 */}
|
||||||
<FloatingCard style={styles.masonryCard} delay={1750}>
|
<FloatingCard style={styles.masonryCard} delay={1750}>
|
||||||
<OxygenSaturationCard
|
<OxygenSaturationCard
|
||||||
resetToken={animToken}
|
|
||||||
style={styles.basalMetabolismCardOverride}
|
style={styles.basalMetabolismCardOverride}
|
||||||
oxygenSaturation={oxygenSaturation}
|
|
||||||
/>
|
/>
|
||||||
</FloatingCard>
|
</FloatingCard>
|
||||||
|
|
||||||
|
|||||||
@@ -300,42 +300,6 @@ export default function NutritionRecordsScreen() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// 渲染视图模式切换器
|
|
||||||
const renderViewModeToggle = () => (
|
|
||||||
<View style={[styles.viewModeContainer, { backgroundColor: colorTokens.pageBackgroundEmphasis }]}>
|
|
||||||
<Text style={[styles.monthTitle, { color: colorTokens.text }]}>{monthTitle}</Text>
|
|
||||||
<View style={[styles.toggleContainer, { backgroundColor: colorTokens.pageBackgroundEmphasis }]}>
|
|
||||||
<TouchableOpacity
|
|
||||||
style={[
|
|
||||||
styles.toggleButton,
|
|
||||||
viewMode === 'daily' && { backgroundColor: colorTokens.primary }
|
|
||||||
]}
|
|
||||||
onPress={() => setViewMode('daily')}
|
|
||||||
>
|
|
||||||
<Text style={[
|
|
||||||
styles.toggleText,
|
|
||||||
{ color: viewMode === 'daily' ? colorTokens.onPrimary : colorTokens.textSecondary }
|
|
||||||
]}>
|
|
||||||
按天查看
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
<TouchableOpacity
|
|
||||||
style={[
|
|
||||||
styles.toggleButton,
|
|
||||||
viewMode === 'all' && { backgroundColor: colorTokens.primary }
|
|
||||||
]}
|
|
||||||
onPress={() => setViewMode('all')}
|
|
||||||
>
|
|
||||||
<Text style={[
|
|
||||||
styles.toggleText,
|
|
||||||
{ color: viewMode === 'all' ? colorTokens.onPrimary : colorTokens.textSecondary }
|
|
||||||
]}>
|
|
||||||
全部记录
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
|
|
||||||
// 渲染日期选择器(仅在按天查看模式下显示)
|
// 渲染日期选择器(仅在按天查看模式下显示)
|
||||||
const renderDateSelector = () => {
|
const renderDateSelector = () => {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { ROUTES } from '@/constants/Routes';
|
import { ROUTES } from '@/constants/Routes';
|
||||||
|
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { BlurView } from 'expo-blur';
|
import { BlurView } from 'expo-blur';
|
||||||
import { useRouter } from 'expo-router';
|
import { useRouter } from 'expo-router';
|
||||||
@@ -20,19 +21,21 @@ interface FloatingFoodOverlayProps {
|
|||||||
export function FloatingFoodOverlay({ visible, onClose, mealType = 'dinner' }: FloatingFoodOverlayProps) {
|
export function FloatingFoodOverlay({ visible, onClose, mealType = 'dinner' }: FloatingFoodOverlayProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
|
const { pushIfAuthedElseLogin } = useAuthGuard()
|
||||||
|
|
||||||
const handleFoodLibrary = () => {
|
const handleFoodLibrary = () => {
|
||||||
onClose();
|
onClose();
|
||||||
router.push(`${ROUTES.FOOD_LIBRARY}?mealType=${mealType}`);
|
pushIfAuthedElseLogin(`${ROUTES.FOOD_LIBRARY}?mealType=${mealType}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePhotoRecognition = () => {
|
const handlePhotoRecognition = () => {
|
||||||
onClose();
|
onClose();
|
||||||
router.push(`/food/camera?mealType=${mealType}`);
|
pushIfAuthedElseLogin(`/food/camera?mealType=${mealType}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleVoiceRecord = () => {
|
const handleVoiceRecord = () => {
|
||||||
onClose();
|
onClose();
|
||||||
router.push(`${ROUTES.VOICE_RECORD}?mealType=${mealType}`);
|
pushIfAuthedElseLogin(`${ROUTES.VOICE_RECORD}?mealType=${mealType}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const menuItems = [
|
const menuItems = [
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { AnimatedNumber } from '@/components/AnimatedNumber';
|
import { AnimatedNumber } from '@/components/AnimatedNumber';
|
||||||
import { ROUTES } from '@/constants/Routes';
|
import { ROUTES } from '@/constants/Routes';
|
||||||
|
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||||
import { NutritionSummary } from '@/services/dietRecords';
|
import { NutritionSummary } from '@/services/dietRecords';
|
||||||
import { triggerLightHaptic } from '@/utils/haptics';
|
import { triggerLightHaptic } from '@/utils/haptics';
|
||||||
import { NutritionGoals, calculateRemainingCalories } from '@/utils/nutrition';
|
import { calculateRemainingCalories } from '@/utils/nutrition';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import { router } from 'expo-router';
|
import { router } from 'expo-router';
|
||||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
@@ -14,8 +15,6 @@ const AnimatedCircle = Animated.createAnimatedComponent(Circle);
|
|||||||
|
|
||||||
export type NutritionRadarCardProps = {
|
export type NutritionRadarCardProps = {
|
||||||
nutritionSummary: NutritionSummary | null;
|
nutritionSummary: NutritionSummary | null;
|
||||||
/** 营养目标 */
|
|
||||||
nutritionGoals?: NutritionGoals;
|
|
||||||
/** 基础代谢消耗的卡路里 */
|
/** 基础代谢消耗的卡路里 */
|
||||||
burnedCalories?: number;
|
burnedCalories?: number;
|
||||||
/** 基础代谢率 */
|
/** 基础代谢率 */
|
||||||
@@ -25,8 +24,6 @@ export type NutritionRadarCardProps = {
|
|||||||
|
|
||||||
/** 动画重置令牌 */
|
/** 动画重置令牌 */
|
||||||
resetToken?: number;
|
resetToken?: number;
|
||||||
/** 餐次点击回调 */
|
|
||||||
onMealPress?: (mealType: 'breakfast' | 'lunch' | 'dinner' | 'snack') => void;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 简化的圆环进度组件
|
// 简化的圆环进度组件
|
||||||
@@ -97,16 +94,15 @@ const SimpleRingProgress = ({
|
|||||||
|
|
||||||
export function NutritionRadarCard({
|
export function NutritionRadarCard({
|
||||||
nutritionSummary,
|
nutritionSummary,
|
||||||
nutritionGoals,
|
|
||||||
burnedCalories = 1618,
|
burnedCalories = 1618,
|
||||||
basalMetabolism,
|
basalMetabolism,
|
||||||
activeCalories,
|
activeCalories,
|
||||||
|
|
||||||
resetToken,
|
resetToken,
|
||||||
onMealPress
|
|
||||||
}: NutritionRadarCardProps) {
|
}: NutritionRadarCardProps) {
|
||||||
const [currentMealType] = useState<'breakfast' | 'lunch' | 'dinner' | 'snack'>('breakfast');
|
const [currentMealType] = useState<'breakfast' | 'lunch' | 'dinner' | 'snack'>('breakfast');
|
||||||
|
|
||||||
|
const { pushIfAuthedElseLogin } = useAuthGuard()
|
||||||
|
|
||||||
const nutritionStats = useMemo(() => {
|
const nutritionStats = useMemo(() => {
|
||||||
return [
|
return [
|
||||||
{ label: '热量', value: nutritionSummary ? `${Math.round(nutritionSummary.totalCalories)} 千卡` : '0 千卡', color: '#FF6B6B' },
|
{ label: '热量', value: nutritionSummary ? `${Math.round(nutritionSummary.totalCalories)} 千卡` : '0 千卡', color: '#FF6B6B' },
|
||||||
@@ -225,7 +221,7 @@ export function NutritionRadarCard({
|
|||||||
style={styles.foodOptionItem}
|
style={styles.foodOptionItem}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
triggerLightHaptic();
|
triggerLightHaptic();
|
||||||
router.push(`/food/camera?mealType=${currentMealType}`);
|
pushIfAuthedElseLogin(`/food/camera?mealType=${currentMealType}`);
|
||||||
}}
|
}}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
>
|
>
|
||||||
@@ -242,7 +238,7 @@ export function NutritionRadarCard({
|
|||||||
style={styles.foodOptionItem}
|
style={styles.foodOptionItem}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
triggerLightHaptic();
|
triggerLightHaptic();
|
||||||
router.push(`${ROUTES.FOOD_LIBRARY}?mealType=${currentMealType}`);
|
pushIfAuthedElseLogin(`${ROUTES.FOOD_LIBRARY}?mealType=${currentMealType}`);
|
||||||
}}
|
}}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
>
|
>
|
||||||
@@ -259,7 +255,7 @@ export function NutritionRadarCard({
|
|||||||
style={styles.foodOptionItem}
|
style={styles.foodOptionItem}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
triggerLightHaptic();
|
triggerLightHaptic();
|
||||||
router.push(`${ROUTES.VOICE_RECORD}?mealType=${currentMealType}`);
|
pushIfAuthedElseLogin(`${ROUTES.VOICE_RECORD}?mealType=${currentMealType}`);
|
||||||
}}
|
}}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,32 +1,63 @@
|
|||||||
import React from 'react';
|
import React, { useState, useCallback, useRef } from 'react';
|
||||||
import { StyleSheet } from 'react-native';
|
import { useFocusEffect } from '@react-navigation/native';
|
||||||
import HealthDataCard from './HealthDataCard';
|
import HealthDataCard from './HealthDataCard';
|
||||||
|
import { fetchOxygenSaturation } from '@/utils/health';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
interface OxygenSaturationCardProps {
|
interface OxygenSaturationCardProps {
|
||||||
resetToken: number;
|
|
||||||
style?: object;
|
style?: object;
|
||||||
oxygenSaturation?: number | null;
|
selectedDate?: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
const OxygenSaturationCard: React.FC<OxygenSaturationCardProps> = ({
|
const OxygenSaturationCard: React.FC<OxygenSaturationCardProps> = ({
|
||||||
resetToken,
|
|
||||||
style,
|
style,
|
||||||
oxygenSaturation
|
selectedDate
|
||||||
}) => {
|
}) => {
|
||||||
|
const [oxygenSaturation, setOxygenSaturation] = useState<number | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const loadingRef = useRef(false);
|
||||||
|
|
||||||
|
// 获取血氧饱和度数据 - 在页面聚焦、日期变化时触发
|
||||||
|
useFocusEffect(
|
||||||
|
useCallback(() => {
|
||||||
|
const loadOxygenSaturationData = async () => {
|
||||||
|
const dateToUse = selectedDate || new Date();
|
||||||
|
|
||||||
|
// 防止重复请求
|
||||||
|
if (loadingRef.current) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
loadingRef.current = true;
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
startDate: dayjs(dateToUse).startOf('day').toDate().toISOString(),
|
||||||
|
endDate: dayjs(dateToUse).endOf('day').toDate().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
const data = await fetchOxygenSaturation(options);
|
||||||
|
setOxygenSaturation(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('OxygenSaturationCard: 获取血氧饱和度数据失败:', error);
|
||||||
|
setOxygenSaturation(null);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
loadingRef.current = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadOxygenSaturationData();
|
||||||
|
}, [selectedDate])
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HealthDataCard
|
<HealthDataCard
|
||||||
title="血氧饱和度"
|
title="血氧饱和度"
|
||||||
value={oxygenSaturation !== null && oxygenSaturation !== undefined ? oxygenSaturation.toFixed(1) : '--'}
|
value={loading ? '--' : (oxygenSaturation !== null && oxygenSaturation !== undefined ? oxygenSaturation.toFixed(1) : '--')}
|
||||||
unit="%"
|
unit="%"
|
||||||
style={style}
|
style={style}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
container: {
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default OxygenSaturationCard;
|
export default OxygenSaturationCard;
|
||||||
@@ -43,7 +43,9 @@ export function WeightHistoryCard() {
|
|||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (isLoggedIn) {
|
||||||
loadWeightHistory();
|
loadWeightHistory();
|
||||||
|
}
|
||||||
}, [userProfile?.weight, isLoggedIn]);
|
}, [userProfile?.weight, isLoggedIn]);
|
||||||
|
|
||||||
const loadWeightHistory = async () => {
|
const loadWeightHistory = async () => {
|
||||||
@@ -67,71 +69,36 @@ export function WeightHistoryCard() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// 如果没有体重数据,显示引导卡片
|
|
||||||
if (!hasWeight) {
|
|
||||||
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}
|
|
||||||
/>
|
|
||||||
<Text style={styles.cardTitle}>体重记录</Text>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View style={styles.emptyContent}>
|
|
||||||
<Text style={styles.emptyTitle}>开始记录你的体重变化</Text>
|
|
||||||
<Text style={styles.emptyDescription}>
|
|
||||||
记录体重变化,追踪你的健康进展
|
|
||||||
</Text>
|
|
||||||
<TouchableOpacity
|
|
||||||
style={styles.recordButton}
|
|
||||||
onPress={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
navigateToCoach();
|
|
||||||
}}
|
|
||||||
activeOpacity={0.8}
|
|
||||||
>
|
|
||||||
<Ionicons name="add" size={18} color="#192126" />
|
|
||||||
<Text style={styles.recordButtonText}>记录</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</TouchableOpacity>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理体重历史数据
|
// 处理体重历史数据
|
||||||
const sortedHistory = [...weightHistory]
|
const sortedHistory = [...weightHistory]
|
||||||
.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime())
|
.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime())
|
||||||
.slice(-7); // 只显示最近7条记录
|
.slice(-7); // 只显示最近7条记录
|
||||||
|
|
||||||
if (sortedHistory.length === 0) {
|
// return (
|
||||||
return (
|
// <TouchableOpacity style={styles.card} onPress={navigateToWeightRecords} activeOpacity={0.8}>
|
||||||
<TouchableOpacity style={styles.card} onPress={navigateToWeightRecords} activeOpacity={0.8}>
|
// <View style={styles.cardHeader}>
|
||||||
<View style={styles.cardHeader}>
|
// <Text style={styles.cardTitle}>体重记录</Text>
|
||||||
<Text style={styles.cardTitle}>体重记录</Text>
|
// </View>
|
||||||
</View>
|
|
||||||
|
|
||||||
<View style={styles.emptyContent}>
|
// <View style={styles.emptyContent}>
|
||||||
<Text style={styles.emptyDescription}>
|
// <Text style={styles.emptyDescription}>
|
||||||
暂无体重记录,点击下方按钮开始记录
|
// 暂无体重记录,点击下方按钮开始记录
|
||||||
</Text>
|
// </Text>
|
||||||
<TouchableOpacity
|
// <TouchableOpacity
|
||||||
style={styles.recordButton}
|
// style={styles.recordButton}
|
||||||
onPress={(e) => {
|
// onPress={(e) => {
|
||||||
e.stopPropagation();
|
// e.stopPropagation();
|
||||||
navigateToCoach();
|
// navigateToCoach();
|
||||||
}}
|
// }}
|
||||||
activeOpacity={0.8}
|
// activeOpacity={0.8}
|
||||||
>
|
// >
|
||||||
<Ionicons name="add" size={18} color="#FFFFFF" />
|
// <Ionicons name="add" size={18} color="#FFFFFF" />
|
||||||
<Text style={styles.recordButtonText}>记录体重</Text>
|
// <Text style={styles.recordButtonText}>记录体重</Text>
|
||||||
</TouchableOpacity>
|
// </TouchableOpacity>
|
||||||
</View>
|
// </View>
|
||||||
</TouchableOpacity>
|
// </TouchableOpacity>
|
||||||
);
|
// );
|
||||||
}
|
// }
|
||||||
|
|
||||||
// 生成图表数据
|
// 生成图表数据
|
||||||
const weights = sortedHistory.map(item => parseFloat(item.weight));
|
const weights = sortedHistory.map(item => parseFloat(item.weight));
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ export interface HealthData {
|
|||||||
activeCalories: number | null;
|
activeCalories: number | null;
|
||||||
basalEnergyBurned: number | null;
|
basalEnergyBurned: number | null;
|
||||||
hrv: number | null;
|
hrv: number | null;
|
||||||
oxygenSaturation: number | null;
|
|
||||||
heartRate: number | null;
|
heartRate: number | null;
|
||||||
activeEnergyBurned: number;
|
activeEnergyBurned: number;
|
||||||
activeCaloriesGoal: number;
|
activeCaloriesGoal: number;
|
||||||
|
|||||||
@@ -216,8 +216,6 @@ export type TodayHealthData = {
|
|||||||
exerciseMinutesGoal: number;
|
exerciseMinutesGoal: number;
|
||||||
standHours: number;
|
standHours: number;
|
||||||
standHoursGoal: number;
|
standHoursGoal: number;
|
||||||
// 新增血氧饱和度和心率数据
|
|
||||||
oxygenSaturation: number | null;
|
|
||||||
heartRate: number | null;
|
heartRate: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -529,7 +527,7 @@ async function fetchActivitySummary(options: HealthDataOptions): Promise<HealthA
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchOxygenSaturation(options: HealthDataOptions): Promise<number | null> {
|
export async function fetchOxygenSaturation(options: HealthDataOptions): Promise<number | null> {
|
||||||
try {
|
try {
|
||||||
const result = await HealthKitManager.getOxygenSaturationSamples(options);
|
const result = await HealthKitManager.getOxygenSaturationSamples(options);
|
||||||
|
|
||||||
@@ -618,7 +616,6 @@ function getDefaultHealthData(): TodayHealthData {
|
|||||||
exerciseMinutesGoal: 30,
|
exerciseMinutesGoal: 30,
|
||||||
standHours: 0,
|
standHours: 0,
|
||||||
standHoursGoal: 12,
|
standHoursGoal: 12,
|
||||||
oxygenSaturation: null,
|
|
||||||
heartRate: null,
|
heartRate: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -636,14 +633,12 @@ export async function fetchHealthDataForDate(date: Date): Promise<TodayHealthDat
|
|||||||
basalEnergyBurned,
|
basalEnergyBurned,
|
||||||
hrv,
|
hrv,
|
||||||
activitySummary,
|
activitySummary,
|
||||||
oxygenSaturation,
|
|
||||||
heartRate
|
heartRate
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
fetchActiveEnergyBurned(options),
|
fetchActiveEnergyBurned(options),
|
||||||
fetchBasalEnergyBurned(options),
|
fetchBasalEnergyBurned(options),
|
||||||
fetchHeartRateVariability(options),
|
fetchHeartRateVariability(options),
|
||||||
fetchActivitySummary(options),
|
fetchActivitySummary(options),
|
||||||
fetchOxygenSaturation(options),
|
|
||||||
fetchHeartRate(options)
|
fetchHeartRate(options)
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -657,7 +652,6 @@ export async function fetchHealthDataForDate(date: Date): Promise<TodayHealthDat
|
|||||||
exerciseMinutesGoal: Math.round(activitySummary?.appleExerciseTimeGoal || 30),
|
exerciseMinutesGoal: Math.round(activitySummary?.appleExerciseTimeGoal || 30),
|
||||||
standHours: Math.round(activitySummary?.appleStandHours || 0),
|
standHours: Math.round(activitySummary?.appleStandHours || 0),
|
||||||
standHoursGoal: Math.round(activitySummary?.appleStandHoursGoal || 12),
|
standHoursGoal: Math.round(activitySummary?.appleStandHoursGoal || 12),
|
||||||
oxygenSaturation,
|
|
||||||
heartRate
|
heartRate
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
Reference in New Issue
Block a user