feat(notifications): 新增晚餐和心情提醒功能,支持HRV压力检测和后台处理
- 新增晚餐提醒(18:00)和心情提醒(21:00)的定时通知 - 实现基于HRV数据的压力检测和智能鼓励通知 - 添加后台任务处理支持,修改iOS后台模式为processing - 优化营养记录页面使用Redux状态管理,支持实时数据更新 - 重构卡路里计算公式,移除目标卡路里概念,改为基代+运动-饮食 - 新增营养目标动态计算功能,基于用户身体数据智能推荐 - 完善通知点击跳转逻辑,支持多种提醒类型的路由处理
This commit is contained in:
@@ -5,14 +5,22 @@ import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { DietRecord, deleteDietRecord, getDietRecords } from '@/services/dietRecords';
|
||||
import { DietRecord } from '@/services/dietRecords';
|
||||
import { selectHealthDataByDate } from '@/store/healthSlice';
|
||||
import { fetchDailyNutritionData, selectNutritionSummaryByDate } from '@/store/nutritionSlice';
|
||||
import {
|
||||
deleteNutritionRecord,
|
||||
fetchDailyNutritionData,
|
||||
fetchNutritionRecords,
|
||||
selectNutritionLoading,
|
||||
selectNutritionRecordsByDate,
|
||||
selectNutritionSummaryByDate
|
||||
} from '@/store/nutritionSlice';
|
||||
import { getMonthDaysZh, getMonthTitleZh, getTodayIndexInMonth } from '@/utils/date';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useFocusEffect } from '@react-navigation/native';
|
||||
import dayjs from 'dayjs';
|
||||
import { router } from 'expo-router';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
FlatList,
|
||||
@@ -35,90 +43,135 @@ export default function NutritionRecordsScreen() {
|
||||
const [selectedIndex, setSelectedIndex] = useState(getTodayIndexInMonth());
|
||||
const monthTitle = getMonthTitleZh();
|
||||
|
||||
// 获取当前选中日期
|
||||
const getCurrentSelectedDate = () => {
|
||||
// 获取当前选中日期 - 使用 useMemo 避免每次渲染都创建新对象
|
||||
const currentSelectedDate = useMemo(() => {
|
||||
return days[selectedIndex]?.date?.toDate() ?? new Date();
|
||||
};
|
||||
}, [selectedIndex, days]);
|
||||
|
||||
const currentSelectedDate = getCurrentSelectedDate();
|
||||
const currentSelectedDateString = dayjs(currentSelectedDate).format('YYYY-MM-DD');
|
||||
const currentSelectedDateString = useMemo(() => {
|
||||
return dayjs(currentSelectedDate).format('YYYY-MM-DD');
|
||||
}, [currentSelectedDate]);
|
||||
|
||||
// 从 Redux 获取数据
|
||||
const healthData = useAppSelector(selectHealthDataByDate(currentSelectedDateString));
|
||||
const nutritionSummary = useAppSelector(selectNutritionSummaryByDate(currentSelectedDateString));
|
||||
const userProfile = useAppSelector((state) => state.user.profile);
|
||||
|
||||
// 从 Redux 获取营养记录数据
|
||||
const nutritionRecords = useAppSelector(selectNutritionRecordsByDate(currentSelectedDateString));
|
||||
const nutritionLoading = useAppSelector(selectNutritionLoading);
|
||||
|
||||
// 视图模式:按天查看 vs 全部查看
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('daily');
|
||||
|
||||
// 数据状态
|
||||
const [records, setRecords] = useState<DietRecord[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
// 全部记录模式的本地状态
|
||||
const [allRecords, setAllRecords] = useState<DietRecord[]>([]);
|
||||
const [allRecordsLoading, setAllRecordsLoading] = useState(false);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [hasMoreData, setHasMoreData] = useState(true);
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
// 加载记录数据
|
||||
const loadRecords = async (isRefresh = false, loadMore = false) => {
|
||||
try {
|
||||
if (isRefresh) {
|
||||
setRefreshing(true);
|
||||
setPage(1);
|
||||
} else if (loadMore) {
|
||||
// 加载更多时不显示loading
|
||||
} else {
|
||||
setLoading(true);
|
||||
}
|
||||
// 根据视图模式选择使用的数据
|
||||
const displayRecords = viewMode === 'daily' ? nutritionRecords : allRecords;
|
||||
const loading = viewMode === 'daily' ? nutritionLoading.records : allRecordsLoading;
|
||||
|
||||
const currentPage = isRefresh ? 1 : (loadMore ? page + 1 : 1);
|
||||
|
||||
let startDate: string | undefined;
|
||||
let endDate: string | undefined;
|
||||
|
||||
// 页面聚焦时自动刷新数据
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
console.log('营养记录页面聚焦,刷新数据...');
|
||||
if (viewMode === 'daily') {
|
||||
// 按天查看时,获取选中日期的数据
|
||||
startDate = days[selectedIndex]?.date.startOf('day').toISOString();
|
||||
endDate = days[selectedIndex]?.date.endOf('day').toISOString();
|
||||
}
|
||||
|
||||
const data = await getDietRecords({
|
||||
startDate,
|
||||
endDate,
|
||||
page: currentPage,
|
||||
limit: 10,
|
||||
});
|
||||
|
||||
if (isRefresh || currentPage === 1) {
|
||||
setRecords(data.records);
|
||||
dispatch(fetchDailyNutritionData(currentSelectedDate));
|
||||
} else {
|
||||
setRecords(prev => [...prev, ...data.records]);
|
||||
}
|
||||
// 全部记录模式:重新加载数据
|
||||
const loadAllRecords = async () => {
|
||||
try {
|
||||
setAllRecordsLoading(true);
|
||||
const response = await dispatch(fetchNutritionRecords({
|
||||
page: 1,
|
||||
limit: 10,
|
||||
append: false,
|
||||
}));
|
||||
|
||||
setHasMoreData(data.records.length === 10); // 如果返回的记录数少于limit,说明没有更多数据
|
||||
setPage(currentPage);
|
||||
} catch (error) {
|
||||
console.error('加载营养记录失败:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
};
|
||||
if (fetchNutritionRecords.fulfilled.match(response)) {
|
||||
const { records } = response.payload;
|
||||
setAllRecords(records);
|
||||
setHasMoreData(records.length === 10);
|
||||
setPage(1);
|
||||
}
|
||||
setAllRecordsLoading(false);
|
||||
} catch (error) {
|
||||
console.error('加载全部记录失败:', error);
|
||||
setAllRecordsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadAllRecords();
|
||||
}
|
||||
}, [viewMode, currentSelectedDateString, dispatch])
|
||||
);
|
||||
|
||||
// 当选中日期或视图模式变化时重新加载数据
|
||||
useEffect(() => {
|
||||
loadRecords();
|
||||
}, [selectedIndex, viewMode]);
|
||||
|
||||
// 当选中日期变化时获取营养数据
|
||||
useEffect(() => {
|
||||
if (viewMode === 'daily') {
|
||||
dispatch(fetchDailyNutritionData(currentSelectedDate));
|
||||
}
|
||||
}, [selectedIndex, viewMode, currentSelectedDate, dispatch]);
|
||||
} else {
|
||||
setPage(1); // 重置分页
|
||||
setAllRecords([]); // 清空记录
|
||||
|
||||
const onRefresh = () => {
|
||||
loadRecords(true);
|
||||
};
|
||||
// 全部记录模式:加载数据
|
||||
const loadAllRecords = async () => {
|
||||
try {
|
||||
setAllRecordsLoading(true);
|
||||
const response = await dispatch(fetchNutritionRecords({
|
||||
page: 1,
|
||||
limit: 10,
|
||||
append: false,
|
||||
}));
|
||||
|
||||
if (fetchNutritionRecords.fulfilled.match(response)) {
|
||||
const { records } = response.payload;
|
||||
setAllRecords(records);
|
||||
setHasMoreData(records.length === 10);
|
||||
}
|
||||
setAllRecordsLoading(false);
|
||||
} catch (error) {
|
||||
console.error('加载全部记录失败:', error);
|
||||
setAllRecordsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadAllRecords();
|
||||
}
|
||||
}, [viewMode, currentSelectedDateString, dispatch]);
|
||||
|
||||
const onRefresh = useCallback(async () => {
|
||||
try {
|
||||
setRefreshing(true);
|
||||
|
||||
if (viewMode === 'daily') {
|
||||
await dispatch(fetchDailyNutritionData(currentSelectedDate));
|
||||
} else {
|
||||
// 全部记录模式:刷新数据
|
||||
setPage(1);
|
||||
const response = await dispatch(fetchNutritionRecords({
|
||||
page: 1,
|
||||
limit: 10,
|
||||
append: false,
|
||||
}));
|
||||
|
||||
if (fetchNutritionRecords.fulfilled.match(response)) {
|
||||
const { records } = response.payload;
|
||||
setAllRecords(records);
|
||||
setHasMoreData(records.length === 10);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('刷新数据失败:', error);
|
||||
} finally {
|
||||
setRefreshing(false);
|
||||
}
|
||||
}, [viewMode, currentSelectedDateString, dispatch]);
|
||||
|
||||
// 计算营养目标
|
||||
const calculateNutritionGoals = () => {
|
||||
@@ -153,21 +206,47 @@ export default function NutritionRecordsScreen() {
|
||||
|
||||
const nutritionGoals = calculateNutritionGoals();
|
||||
|
||||
const loadMoreRecords = () => {
|
||||
if (hasMoreData && !loading && !refreshing) {
|
||||
loadRecords(false, true);
|
||||
const loadMoreRecords = useCallback(async () => {
|
||||
if (hasMoreData && !loading && !refreshing && viewMode === 'all') {
|
||||
try {
|
||||
const nextPage = page + 1;
|
||||
const response = await dispatch(fetchNutritionRecords({
|
||||
page: nextPage,
|
||||
limit: 10,
|
||||
append: true,
|
||||
}));
|
||||
|
||||
if (fetchNutritionRecords.fulfilled.match(response)) {
|
||||
const { records } = response.payload;
|
||||
setAllRecords(prev => [...prev, ...records]);
|
||||
setHasMoreData(records.length === 10);
|
||||
setPage(nextPage);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载更多记录失败:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
}, [hasMoreData, loading, refreshing, viewMode, page, dispatch]);
|
||||
|
||||
// 删除记录
|
||||
const handleDeleteRecord = async (recordId: number) => {
|
||||
try {
|
||||
await deleteDietRecord(recordId);
|
||||
// 从本地状态中移除已删除的记录
|
||||
setRecords(prev => prev.filter(record => record.id !== recordId));
|
||||
if (viewMode === 'daily') {
|
||||
// 按天查看模式,使用 Redux 删除
|
||||
await dispatch(deleteNutritionRecord({
|
||||
recordId,
|
||||
dateKey: currentSelectedDateString
|
||||
}));
|
||||
} else {
|
||||
// 全部记录模式,从本地状态中移除
|
||||
await dispatch(deleteNutritionRecord({
|
||||
recordId,
|
||||
dateKey: currentSelectedDateString
|
||||
}));
|
||||
setAllRecords(prev => prev.filter(record => record.id !== recordId));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('删除营养记录失败:', error);
|
||||
// 可以添加错误提示
|
||||
}
|
||||
};
|
||||
|
||||
@@ -254,7 +333,7 @@ export default function NutritionRecordsScreen() {
|
||||
);
|
||||
}
|
||||
|
||||
if (viewMode === 'all' && records.length > 0) {
|
||||
if (viewMode === 'all' && displayRecords.length > 0) {
|
||||
return (
|
||||
<TouchableOpacity style={styles.loadMoreButton} onPress={loadMoreRecords}>
|
||||
<Text style={[styles.loadMoreText, { color: colorTokens.primary }]}>
|
||||
@@ -267,11 +346,44 @@ export default function NutritionRecordsScreen() {
|
||||
return null;
|
||||
};
|
||||
|
||||
// 根据当前时间智能判断餐次类型
|
||||
const getCurrentMealType = (): 'breakfast' | 'lunch' | 'dinner' | 'snack' => {
|
||||
const hour = new Date().getHours();
|
||||
|
||||
if (hour >= 5 && hour < 11) {
|
||||
return 'breakfast'; // 5:00-10:59 早餐
|
||||
} else if (hour >= 11 && hour < 14) {
|
||||
return 'lunch'; // 11:00-13:59 午餐
|
||||
} else if (hour >= 17 && hour < 21) {
|
||||
return 'dinner'; // 17:00-20:59 晚餐
|
||||
} else {
|
||||
return 'snack'; // 其他时间默认为零食
|
||||
}
|
||||
};
|
||||
|
||||
// 添加食物的处理函数
|
||||
const handleAddFood = () => {
|
||||
const mealType = getCurrentMealType();
|
||||
router.push(`/food-library?mealType=${mealType}`);
|
||||
};
|
||||
|
||||
// 渲染右侧添加按钮
|
||||
const renderRightButton = () => (
|
||||
<TouchableOpacity
|
||||
style={[styles.addButton, { backgroundColor: colorTokens.pageBackgroundEmphasis }]}
|
||||
onPress={handleAddFood}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Ionicons name="add" size={20} color={colorTokens.primary} />
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: colorTokens.pageBackgroundEmphasis }]}>
|
||||
<HeaderBar
|
||||
title="营养记录"
|
||||
onBack={() => router.back()}
|
||||
right={renderRightButton()}
|
||||
/>
|
||||
|
||||
{renderViewModeToggle()}
|
||||
@@ -282,7 +394,7 @@ export default function NutritionRecordsScreen() {
|
||||
metabolism={healthData?.basalEnergyBurned || 1482}
|
||||
exercise={healthData?.activeEnergyBurned || 0}
|
||||
consumed={nutritionSummary?.totalCalories || 0}
|
||||
goal={userProfile?.dailyCaloriesGoal || 200}
|
||||
goal={0}
|
||||
protein={nutritionSummary?.totalProtein || 0}
|
||||
fat={nutritionSummary?.totalFat || 0}
|
||||
carbs={nutritionSummary?.totalCarbohydrate || 0}
|
||||
@@ -300,7 +412,7 @@ export default function NutritionRecordsScreen() {
|
||||
</View>
|
||||
) : (
|
||||
<FlatList
|
||||
data={records}
|
||||
data={displayRecords}
|
||||
renderItem={({ item, index }) => renderRecord({ item, index })}
|
||||
keyExtractor={(item) => item.id.toString()}
|
||||
contentContainerStyle={[
|
||||
@@ -393,6 +505,14 @@ const styles = StyleSheet.create({
|
||||
borderRadius: 16,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: {
|
||||
width: 0,
|
||||
height: 2,
|
||||
},
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
elevation: 2,
|
||||
},
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
|
||||
Reference in New Issue
Block a user