feat: Refactor MoodCalendarScreen to use dayjs for date handling and improve calendar data generation
feat: Update FitnessRingsCard to navigate to fitness rings detail page on press feat: Modify NutritionRadarCard to enhance UI and add haptic feedback on actions feat: Add FITNESS_RINGS_DETAIL route for navigation fix: Adjust minimum fetch interval in BackgroundTaskManager for background tasks feat: Implement haptic feedback utility functions for better user experience feat: Extend health permissions to include Apple Exercise Time and Apple Stand Time feat: Add functions to fetch hourly activity, exercise, and stand data for improved health tracking feat: Enhance user preferences to manage fitness exercise minutes and active hours info dismissal
This commit is contained in:
860
app/fitness-rings-detail.tsx
Normal file
860
app/fitness-rings-detail.tsx
Normal file
@@ -0,0 +1,860 @@
|
|||||||
|
import { CircularRing } from '@/components/CircularRing';
|
||||||
|
import { ThemedView } from '@/components/ThemedView';
|
||||||
|
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||||
|
import { Colors } from '@/constants/Colors';
|
||||||
|
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||||
|
import {
|
||||||
|
fetchActivityRingsForDate,
|
||||||
|
fetchHourlyActiveCaloriesForDate,
|
||||||
|
fetchHourlyExerciseMinutesForDate,
|
||||||
|
fetchHourlyStandHoursForDate,
|
||||||
|
type ActivityRingsData,
|
||||||
|
type HourlyActivityData,
|
||||||
|
type HourlyExerciseData,
|
||||||
|
type HourlyStandData
|
||||||
|
} from '@/utils/health';
|
||||||
|
import { getFitnessExerciseMinutesInfoDismissed, setFitnessExerciseMinutesInfoDismissed } from '@/utils/userPreferences';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import DateTimePicker from '@react-native-community/datetimepicker';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import timezone from 'dayjs/plugin/timezone';
|
||||||
|
import utc from 'dayjs/plugin/utc';
|
||||||
|
import weekday from 'dayjs/plugin/weekday';
|
||||||
|
import { router } from 'expo-router';
|
||||||
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Animated,
|
||||||
|
Modal,
|
||||||
|
Platform,
|
||||||
|
Pressable,
|
||||||
|
ScrollView,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
TouchableOpacity,
|
||||||
|
View
|
||||||
|
} from 'react-native';
|
||||||
|
|
||||||
|
// 配置 dayjs 插件
|
||||||
|
dayjs.extend(utc);
|
||||||
|
dayjs.extend(timezone);
|
||||||
|
dayjs.extend(weekday);
|
||||||
|
|
||||||
|
// 设置默认时区为中国时区
|
||||||
|
dayjs.tz.setDefault('Asia/Shanghai');
|
||||||
|
|
||||||
|
type WeekData = {
|
||||||
|
date: Date;
|
||||||
|
data: ActivityRingsData | null;
|
||||||
|
isToday: boolean;
|
||||||
|
dayName: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function FitnessRingsDetailScreen() {
|
||||||
|
const colorScheme = useColorScheme();
|
||||||
|
const [weekData, setWeekData] = useState<WeekData[]>([]);
|
||||||
|
const [selectedDate, setSelectedDate] = useState<Date>(new Date());
|
||||||
|
const [selectedDayData, setSelectedDayData] = useState<ActivityRingsData | null>(null);
|
||||||
|
const [datePickerVisible, setDatePickerVisible] = useState(false);
|
||||||
|
const [pickerDate, setPickerDate] = useState<Date>(new Date());
|
||||||
|
// 每小时数据状态
|
||||||
|
const [hourlyCaloriesData, setHourlyCaloriesData] = useState<HourlyActivityData[]>([]);
|
||||||
|
const [hourlyExerciseData, setHourlyExerciseData] = useState<HourlyExerciseData[]>([]);
|
||||||
|
const [hourlyStandData, setHourlyStandData] = useState<HourlyStandData[]>([]);
|
||||||
|
const [showExerciseInfo, setShowExerciseInfo] = useState(true);
|
||||||
|
const exerciseInfoAnim = useRef(new Animated.Value(1)).current;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// 加载周数据和选中日期的详细数据
|
||||||
|
loadWeekData(selectedDate);
|
||||||
|
loadSelectedDayData();
|
||||||
|
loadExerciseInfoPreference();
|
||||||
|
}, [selectedDate]);
|
||||||
|
|
||||||
|
const loadExerciseInfoPreference = async () => {
|
||||||
|
try {
|
||||||
|
const dismissed = await getFitnessExerciseMinutesInfoDismissed();
|
||||||
|
setShowExerciseInfo(!dismissed);
|
||||||
|
if (!dismissed) {
|
||||||
|
exerciseInfoAnim.setValue(1);
|
||||||
|
} else {
|
||||||
|
exerciseInfoAnim.setValue(0);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载锻炼分钟说明偏好失败:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadWeekData = async (targetDate: Date) => {
|
||||||
|
const target = dayjs(targetDate).tz('Asia/Shanghai');
|
||||||
|
const today = dayjs().tz('Asia/Shanghai');
|
||||||
|
const weekDays = [];
|
||||||
|
|
||||||
|
// 获取目标日期所在周的数据 (周一到周日)
|
||||||
|
// 使用 weekday() 确保周一为一周的开始 (0=Monday, 6=Sunday)
|
||||||
|
const startOfWeek = target.weekday(0); // 周一开始
|
||||||
|
|
||||||
|
for (let i = 0; i < 7; i++) {
|
||||||
|
const currentDay = startOfWeek.add(i, 'day');
|
||||||
|
const isToday = currentDay.isSame(today, 'day');
|
||||||
|
const dayNames = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const activityRingsData = await fetchActivityRingsForDate(currentDay.toDate());
|
||||||
|
weekDays.push({
|
||||||
|
date: currentDay.toDate(),
|
||||||
|
data: activityRingsData,
|
||||||
|
isToday,
|
||||||
|
dayName: dayNames[i]
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch activity rings data for', currentDay.format('YYYY-MM-DD'), error);
|
||||||
|
weekDays.push({
|
||||||
|
date: currentDay.toDate(),
|
||||||
|
data: null,
|
||||||
|
isToday,
|
||||||
|
dayName: dayNames[i]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setWeekData(weekDays);
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadSelectedDayData = async () => {
|
||||||
|
try {
|
||||||
|
// 并行获取活动圆环数据和每小时详细数据
|
||||||
|
const [activityRingsData, hourlyCalories, hourlyExercise, hourlyStand] = await Promise.all([
|
||||||
|
fetchActivityRingsForDate(selectedDate),
|
||||||
|
fetchHourlyActiveCaloriesForDate(selectedDate),
|
||||||
|
fetchHourlyExerciseMinutesForDate(selectedDate),
|
||||||
|
fetchHourlyStandHoursForDate(selectedDate)
|
||||||
|
]);
|
||||||
|
|
||||||
|
setSelectedDayData(activityRingsData);
|
||||||
|
setHourlyCaloriesData(hourlyCalories);
|
||||||
|
setHourlyExerciseData(hourlyExercise);
|
||||||
|
setHourlyStandData(hourlyStand);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch selected day activity rings data', error);
|
||||||
|
setSelectedDayData(null);
|
||||||
|
setHourlyCaloriesData([]);
|
||||||
|
setHourlyExerciseData([]);
|
||||||
|
setHourlyStandData([]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 日期选择器相关函数
|
||||||
|
const openDatePicker = () => {
|
||||||
|
setPickerDate(selectedDate);
|
||||||
|
setDatePickerVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeDatePicker = () => setDatePickerVisible(false);
|
||||||
|
|
||||||
|
const onConfirmDate = async (date: Date) => {
|
||||||
|
const today = dayjs().tz('Asia/Shanghai').startOf('day');
|
||||||
|
const picked = dayjs(date).tz('Asia/Shanghai').startOf('day');
|
||||||
|
const finalDate = picked.isAfter(today) ? today.toDate() : picked.toDate();
|
||||||
|
|
||||||
|
setSelectedDate(finalDate);
|
||||||
|
closeDatePicker();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 格式化头部显示的日期
|
||||||
|
const formatHeaderDate = (date: Date) => {
|
||||||
|
const dayJsDate = dayjs(date).tz('Asia/Shanghai');
|
||||||
|
return `${dayJsDate.format('YYYY年MM月DD日')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderWeekRingItem = (item: WeekData, index: number) => {
|
||||||
|
const isSelected = dayjs(item.date).tz('Asia/Shanghai').isSame(dayjs(selectedDate).tz('Asia/Shanghai'), 'day');
|
||||||
|
|
||||||
|
// 使用默认值确保即使没有数据也能显示圆环
|
||||||
|
const data = item.data || {
|
||||||
|
activeEnergyBurned: 0,
|
||||||
|
activeEnergyBurnedGoal: 350,
|
||||||
|
appleExerciseTime: 0,
|
||||||
|
appleExerciseTimeGoal: 30,
|
||||||
|
appleStandHours: 0,
|
||||||
|
appleStandHoursGoal: 12,
|
||||||
|
};
|
||||||
|
|
||||||
|
const { activeEnergyBurned, activeEnergyBurnedGoal, appleExerciseTime, appleExerciseTimeGoal, appleStandHours, appleStandHoursGoal } = data;
|
||||||
|
|
||||||
|
// 计算进度百分比
|
||||||
|
const caloriesProgress = Math.min(1, Math.max(0, activeEnergyBurned / activeEnergyBurnedGoal));
|
||||||
|
const exerciseProgress = Math.min(1, Math.max(0, appleExerciseTime / appleExerciseTimeGoal));
|
||||||
|
const standProgress = Math.min(1, Math.max(0, appleStandHours / appleStandHoursGoal));
|
||||||
|
|
||||||
|
// 检查是否完成了所有目标
|
||||||
|
const isComplete = caloriesProgress >= 1 && exerciseProgress >= 1 && standProgress >= 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={index}
|
||||||
|
style={[styles.weekRingItem, isSelected && styles.weekRingItemSelected]}
|
||||||
|
onPress={() => setSelectedDate(item.date)}
|
||||||
|
>
|
||||||
|
<View style={styles.weekRingContainer}>
|
||||||
|
{/* {isComplete && (
|
||||||
|
<View style={styles.weekStarContainer}>
|
||||||
|
<Text style={styles.weekStarIcon}>✓</Text>
|
||||||
|
</View>
|
||||||
|
)} */}
|
||||||
|
|
||||||
|
<View style={styles.weekRingsWrapper}>
|
||||||
|
{/* 外圈 - 活动卡路里 (红色) */}
|
||||||
|
<View style={styles.ringPosition}>
|
||||||
|
<CircularRing
|
||||||
|
size={50}
|
||||||
|
strokeWidth={3}
|
||||||
|
trackColor="rgba(255, 59, 48, 0.15)"
|
||||||
|
progressColor="#FF3B30"
|
||||||
|
progress={caloriesProgress}
|
||||||
|
showCenterText={false}
|
||||||
|
startAngleDeg={-90}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 中圈 - 锻炼分钟 (橙色) */}
|
||||||
|
<View style={styles.ringPosition}>
|
||||||
|
<CircularRing
|
||||||
|
size={36}
|
||||||
|
strokeWidth={2.5}
|
||||||
|
trackColor="rgba(255, 149, 0, 0.15)"
|
||||||
|
progressColor="#FF9500"
|
||||||
|
progress={exerciseProgress}
|
||||||
|
showCenterText={false}
|
||||||
|
startAngleDeg={-90}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 内圈 - 站立小时 (蓝色) */}
|
||||||
|
<View style={styles.ringPosition}>
|
||||||
|
<CircularRing
|
||||||
|
size={22}
|
||||||
|
strokeWidth={2}
|
||||||
|
trackColor="rgba(0, 122, 255, 0.15)"
|
||||||
|
progressColor="#007AFF"
|
||||||
|
progress={standProgress}
|
||||||
|
showCenterText={false}
|
||||||
|
startAngleDeg={-90}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text style={[
|
||||||
|
styles.weekDayNumber,
|
||||||
|
item.isToday && styles.weekTodayNumber,
|
||||||
|
isSelected && styles.weekSelectedNumber,
|
||||||
|
{ color: isSelected ? '#007AFF' : (item.isToday ? '#007AFF' : Colors[colorScheme ?? 'light'].text) }
|
||||||
|
]}>
|
||||||
|
{dayjs(item.date).tz('Asia/Shanghai').date()}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text style={[
|
||||||
|
styles.weekDayLabel,
|
||||||
|
item.isToday && styles.weekTodayLabel,
|
||||||
|
isSelected && styles.weekSelectedLabel,
|
||||||
|
{ color: isSelected ? '#007AFF' : (item.isToday ? '#007AFF' : Colors[colorScheme ?? 'light'].tabIconDefault) }
|
||||||
|
]}>
|
||||||
|
{item.dayName}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getClosedRingCount = () => {
|
||||||
|
let count = 0;
|
||||||
|
weekData.forEach(item => {
|
||||||
|
// 使用默认值处理空数据情况
|
||||||
|
const data = item.data || {
|
||||||
|
activeEnergyBurned: 0,
|
||||||
|
activeEnergyBurnedGoal: 350,
|
||||||
|
appleExerciseTime: 0,
|
||||||
|
appleExerciseTimeGoal: 30,
|
||||||
|
appleStandHours: 0,
|
||||||
|
appleStandHoursGoal: 12,
|
||||||
|
};
|
||||||
|
|
||||||
|
const { activeEnergyBurned, activeEnergyBurnedGoal, appleExerciseTime, appleExerciseTimeGoal, appleStandHours, appleStandHoursGoal } = data;
|
||||||
|
const caloriesComplete = activeEnergyBurned >= activeEnergyBurnedGoal;
|
||||||
|
const exerciseComplete = appleExerciseTime >= appleExerciseTimeGoal;
|
||||||
|
const standComplete = appleStandHours >= appleStandHoursGoal;
|
||||||
|
|
||||||
|
if (caloriesComplete && exerciseComplete && standComplete) {
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return count;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKnowButtonPress = async () => {
|
||||||
|
try {
|
||||||
|
await setFitnessExerciseMinutesInfoDismissed(true);
|
||||||
|
Animated.timing(exerciseInfoAnim, {
|
||||||
|
toValue: 0,
|
||||||
|
duration: 300,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}).start(() => {
|
||||||
|
setShowExerciseInfo(false);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('保存锻炼分钟说明偏好失败:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 渲染简单的柱状图
|
||||||
|
const renderBarChart = (data: number[], maxValue: number, color: string, unit: string) => {
|
||||||
|
// 确保始终有24小时的数据,没有数据时用0填充
|
||||||
|
const chartData = Array.from({ length: 24 }, (_, index) => {
|
||||||
|
if (data && data.length > index) {
|
||||||
|
return data[index] || 0;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 计算最大值,如果所有数据都是0,使用传入的maxValue作为参考
|
||||||
|
const maxChartValue = Math.max(...chartData, 1); // 确保最小值为1,避免除零
|
||||||
|
const effectiveMaxValue = Math.max(maxChartValue, maxValue);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.chartContainer}>
|
||||||
|
<View style={styles.chartBars}>
|
||||||
|
{chartData.map((value, index) => {
|
||||||
|
const height = Math.max(2, (value / effectiveMaxValue) * 40); // 最小高度2,最大40
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
key={index}
|
||||||
|
style={[
|
||||||
|
styles.chartBar,
|
||||||
|
{
|
||||||
|
height: value > 0 ? height : 2, // 没有数据时显示最小高度的灰色条
|
||||||
|
backgroundColor: value > 0 ? color : '#E5E5EA',
|
||||||
|
opacity: value > 0 ? 1 : 0.5
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
<View style={styles.chartLabels}>
|
||||||
|
<Text style={styles.chartLabel}>00:00</Text>
|
||||||
|
<Text style={styles.chartLabel}>06:00</Text>
|
||||||
|
<Text style={styles.chartLabel}>12:00</Text>
|
||||||
|
<Text style={styles.chartLabel}>18:00</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderSelectedDayDetail = () => {
|
||||||
|
// 使用默认值确保即使没有数据也能显示图表
|
||||||
|
const data = selectedDayData || {
|
||||||
|
activeEnergyBurned: 0,
|
||||||
|
activeEnergyBurnedGoal: 350,
|
||||||
|
appleExerciseTime: 0,
|
||||||
|
appleExerciseTimeGoal: 30,
|
||||||
|
appleStandHours: 0,
|
||||||
|
appleStandHoursGoal: 12,
|
||||||
|
};
|
||||||
|
|
||||||
|
const { activeEnergyBurned, activeEnergyBurnedGoal, appleExerciseTime, appleExerciseTimeGoal, appleStandHours, appleStandHoursGoal } = data;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.detailContainer}>
|
||||||
|
{/* 活动热量卡片 */}
|
||||||
|
<View style={styles.metricCard}>
|
||||||
|
<View style={styles.cardHeader}>
|
||||||
|
<Text style={styles.cardTitle}>活动热量</Text>
|
||||||
|
<TouchableOpacity style={styles.helpButton}>
|
||||||
|
<Text style={styles.helpIcon}>?</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.cardValue}>
|
||||||
|
<Text style={[styles.valueText, { color: '#FF3B30' }]}>
|
||||||
|
{Math.round(activeEnergyBurned)}/{activeEnergyBurnedGoal}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.unitText}>千卡</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text style={styles.cardSubtext}>
|
||||||
|
{Math.round(activeEnergyBurned)}千卡
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{renderBarChart(
|
||||||
|
hourlyCaloriesData.map(h => h.calories),
|
||||||
|
Math.max(activeEnergyBurnedGoal / 24, 1),
|
||||||
|
'#FF3B30',
|
||||||
|
'千卡'
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 锻炼分钟卡片 */}
|
||||||
|
<View style={styles.metricCard}>
|
||||||
|
<View style={styles.cardHeader}>
|
||||||
|
<Text style={styles.cardTitle}>锻炼分钟数</Text>
|
||||||
|
<TouchableOpacity style={styles.helpButton}>
|
||||||
|
<Text style={styles.helpIcon}>?</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.cardValue}>
|
||||||
|
<Text style={[styles.valueText, { color: '#FF9500' }]}>
|
||||||
|
{Math.round(appleExerciseTime)}/{appleExerciseTimeGoal}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.unitText}>分钟</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text style={styles.cardSubtext}>
|
||||||
|
{Math.round(appleExerciseTime)}分钟
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{renderBarChart(
|
||||||
|
hourlyExerciseData.map(h => h.minutes),
|
||||||
|
Math.max(appleExerciseTimeGoal / 8, 1),
|
||||||
|
'#FF9500',
|
||||||
|
'分钟'
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 锻炼分钟说明 */}
|
||||||
|
{showExerciseInfo && (
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
styles.exerciseInfo,
|
||||||
|
{
|
||||||
|
opacity: exerciseInfoAnim,
|
||||||
|
transform: [
|
||||||
|
{
|
||||||
|
scale: exerciseInfoAnim.interpolate({
|
||||||
|
inputRange: [0, 1],
|
||||||
|
outputRange: [0.95, 1],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text style={styles.exerciseTitle}>锻炼分钟数:</Text>
|
||||||
|
<Text style={styles.exerciseDesc}>
|
||||||
|
进行强度不低于"快走"的运动锻炼,就会积累对应时长的锻炼分钟数。
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.exerciseRecommendation}>
|
||||||
|
世卫组织推荐的成年人每天至少保持30分钟以上的中高强度运动。
|
||||||
|
</Text>
|
||||||
|
<TouchableOpacity style={styles.knowButton} onPress={handleKnowButtonPress}>
|
||||||
|
<Text style={styles.knowButtonText}>知道了</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</Animated.View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 活动小时数卡片 */}
|
||||||
|
<View style={styles.metricCard}>
|
||||||
|
<View style={styles.cardHeader}>
|
||||||
|
<Text style={styles.cardTitle}>活动小时数</Text>
|
||||||
|
<TouchableOpacity style={styles.helpButton}>
|
||||||
|
<Text style={styles.helpIcon}>?</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.cardValue}>
|
||||||
|
<Text style={[styles.valueText, { color: '#007AFF' }]}>
|
||||||
|
{Math.round(appleStandHours)}/{appleStandHoursGoal}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.unitText}>小时</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text style={styles.cardSubtext}>
|
||||||
|
{Math.round(appleStandHours)}小时
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{renderBarChart(
|
||||||
|
hourlyStandData.map(h => h.hasStood),
|
||||||
|
1,
|
||||||
|
'#007AFF',
|
||||||
|
'小时'
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemedView style={styles.container}>
|
||||||
|
{/* 头部 */}
|
||||||
|
<HeaderBar
|
||||||
|
title={formatHeaderDate(selectedDate)}
|
||||||
|
onBack={() => router.back()}
|
||||||
|
right={
|
||||||
|
<TouchableOpacity style={styles.calendarButton} onPress={openDatePicker}>
|
||||||
|
<Ionicons name="calendar-outline" size={20} color="#666666" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
}
|
||||||
|
withSafeTop={true}
|
||||||
|
transparent={true}
|
||||||
|
variant="default"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ScrollView
|
||||||
|
style={styles.scrollView}
|
||||||
|
contentContainerStyle={styles.scrollContent}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
>
|
||||||
|
{/* 本周圆环横向滚动 */}
|
||||||
|
<View style={styles.weekSection}>
|
||||||
|
<ScrollView
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
contentContainerStyle={styles.weekScrollContent}
|
||||||
|
style={styles.weekScrollView}
|
||||||
|
>
|
||||||
|
{weekData.map((item, index) => renderWeekRingItem(item, index))}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 选中日期的详细数据 */}
|
||||||
|
{renderSelectedDayDetail()}
|
||||||
|
|
||||||
|
{/* 周闭环天数统计 */}
|
||||||
|
<View style={styles.statsContainer}>
|
||||||
|
<View style={styles.statRow}>
|
||||||
|
<Text style={[styles.statLabel, { color: Colors[colorScheme ?? 'light'].text }]}>周闭环天数</Text>
|
||||||
|
<View style={styles.statValue}>
|
||||||
|
<Text style={[styles.statNumber, { color: Colors[colorScheme ?? 'light'].text }]}>{getClosedRingCount()}天</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
{/* 日期选择器弹窗 */}
|
||||||
|
<Modal
|
||||||
|
visible={datePickerVisible}
|
||||||
|
transparent
|
||||||
|
animationType="fade"
|
||||||
|
onRequestClose={closeDatePicker}
|
||||||
|
>
|
||||||
|
<Pressable style={styles.modalBackdrop} onPress={closeDatePicker} />
|
||||||
|
<View style={styles.modalSheet}>
|
||||||
|
<DateTimePicker
|
||||||
|
value={pickerDate}
|
||||||
|
mode="date"
|
||||||
|
display={Platform.OS === 'ios' ? 'inline' : 'calendar'}
|
||||||
|
minimumDate={new Date(2020, 0, 1)}
|
||||||
|
maximumDate={new Date()}
|
||||||
|
{...(Platform.OS === 'ios' ? { locale: 'zh-CN' } : {})}
|
||||||
|
onChange={(event, date) => {
|
||||||
|
if (Platform.OS === 'ios') {
|
||||||
|
if (date) setPickerDate(date);
|
||||||
|
} else {
|
||||||
|
if (event.type === 'set' && date) {
|
||||||
|
onConfirmDate(date);
|
||||||
|
} else {
|
||||||
|
closeDatePicker();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{Platform.OS === 'ios' && (
|
||||||
|
<View style={styles.modalActions}>
|
||||||
|
<Pressable onPress={closeDatePicker} style={[styles.modalBtn]}>
|
||||||
|
<Text style={styles.modalBtnText}>取消</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Pressable onPress={() => {
|
||||||
|
onConfirmDate(pickerDate);
|
||||||
|
}} style={[styles.modalBtn, styles.modalBtnPrimary]}>
|
||||||
|
<Text style={[styles.modalBtnText, styles.modalBtnTextPrimary]}>确定</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
</ThemedView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
calendarButton: {
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
scrollView: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
scrollContent: {
|
||||||
|
paddingBottom: 32,
|
||||||
|
},
|
||||||
|
weekSection: {
|
||||||
|
paddingVertical: 20,
|
||||||
|
},
|
||||||
|
weekScrollView: {
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
},
|
||||||
|
weekScrollContent: {
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
},
|
||||||
|
weekRingItem: {
|
||||||
|
alignItems: 'center',
|
||||||
|
marginHorizontal: 8,
|
||||||
|
padding: 8,
|
||||||
|
borderRadius: 12,
|
||||||
|
},
|
||||||
|
weekRingItemSelected: {
|
||||||
|
backgroundColor: 'rgba(0, 122, 255, 0.1)',
|
||||||
|
},
|
||||||
|
weekRingContainer: {
|
||||||
|
position: 'relative',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
weekStarContainer: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: -8,
|
||||||
|
right: -8,
|
||||||
|
zIndex: 10,
|
||||||
|
},
|
||||||
|
weekStarIcon: {
|
||||||
|
fontSize: 12,
|
||||||
|
},
|
||||||
|
weekRingsWrapper: {
|
||||||
|
position: 'relative',
|
||||||
|
width: 50,
|
||||||
|
height: 50,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
ringPosition: {
|
||||||
|
position: 'absolute',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
weekDayNumber: {
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: '600',
|
||||||
|
marginTop: 6,
|
||||||
|
},
|
||||||
|
weekTodayNumber: {
|
||||||
|
color: '#007AFF',
|
||||||
|
},
|
||||||
|
weekSelectedNumber: {
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
weekDayLabel: {
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: '500',
|
||||||
|
marginTop: 2,
|
||||||
|
},
|
||||||
|
weekTodayLabel: {
|
||||||
|
color: '#007AFF',
|
||||||
|
},
|
||||||
|
weekSelectedLabel: {
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
detailContainer: {
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 20,
|
||||||
|
},
|
||||||
|
// 卡片样式
|
||||||
|
metricCard: {
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
borderRadius: 16,
|
||||||
|
padding: 20,
|
||||||
|
marginBottom: 16,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: {
|
||||||
|
width: 0,
|
||||||
|
height: 2,
|
||||||
|
},
|
||||||
|
shadowOpacity: 0.06,
|
||||||
|
shadowRadius: 8,
|
||||||
|
elevation: 3,
|
||||||
|
},
|
||||||
|
cardHeader: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
cardTitle: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#1C1C1E',
|
||||||
|
},
|
||||||
|
helpButton: {
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
borderRadius: 12,
|
||||||
|
backgroundColor: '#F2F2F7',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
helpIcon: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#8E8E93',
|
||||||
|
},
|
||||||
|
cardValue: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'baseline',
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
valueText: {
|
||||||
|
fontSize: 32,
|
||||||
|
fontWeight: '700',
|
||||||
|
letterSpacing: -1,
|
||||||
|
},
|
||||||
|
unitText: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: '500',
|
||||||
|
color: '#8E8E93',
|
||||||
|
marginLeft: 4,
|
||||||
|
},
|
||||||
|
cardSubtext: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#8E8E93',
|
||||||
|
marginBottom: 20,
|
||||||
|
},
|
||||||
|
// 图表样式
|
||||||
|
chartContainer: {
|
||||||
|
marginTop: 16,
|
||||||
|
},
|
||||||
|
chartBars: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'flex-end',
|
||||||
|
height: 60,
|
||||||
|
marginBottom: 8,
|
||||||
|
paddingHorizontal: 4,
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
},
|
||||||
|
chartBar: {
|
||||||
|
width: 3,
|
||||||
|
borderRadius: 1.5,
|
||||||
|
marginHorizontal: 0.5,
|
||||||
|
},
|
||||||
|
chartLabels: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
paddingHorizontal: 4,
|
||||||
|
},
|
||||||
|
chartLabel: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#8E8E93',
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
// 锻炼信息样式
|
||||||
|
exerciseInfo: {
|
||||||
|
marginTop: 20,
|
||||||
|
padding: 16,
|
||||||
|
backgroundColor: '#F2F2F7',
|
||||||
|
borderRadius: 12,
|
||||||
|
},
|
||||||
|
exerciseTitle: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#1C1C1E',
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
exerciseDesc: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#3C3C43',
|
||||||
|
lineHeight: 20,
|
||||||
|
marginBottom: 12,
|
||||||
|
},
|
||||||
|
exerciseRecommendation: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#3C3C43',
|
||||||
|
lineHeight: 20,
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
knowButton: {
|
||||||
|
alignSelf: 'flex-end',
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 8,
|
||||||
|
backgroundColor: '#007AFF',
|
||||||
|
borderRadius: 20,
|
||||||
|
},
|
||||||
|
knowButtonText: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#FFFFFF',
|
||||||
|
},
|
||||||
|
noDataText: {
|
||||||
|
fontSize: 16,
|
||||||
|
textAlign: 'center',
|
||||||
|
marginTop: 40,
|
||||||
|
},
|
||||||
|
statsContainer: {
|
||||||
|
marginHorizontal: 16,
|
||||||
|
marginTop: 32,
|
||||||
|
padding: 16,
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.05)',
|
||||||
|
borderRadius: 12,
|
||||||
|
},
|
||||||
|
statRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
},
|
||||||
|
statLabel: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
statValue: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
statNumber: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
marginLeft: 4,
|
||||||
|
},
|
||||||
|
starIcon: {
|
||||||
|
fontSize: 16,
|
||||||
|
},
|
||||||
|
// 日期选择器样式
|
||||||
|
modalBackdrop: {
|
||||||
|
...StyleSheet.absoluteFillObject,
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.4)',
|
||||||
|
},
|
||||||
|
modalSheet: {
|
||||||
|
position: 'absolute',
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
padding: 16,
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
borderTopLeftRadius: 16,
|
||||||
|
borderTopRightRadius: 16,
|
||||||
|
},
|
||||||
|
modalActions: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
marginTop: 8,
|
||||||
|
gap: 12,
|
||||||
|
},
|
||||||
|
modalBtn: {
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
paddingVertical: 10,
|
||||||
|
borderRadius: 10,
|
||||||
|
backgroundColor: '#F1F5F9',
|
||||||
|
},
|
||||||
|
modalBtnPrimary: {
|
||||||
|
backgroundColor: '#7a5af8',
|
||||||
|
},
|
||||||
|
modalBtnText: {
|
||||||
|
color: '#334155',
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
modalBtnTextPrimary: {
|
||||||
|
color: '#FFFFFF',
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,7 +1,5 @@
|
|||||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||||
import { Colors } from '@/constants/Colors';
|
import { useAppSelector } from '@/hooks/redux';
|
||||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
|
||||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
|
||||||
import { useMoodData } from '@/hooks/useMoodData';
|
import { useMoodData } from '@/hooks/useMoodData';
|
||||||
import { getMoodOptions } from '@/services/moodCheckins';
|
import { getMoodOptions } from '@/services/moodCheckins';
|
||||||
import { selectLatestMoodRecordByDate } from '@/store/moodSlice';
|
import { selectLatestMoodRecordByDate } from '@/store/moodSlice';
|
||||||
@@ -22,16 +20,22 @@ const { width } = Dimensions.get('window');
|
|||||||
|
|
||||||
// 心情日历数据生成函数
|
// 心情日历数据生成函数
|
||||||
const generateCalendarData = (targetDate: Date) => {
|
const generateCalendarData = (targetDate: Date) => {
|
||||||
const year = targetDate.getFullYear();
|
// 使用 dayjs 确保时区一致性
|
||||||
const month = targetDate.getMonth();
|
const targetDayjs = dayjs(targetDate);
|
||||||
const daysInMonth = new Date(year, month + 1, 0).getDate();
|
const year = targetDayjs.year();
|
||||||
const firstDayOfWeek = new Date(year, month, 1).getDay();
|
const month = targetDayjs.month(); // dayjs month is 0-based
|
||||||
|
const daysInMonth = targetDayjs.daysInMonth();
|
||||||
|
|
||||||
|
// 使用 dayjs 获取月初第一天是周几(0=周日,1=周一...6=周六)
|
||||||
|
const firstDayOfWeek = targetDayjs.startOf('month').day();
|
||||||
|
// 转换为中国习惯(周一为一周开始):周日(0)转为6,其他减1
|
||||||
|
const firstDayAdjusted = firstDayOfWeek === 0 ? 6 : firstDayOfWeek - 1;
|
||||||
|
|
||||||
const calendar = [];
|
const calendar = [];
|
||||||
const weeks = [];
|
const weeks = [];
|
||||||
|
|
||||||
// 添加空白日期
|
// 添加空白日期(基于周一开始)
|
||||||
for (let i = 0; i < firstDayOfWeek; i++) {
|
for (let i = 0; i < firstDayAdjusted; i++) {
|
||||||
weeks.push(null);
|
weeks.push(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,14 +49,18 @@ const generateCalendarData = (targetDate: Date) => {
|
|||||||
calendar.push(weeks.slice(i, i + 7));
|
calendar.push(weeks.slice(i, i + 7));
|
||||||
}
|
}
|
||||||
|
|
||||||
return { calendar, today: new Date().getDate(), month: month + 1, year };
|
// 使用 dayjs 获取今天的日期,确保时区一致
|
||||||
|
const today = dayjs();
|
||||||
|
return {
|
||||||
|
calendar,
|
||||||
|
today: today.date(),
|
||||||
|
month: month + 1, // 转回1-based用于显示
|
||||||
|
year
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function MoodCalendarScreen() {
|
export default function MoodCalendarScreen() {
|
||||||
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
|
||||||
const colorTokens = Colors[theme];
|
|
||||||
const params = useLocalSearchParams();
|
const params = useLocalSearchParams();
|
||||||
const dispatch = useAppDispatch();
|
|
||||||
const { fetchMoodRecords, fetchMoodHistoryRecords } = useMoodData();
|
const { fetchMoodRecords, fetchMoodHistoryRecords } = useMoodData();
|
||||||
|
|
||||||
// 使用 useRef 来存储函数引用,避免依赖循环
|
// 使用 useRef 来存储函数引用,避免依赖循环
|
||||||
@@ -116,9 +124,9 @@ export default function MoodCalendarScreen() {
|
|||||||
loadDailyMoodCheckins(dateString);
|
loadDailyMoodCheckins(dateString);
|
||||||
loadMonthMoodData(date.toDate());
|
loadMonthMoodData(date.toDate());
|
||||||
} else {
|
} else {
|
||||||
const today = new Date();
|
const today = dayjs().toDate();
|
||||||
setCurrentMonth(today);
|
setCurrentMonth(today);
|
||||||
setSelectedDay(today.getDate());
|
setSelectedDay(dayjs().date());
|
||||||
const dateString = dayjs().format('YYYY-MM-DD');
|
const dateString = dayjs().format('YYYY-MM-DD');
|
||||||
loadDailyMoodCheckins(dateString);
|
loadDailyMoodCheckins(dateString);
|
||||||
loadMonthMoodData(today);
|
loadMonthMoodData(today);
|
||||||
@@ -144,16 +152,14 @@ export default function MoodCalendarScreen() {
|
|||||||
|
|
||||||
// 月份切换函数
|
// 月份切换函数
|
||||||
const goToPreviousMonth = () => {
|
const goToPreviousMonth = () => {
|
||||||
const newMonth = new Date(currentMonth);
|
const newMonth = dayjs(currentMonth).subtract(1, 'month').toDate();
|
||||||
newMonth.setMonth(newMonth.getMonth() - 1);
|
|
||||||
setCurrentMonth(newMonth);
|
setCurrentMonth(newMonth);
|
||||||
setSelectedDay(null);
|
setSelectedDay(null);
|
||||||
loadMonthMoodData(newMonth);
|
loadMonthMoodData(newMonth);
|
||||||
};
|
};
|
||||||
|
|
||||||
const goToNextMonth = () => {
|
const goToNextMonth = () => {
|
||||||
const newMonth = new Date(currentMonth);
|
const newMonth = dayjs(currentMonth).add(1, 'month').toDate();
|
||||||
newMonth.setMonth(newMonth.getMonth() + 1);
|
|
||||||
setCurrentMonth(newMonth);
|
setCurrentMonth(newMonth);
|
||||||
setSelectedDay(null);
|
setSelectedDay(null);
|
||||||
loadMonthMoodData(newMonth);
|
loadMonthMoodData(newMonth);
|
||||||
@@ -188,9 +194,9 @@ export default function MoodCalendarScreen() {
|
|||||||
const dayRecords = moodRecords[dayDateString] || [];
|
const dayRecords = moodRecords[dayDateString] || [];
|
||||||
const moodRecord = dayRecords.length > 0 ? dayRecords[0] : null;
|
const moodRecord = dayRecords.length > 0 ? dayRecords[0] : null;
|
||||||
|
|
||||||
const isToday = day === new Date().getDate() &&
|
const isToday = day === dayjs().date() &&
|
||||||
month === new Date().getMonth() + 1 &&
|
month === dayjs().month() + 1 &&
|
||||||
year === new Date().getFullYear();
|
year === dayjs().year();
|
||||||
|
|
||||||
if (moodRecord) {
|
if (moodRecord) {
|
||||||
const mood = moodOptions.find(m => m.type === moodRecord.moodType);
|
const mood = moodOptions.find(m => m.type === moodRecord.moodType);
|
||||||
@@ -260,11 +266,12 @@ export default function MoodCalendarScreen() {
|
|||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
<View >
|
||||||
{calendar.map((week, weekIndex) => (
|
{calendar.map((week, weekIndex) => (
|
||||||
<View key={weekIndex} style={styles.weekRow}>
|
<View key={weekIndex} style={styles.weekRow}>
|
||||||
{week.map((day, dayIndex) => {
|
{week.map((day, dayIndex) => {
|
||||||
const isSelected = day === selectedDay;
|
const isSelected = day === selectedDay;
|
||||||
const isToday = day === today && month === new Date().getMonth() + 1 && year === new Date().getFullYear();
|
const isToday = day === today && month === dayjs().month() + 1 && year === dayjs().year();
|
||||||
const isFutureDate = Boolean(day && dayjs(currentMonth).date(day).isAfter(dayjs(), 'day'));
|
const isFutureDate = Boolean(day && dayjs(currentMonth).date(day).isAfter(dayjs(), 'day'));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -298,6 +305,7 @@ export default function MoodCalendarScreen() {
|
|||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
{/* 选中日期的记录 */}
|
{/* 选中日期的记录 */}
|
||||||
<View style={styles.selectedDateSection}>
|
<View style={styles.selectedDateSection}>
|
||||||
@@ -402,6 +410,8 @@ const styles = StyleSheet.create({
|
|||||||
backgroundColor: 'rgba(255,255,255,0.95)',
|
backgroundColor: 'rgba(255,255,255,0.95)',
|
||||||
margin: 16,
|
margin: 16,
|
||||||
borderRadius: 20,
|
borderRadius: 20,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
padding: 20,
|
padding: 20,
|
||||||
shadowColor: '#7a5af8',
|
shadowColor: '#7a5af8',
|
||||||
shadowOffset: { width: 0, height: 4 },
|
shadowOffset: { width: 0, height: 4 },
|
||||||
@@ -411,6 +421,7 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
monthNavigation: {
|
monthNavigation: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
|
width: '100%',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
marginBottom: 24,
|
marginBottom: 24,
|
||||||
@@ -440,7 +451,7 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
weekHeader: {
|
weekHeader: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
justifyContent: 'space-around',
|
justifyContent: 'flex-start',
|
||||||
marginBottom: 20,
|
marginBottom: 20,
|
||||||
},
|
},
|
||||||
weekDay: {
|
weekDay: {
|
||||||
@@ -452,7 +463,7 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
weekRow: {
|
weekRow: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
justifyContent: 'space-around',
|
justifyContent: 'flex-start',
|
||||||
marginBottom: 16,
|
marginBottom: 16,
|
||||||
},
|
},
|
||||||
dayContainer: {
|
dayContainer: {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { StyleSheet, Text, View } from 'react-native';
|
import { StyleSheet, Text, View, TouchableOpacity } from 'react-native';
|
||||||
|
import { router } from 'expo-router';
|
||||||
import { CircularRing } from './CircularRing';
|
import { CircularRing } from './CircularRing';
|
||||||
|
import { ROUTES } from '@/constants/Routes';
|
||||||
|
|
||||||
type FitnessRingsCardProps = {
|
type FitnessRingsCardProps = {
|
||||||
style?: any;
|
style?: any;
|
||||||
@@ -35,8 +37,12 @@ export function FitnessRingsCard({
|
|||||||
const exerciseProgress = Math.min(1, Math.max(0, exerciseMinutes / exerciseMinutesGoal));
|
const exerciseProgress = Math.min(1, Math.max(0, exerciseMinutes / exerciseMinutesGoal));
|
||||||
const standProgress = Math.min(1, Math.max(0, standHours / standHoursGoal));
|
const standProgress = Math.min(1, Math.max(0, standHours / standHoursGoal));
|
||||||
|
|
||||||
|
const handlePress = () => {
|
||||||
|
router.push(ROUTES.FITNESS_RINGS_DETAIL);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[styles.container, style]}>
|
<TouchableOpacity style={[styles.container, style]} onPress={handlePress}>
|
||||||
<View style={styles.contentContainer}>
|
<View style={styles.contentContainer}>
|
||||||
{/* 左侧圆环 */}
|
{/* 左侧圆环 */}
|
||||||
<View style={styles.ringsContainer}>
|
<View style={styles.ringsContainer}>
|
||||||
@@ -112,7 +118,7 @@ export function FitnessRingsCard({
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</TouchableOpacity>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,13 +2,12 @@ import { AnimatedNumber } from '@/components/AnimatedNumber';
|
|||||||
import { FloatingFoodOverlay } from '@/components/FloatingFoodOverlay';
|
import { FloatingFoodOverlay } from '@/components/FloatingFoodOverlay';
|
||||||
import { ROUTES } from '@/constants/Routes';
|
import { ROUTES } from '@/constants/Routes';
|
||||||
import { NutritionSummary } from '@/services/dietRecords';
|
import { NutritionSummary } from '@/services/dietRecords';
|
||||||
|
import { triggerLightHaptic } from '@/utils/haptics';
|
||||||
import { NutritionGoals, calculateRemainingCalories } from '@/utils/nutrition';
|
import { NutritionGoals, calculateRemainingCalories } from '@/utils/nutrition';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import * as Haptics from 'expo-haptics';
|
|
||||||
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';
|
||||||
import { Animated, Platform, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
import { Animated, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||||
import Svg, { Circle } from 'react-native-svg';
|
import Svg, { Circle } from 'react-native-svg';
|
||||||
|
|
||||||
const AnimatedCircle = Animated.createAnimatedComponent(Circle);
|
const AnimatedCircle = Animated.createAnimatedComponent(Circle);
|
||||||
@@ -138,25 +137,27 @@ export function NutritionRadarCard({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const handleNavigateToRecords = () => {
|
const handleNavigateToRecords = () => {
|
||||||
// ios 下震动反馈
|
triggerLightHaptic();
|
||||||
if (Platform.OS === 'ios') {
|
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
|
||||||
}
|
|
||||||
router.push(ROUTES.NUTRITION_RECORDS);
|
router.push(ROUTES.NUTRITION_RECORDS);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAddFood = () => {
|
const handleAddFood = () => {
|
||||||
|
triggerLightHaptic();
|
||||||
setShowFoodOverlay(true);
|
setShowFoodOverlay(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity style={styles.card} onPress={handleNavigateToRecords} activeOpacity={0.8}>
|
<TouchableOpacity style={styles.card} onPress={handleNavigateToRecords} activeOpacity={0.8}>
|
||||||
<View style={styles.cardHeader}>
|
<View style={styles.cardHeader}>
|
||||||
<Text style={styles.cardTitle}>营养摄入分析</Text>
|
<Text style={styles.cardTitle}>饮食分析</Text>
|
||||||
<View style={styles.cardRightContainer}>
|
<View style={styles.cardRightContainer}>
|
||||||
<Text style={styles.cardSubtitle}>更新: {dayjs(nutritionSummary?.updatedAt).format('MM-DD HH:mm')}</Text>
|
<Text style={styles.cardSubtitle}>更新: {dayjs(nutritionSummary?.updatedAt).format('MM-DD HH:mm')}</Text>
|
||||||
<TouchableOpacity style={styles.addButton} onPress={handleAddFood}>
|
<TouchableOpacity style={styles.addButton} onPress={handleAddFood}>
|
||||||
<Ionicons name="add" size={16} color="#514b4bff" />
|
{/* <Ionicons name="add" size={16} color="#514b4bff" /> */}
|
||||||
|
<Text style={{
|
||||||
|
fontSize: 12,
|
||||||
|
color: 'white'
|
||||||
|
}}>添加+</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -419,10 +420,10 @@ const styles = StyleSheet.create({
|
|||||||
fontSize: 24,
|
fontSize: 24,
|
||||||
},
|
},
|
||||||
addButton: {
|
addButton: {
|
||||||
width: 22,
|
width: 52,
|
||||||
height: 22,
|
height: 26,
|
||||||
borderRadius: 12,
|
borderRadius: 16,
|
||||||
backgroundColor: '#c1c1eeff',
|
backgroundColor: '#7b7be2ff',
|
||||||
marginLeft: 8,
|
marginLeft: 8,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
|
|||||||
@@ -41,6 +41,9 @@ export const ROUTES = {
|
|||||||
// 体重记录相关路由
|
// 体重记录相关路由
|
||||||
WEIGHT_RECORDS: '/weight-records',
|
WEIGHT_RECORDS: '/weight-records',
|
||||||
|
|
||||||
|
// 健康相关路由
|
||||||
|
FITNESS_RINGS_DETAIL: '/fitness-rings-detail',
|
||||||
|
|
||||||
// 任务相关路由
|
// 任务相关路由
|
||||||
TASK_DETAIL: '/task-detail',
|
TASK_DETAIL: '/task-detail',
|
||||||
|
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export class BackgroundTaskManager {
|
|||||||
try {
|
try {
|
||||||
// 配置后台获取
|
// 配置后台获取
|
||||||
const status = await BackgroundFetch.configure({
|
const status = await BackgroundFetch.configure({
|
||||||
minimumFetchInterval: 15000, // 最小间隔15分钟(iOS 实际控制间隔)
|
minimumFetchInterval: 15, // 最小间隔15分钟(iOS 实际控制间隔)
|
||||||
}, async (taskId) => {
|
}, async (taskId) => {
|
||||||
console.log('[BackgroundFetch] 后台任务执行:', taskId);
|
console.log('[BackgroundFetch] 后台任务执行:', taskId);
|
||||||
await this.executeBackgroundTasks();
|
await this.executeBackgroundTasks();
|
||||||
@@ -73,6 +73,24 @@ export class BackgroundTaskManager {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 获取用户名
|
||||||
|
const state = store.getState();
|
||||||
|
const userName = state.user.profile?.name || '朋友';
|
||||||
|
|
||||||
|
// 发送测试通知
|
||||||
|
const Notifications = await import('expo-notifications');
|
||||||
|
|
||||||
|
await Notifications.scheduleNotificationAsync({
|
||||||
|
content: {
|
||||||
|
title: '测试通知',
|
||||||
|
body: `你好 ${userName}!这是一条测试消息,用于验证通知功能是否正常工作。`,
|
||||||
|
data: { type: 'test_notification', timestamp: Date.now() },
|
||||||
|
sound: true,
|
||||||
|
priority: Notifications.AndroidNotificationPriority.HIGH,
|
||||||
|
},
|
||||||
|
trigger: null, // 立即发送
|
||||||
|
});
|
||||||
|
|
||||||
// 执行喝水提醒检查任务
|
// 执行喝水提醒检查任务
|
||||||
await this.executeWaterReminderTask();
|
await this.executeWaterReminderTask();
|
||||||
|
|
||||||
@@ -375,6 +393,7 @@ export class BackgroundTaskManager {
|
|||||||
console.error('站立提醒后台任务测试失败:', error);
|
console.error('站立提醒后台任务测试失败:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
56
utils/haptics.ts
Normal file
56
utils/haptics.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { Platform } from 'react-native';
|
||||||
|
import * as Haptics from 'expo-haptics';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 触发轻微震动反馈 (仅在 iOS 上生效)
|
||||||
|
*/
|
||||||
|
export const triggerLightHaptic = () => {
|
||||||
|
if (Platform.OS === 'ios') {
|
||||||
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 触发中等震动反馈 (仅在 iOS 上生效)
|
||||||
|
*/
|
||||||
|
export const triggerMediumHaptic = () => {
|
||||||
|
if (Platform.OS === 'ios') {
|
||||||
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 触发强烈震动反馈 (仅在 iOS 上生效)
|
||||||
|
*/
|
||||||
|
export const triggerHeavyHaptic = () => {
|
||||||
|
if (Platform.OS === 'ios') {
|
||||||
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 触发成功反馈震动 (仅在 iOS 上生效)
|
||||||
|
*/
|
||||||
|
export const triggerSuccessHaptic = () => {
|
||||||
|
if (Platform.OS === 'ios') {
|
||||||
|
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 触发警告反馈震动 (仅在 iOS 上生效)
|
||||||
|
*/
|
||||||
|
export const triggerWarningHaptic = () => {
|
||||||
|
if (Platform.OS === 'ios') {
|
||||||
|
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 触发错误反馈震动 (仅在 iOS 上生效)
|
||||||
|
*/
|
||||||
|
export const triggerErrorHaptic = () => {
|
||||||
|
if (Platform.OS === 'ios') {
|
||||||
|
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
|
||||||
|
}
|
||||||
|
};
|
||||||
242
utils/health.ts
242
utils/health.ts
@@ -20,6 +20,9 @@ const PERMISSIONS: HealthKitPermissions = {
|
|||||||
AppleHealthKit.Constants.Permissions.OxygenSaturation,
|
AppleHealthKit.Constants.Permissions.OxygenSaturation,
|
||||||
AppleHealthKit.Constants.Permissions.HeartRate,
|
AppleHealthKit.Constants.Permissions.HeartRate,
|
||||||
AppleHealthKit.Constants.Permissions.Water,
|
AppleHealthKit.Constants.Permissions.Water,
|
||||||
|
// 添加 Apple Exercise Time 和 Apple Stand Time 权限
|
||||||
|
AppleHealthKit.Constants.Permissions.AppleExerciseTime,
|
||||||
|
AppleHealthKit.Constants.Permissions.AppleStandTime,
|
||||||
],
|
],
|
||||||
write: [
|
write: [
|
||||||
// 支持体重写入
|
// 支持体重写入
|
||||||
@@ -35,6 +38,21 @@ export type HourlyStepData = {
|
|||||||
steps: number;
|
steps: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type HourlyActivityData = {
|
||||||
|
hour: number; // 0-23
|
||||||
|
calories: number; // 活动热量
|
||||||
|
};
|
||||||
|
|
||||||
|
export type HourlyExerciseData = {
|
||||||
|
hour: number; // 0-23
|
||||||
|
minutes: number; // 锻炼分钟数
|
||||||
|
};
|
||||||
|
|
||||||
|
export type HourlyStandData = {
|
||||||
|
hour: number; // 0-23
|
||||||
|
hasStood: number; // 1表示该小时有站立,0表示没有
|
||||||
|
};
|
||||||
|
|
||||||
export type TodayHealthData = {
|
export type TodayHealthData = {
|
||||||
steps: number;
|
steps: number;
|
||||||
activeEnergyBurned: number; // kilocalories
|
activeEnergyBurned: number; // kilocalories
|
||||||
@@ -192,8 +210,9 @@ async function fetchHourlyStepSamples(date: Date): Promise<HourlyStepData[]> {
|
|||||||
(err: any, res: any[]) => {
|
(err: any, res: any[]) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
logError('每小时步数样本', err);
|
logError('每小时步数样本', err);
|
||||||
// 如果主方法失败,尝试使用备用方法
|
// 如果主方法失败,返回默认数据
|
||||||
return null
|
resolve(Array.from({ length: 24 }, (_, i) => ({ hour: i, steps: 0 })));
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
logSuccess('每小时步数样本', res);
|
logSuccess('每小时步数样本', res);
|
||||||
@@ -225,6 +244,165 @@ async function fetchHourlyStepSamples(date: Date): Promise<HourlyStepData[]> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 获取每小时活动热量数据
|
||||||
|
// 优化版本:使用更精确的时间间隔来获取每小时数据
|
||||||
|
async function fetchHourlyActiveCalories(date: Date): Promise<HourlyActivityData[]> {
|
||||||
|
return new Promise(async (resolve) => {
|
||||||
|
const startOfDay = dayjs(date).startOf('day');
|
||||||
|
|
||||||
|
// 初始化24小时数据
|
||||||
|
const hourlyData: HourlyActivityData[] = Array.from({ length: 24 }, (_, i) => ({
|
||||||
|
hour: i,
|
||||||
|
calories: 0
|
||||||
|
}));
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 为每个小时单独获取数据,确保精确性
|
||||||
|
const promises = Array.from({ length: 24 }, (_, hour) => {
|
||||||
|
const hourStart = startOfDay.add(hour, 'hour');
|
||||||
|
const hourEnd = hourStart.add(1, 'hour');
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
startDate: hourStart.toDate().toISOString(),
|
||||||
|
endDate: hourEnd.toDate().toISOString(),
|
||||||
|
ascending: true,
|
||||||
|
includeManuallyAdded: false
|
||||||
|
};
|
||||||
|
|
||||||
|
return new Promise<number>((resolveHour) => {
|
||||||
|
AppleHealthKit.getActiveEnergyBurned(options, (err, res) => {
|
||||||
|
if (err || !res || !Array.isArray(res)) {
|
||||||
|
resolveHour(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = res.reduce((acc: number, sample: any) => {
|
||||||
|
return acc + (sample?.value || 0);
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
resolveHour(Math.round(total));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const results = await Promise.all(promises);
|
||||||
|
|
||||||
|
results.forEach((calories, hour) => {
|
||||||
|
hourlyData[hour].calories = calories;
|
||||||
|
});
|
||||||
|
|
||||||
|
logSuccess('每小时活动热量', hourlyData);
|
||||||
|
resolve(hourlyData);
|
||||||
|
} catch (error) {
|
||||||
|
logError('每小时活动热量', error);
|
||||||
|
resolve(hourlyData);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取每小时锻炼分钟数据
|
||||||
|
// 使用 AppleHealthKit.getAppleExerciseTime 获取锻炼样本数据
|
||||||
|
async function fetchHourlyExerciseMinutes(date: Date): Promise<HourlyExerciseData[]> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const startOfDay = dayjs(date).startOf('day');
|
||||||
|
const endOfDay = dayjs(date).endOf('day');
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
startDate: startOfDay.toDate().toISOString(),
|
||||||
|
endDate: endOfDay.toDate().toISOString(),
|
||||||
|
ascending: true,
|
||||||
|
includeManuallyAdded: false
|
||||||
|
};
|
||||||
|
|
||||||
|
// 使用 getAppleExerciseTime 获取详细的锻炼样本数据
|
||||||
|
AppleHealthKit.getAppleExerciseTime(options, (err, res) => {
|
||||||
|
if (err) {
|
||||||
|
logError('每小时锻炼分钟', err);
|
||||||
|
resolve(Array.from({ length: 24 }, (_, i) => ({ hour: i, minutes: 0 })));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res || !Array.isArray(res)) {
|
||||||
|
logWarning('每小时锻炼分钟', '数据为空');
|
||||||
|
resolve(Array.from({ length: 24 }, (_, i) => ({ hour: i, minutes: 0 })));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logSuccess('每小时锻炼分钟', res);
|
||||||
|
|
||||||
|
// 初始化24小时数据
|
||||||
|
const hourlyData: HourlyExerciseData[] = Array.from({ length: 24 }, (_, i) => ({
|
||||||
|
hour: i,
|
||||||
|
minutes: 0
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 将锻炼样本数据按小时分组统计
|
||||||
|
res.forEach((sample: any) => {
|
||||||
|
if (sample && sample.startDate && sample.value !== undefined) {
|
||||||
|
const hour = dayjs(sample.startDate).hour();
|
||||||
|
if (hour >= 0 && hour < 24) {
|
||||||
|
hourlyData[hour].minutes += sample.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 四舍五入处理
|
||||||
|
hourlyData.forEach(data => {
|
||||||
|
data.minutes = Math.round(data.minutes);
|
||||||
|
});
|
||||||
|
|
||||||
|
resolve(hourlyData);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取每小时站立小时数据
|
||||||
|
// 使用 AppleHealthKit.getAppleStandTime 获取站立样本数据
|
||||||
|
async function fetchHourlyStandHours(date: Date): Promise<number[]> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const startOfDay = dayjs(date).startOf('day');
|
||||||
|
const endOfDay = dayjs(date).endOf('day');
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
startDate: startOfDay.toDate().toISOString(),
|
||||||
|
endDate: endOfDay.toDate().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
// 使用 getAppleStandTime 获取详细的站立样本数据
|
||||||
|
AppleHealthKit.getAppleStandTime(options, (err, res) => {
|
||||||
|
if (err) {
|
||||||
|
logError('每小时站立数据', err);
|
||||||
|
resolve(Array.from({ length: 24 }, () => 0));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res || !Array.isArray(res)) {
|
||||||
|
logWarning('每小时站立数据', '数据为空');
|
||||||
|
resolve(Array.from({ length: 24 }, () => 0));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logSuccess('每小时站立数据', res);
|
||||||
|
|
||||||
|
// 初始化24小时数据
|
||||||
|
const hourlyData: number[] = Array.from({ length: 24 }, () => 0);
|
||||||
|
|
||||||
|
// 将站立样本数据按小时分组统计
|
||||||
|
res.forEach((sample: any) => {
|
||||||
|
if (sample && sample.startDate && sample.value !== undefined) {
|
||||||
|
const hour = dayjs(sample.startDate).hour();
|
||||||
|
if (hour >= 0 && hour < 24) {
|
||||||
|
// 站立时间通常以分钟为单位,转换为小时(1表示该小时有站立,0表示没有)
|
||||||
|
hourlyData[hour] = sample.value > 0 ? 1 : 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
resolve(hourlyData);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchActiveEnergyBurned(options: HealthDataOptions): Promise<number> {
|
async function fetchActiveEnergyBurned(options: HealthDataOptions): Promise<number> {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
AppleHealthKit.getActiveEnergyBurned(options, (err, res) => {
|
AppleHealthKit.getActiveEnergyBurned(options, (err, res) => {
|
||||||
@@ -612,3 +790,63 @@ export async function getCurrentHourStandStatus(): Promise<{ hasStood: boolean;
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// === 专门为健身圆环详情页提供的独立函数 ===
|
||||||
|
|
||||||
|
// 精简的活动圆环数据类型,只包含必要字段
|
||||||
|
export type ActivityRingsData = {
|
||||||
|
// 活动圆环数据(来自 getActivitySummary)
|
||||||
|
activeEnergyBurned: number; // activeEnergyBurned
|
||||||
|
activeEnergyBurnedGoal: number; // activeEnergyBurnedGoal
|
||||||
|
appleExerciseTime: number; // appleExerciseTime (分钟)
|
||||||
|
appleExerciseTimeGoal: number; // appleExerciseTimeGoal
|
||||||
|
appleStandHours: number; // appleStandHours
|
||||||
|
appleStandHoursGoal: number; // appleStandHoursGoal
|
||||||
|
};
|
||||||
|
|
||||||
|
// 导出每小时活动热量数据获取函数
|
||||||
|
export async function fetchHourlyActiveCaloriesForDate(date: Date): Promise<HourlyActivityData[]> {
|
||||||
|
return fetchHourlyActiveCalories(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导出每小时锻炼分钟数据获取函数
|
||||||
|
export async function fetchHourlyExerciseMinutesForDate(date: Date): Promise<HourlyExerciseData[]> {
|
||||||
|
return fetchHourlyExerciseMinutes(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导出每小时站立数据获取函数
|
||||||
|
export async function fetchHourlyStandHoursForDate(date: Date): Promise<HourlyStandData[]> {
|
||||||
|
const hourlyStandData = await fetchHourlyStandHours(date);
|
||||||
|
return hourlyStandData.map((hasStood, hour) => ({
|
||||||
|
hour,
|
||||||
|
hasStood
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 专门为活动圆环详情页获取精简的数据
|
||||||
|
export async function fetchActivityRingsForDate(date: Date): Promise<ActivityRingsData | null> {
|
||||||
|
try {
|
||||||
|
console.log('获取活动圆环数据...', date);
|
||||||
|
const options = createDateRange(date);
|
||||||
|
|
||||||
|
const activitySummary = await fetchActivitySummary(options);
|
||||||
|
|
||||||
|
if (!activitySummary) {
|
||||||
|
console.warn('ActivitySummary 数据为空');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 直接使用 getActivitySummary 返回的字段名,与文档保持一致
|
||||||
|
return {
|
||||||
|
activeEnergyBurned: Math.round(activitySummary.activeEnergyBurned || 0),
|
||||||
|
activeEnergyBurnedGoal: Math.round(activitySummary.activeEnergyBurnedGoal || 350),
|
||||||
|
appleExerciseTime: Math.round(activitySummary.appleExerciseTime || 0),
|
||||||
|
appleExerciseTimeGoal: Math.round(activitySummary.appleExerciseTimeGoal || 30),
|
||||||
|
appleStandHours: Math.round(activitySummary.appleStandHours || 0),
|
||||||
|
appleStandHoursGoal: Math.round(activitySummary.appleStandHoursGoal || 12),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取活动圆环数据失败:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,18 +4,24 @@ import AsyncStorage from '@react-native-async-storage/async-storage';
|
|||||||
const PREFERENCES_KEYS = {
|
const PREFERENCES_KEYS = {
|
||||||
QUICK_WATER_AMOUNT: 'user_preference_quick_water_amount',
|
QUICK_WATER_AMOUNT: 'user_preference_quick_water_amount',
|
||||||
NOTIFICATION_ENABLED: 'user_preference_notification_enabled',
|
NOTIFICATION_ENABLED: 'user_preference_notification_enabled',
|
||||||
|
FITNESS_EXERCISE_MINUTES_INFO_DISMISSED: 'user_preference_fitness_exercise_minutes_info_dismissed',
|
||||||
|
FITNESS_ACTIVE_HOURS_INFO_DISMISSED: 'user_preference_fitness_active_hours_info_dismissed',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
// 用户偏好设置接口
|
// 用户偏好设置接口
|
||||||
export interface UserPreferences {
|
export interface UserPreferences {
|
||||||
quickWaterAmount: number;
|
quickWaterAmount: number;
|
||||||
notificationEnabled: boolean;
|
notificationEnabled: boolean;
|
||||||
|
fitnessExerciseMinutesInfoDismissed: boolean;
|
||||||
|
fitnessActiveHoursInfoDismissed: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 默认的用户偏好设置
|
// 默认的用户偏好设置
|
||||||
const DEFAULT_PREFERENCES: UserPreferences = {
|
const DEFAULT_PREFERENCES: UserPreferences = {
|
||||||
quickWaterAmount: 150, // 默认快速添加饮水量为 150ml
|
quickWaterAmount: 150, // 默认快速添加饮水量为 150ml
|
||||||
notificationEnabled: true, // 默认开启消息推送
|
notificationEnabled: true, // 默认开启消息推送
|
||||||
|
fitnessExerciseMinutesInfoDismissed: false, // 默认显示锻炼分钟说明
|
||||||
|
fitnessActiveHoursInfoDismissed: false, // 默认显示活动小时说明
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -25,10 +31,14 @@ export const getUserPreferences = async (): Promise<UserPreferences> => {
|
|||||||
try {
|
try {
|
||||||
const quickWaterAmount = await AsyncStorage.getItem(PREFERENCES_KEYS.QUICK_WATER_AMOUNT);
|
const quickWaterAmount = await AsyncStorage.getItem(PREFERENCES_KEYS.QUICK_WATER_AMOUNT);
|
||||||
const notificationEnabled = await AsyncStorage.getItem(PREFERENCES_KEYS.NOTIFICATION_ENABLED);
|
const notificationEnabled = await AsyncStorage.getItem(PREFERENCES_KEYS.NOTIFICATION_ENABLED);
|
||||||
|
const fitnessExerciseMinutesInfoDismissed = await AsyncStorage.getItem(PREFERENCES_KEYS.FITNESS_EXERCISE_MINUTES_INFO_DISMISSED);
|
||||||
|
const fitnessActiveHoursInfoDismissed = await AsyncStorage.getItem(PREFERENCES_KEYS.FITNESS_ACTIVE_HOURS_INFO_DISMISSED);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
quickWaterAmount: quickWaterAmount ? parseInt(quickWaterAmount, 10) : DEFAULT_PREFERENCES.quickWaterAmount,
|
quickWaterAmount: quickWaterAmount ? parseInt(quickWaterAmount, 10) : DEFAULT_PREFERENCES.quickWaterAmount,
|
||||||
notificationEnabled: notificationEnabled !== null ? notificationEnabled === 'true' : DEFAULT_PREFERENCES.notificationEnabled,
|
notificationEnabled: notificationEnabled !== null ? notificationEnabled === 'true' : DEFAULT_PREFERENCES.notificationEnabled,
|
||||||
|
fitnessExerciseMinutesInfoDismissed: fitnessExerciseMinutesInfoDismissed !== null ? fitnessExerciseMinutesInfoDismissed === 'true' : DEFAULT_PREFERENCES.fitnessExerciseMinutesInfoDismissed,
|
||||||
|
fitnessActiveHoursInfoDismissed: fitnessActiveHoursInfoDismissed !== null ? fitnessActiveHoursInfoDismissed === 'true' : DEFAULT_PREFERENCES.fitnessActiveHoursInfoDismissed,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('获取用户偏好设置失败:', error);
|
console.error('获取用户偏好设置失败:', error);
|
||||||
@@ -90,6 +100,58 @@ export const getNotificationEnabled = async (): Promise<boolean> => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置健身锻炼分钟说明已阅读状态
|
||||||
|
* @param dismissed 是否已阅读
|
||||||
|
*/
|
||||||
|
export const setFitnessExerciseMinutesInfoDismissed = async (dismissed: boolean): Promise<void> => {
|
||||||
|
try {
|
||||||
|
await AsyncStorage.setItem(PREFERENCES_KEYS.FITNESS_EXERCISE_MINUTES_INFO_DISMISSED, dismissed.toString());
|
||||||
|
} catch (error) {
|
||||||
|
console.error('设置健身锻炼分钟说明已阅读状态失败:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取健身锻炼分钟说明已阅读状态
|
||||||
|
*/
|
||||||
|
export const getFitnessExerciseMinutesInfoDismissed = async (): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
const dismissed = await AsyncStorage.getItem(PREFERENCES_KEYS.FITNESS_EXERCISE_MINUTES_INFO_DISMISSED);
|
||||||
|
return dismissed !== null ? dismissed === 'true' : DEFAULT_PREFERENCES.fitnessExerciseMinutesInfoDismissed;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取健身锻炼分钟说明已阅读状态失败:', error);
|
||||||
|
return DEFAULT_PREFERENCES.fitnessExerciseMinutesInfoDismissed;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置健身活动小时说明已阅读状态
|
||||||
|
* @param dismissed 是否已阅读
|
||||||
|
*/
|
||||||
|
export const setFitnessActiveHoursInfoDismissed = async (dismissed: boolean): Promise<void> => {
|
||||||
|
try {
|
||||||
|
await AsyncStorage.setItem(PREFERENCES_KEYS.FITNESS_ACTIVE_HOURS_INFO_DISMISSED, dismissed.toString());
|
||||||
|
} catch (error) {
|
||||||
|
console.error('设置健身活动小时说明已阅读状态失败:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取健身活动小时说明已阅读状态
|
||||||
|
*/
|
||||||
|
export const getFitnessActiveHoursInfoDismissed = async (): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
const dismissed = await AsyncStorage.getItem(PREFERENCES_KEYS.FITNESS_ACTIVE_HOURS_INFO_DISMISSED);
|
||||||
|
return dismissed !== null ? dismissed === 'true' : DEFAULT_PREFERENCES.fitnessActiveHoursInfoDismissed;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取健身活动小时说明已阅读状态失败:', error);
|
||||||
|
return DEFAULT_PREFERENCES.fitnessActiveHoursInfoDismissed;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 重置所有用户偏好设置为默认值
|
* 重置所有用户偏好设置为默认值
|
||||||
*/
|
*/
|
||||||
@@ -97,6 +159,8 @@ export const resetUserPreferences = async (): Promise<void> => {
|
|||||||
try {
|
try {
|
||||||
await AsyncStorage.removeItem(PREFERENCES_KEYS.QUICK_WATER_AMOUNT);
|
await AsyncStorage.removeItem(PREFERENCES_KEYS.QUICK_WATER_AMOUNT);
|
||||||
await AsyncStorage.removeItem(PREFERENCES_KEYS.NOTIFICATION_ENABLED);
|
await AsyncStorage.removeItem(PREFERENCES_KEYS.NOTIFICATION_ENABLED);
|
||||||
|
await AsyncStorage.removeItem(PREFERENCES_KEYS.FITNESS_EXERCISE_MINUTES_INFO_DISMISSED);
|
||||||
|
await AsyncStorage.removeItem(PREFERENCES_KEYS.FITNESS_ACTIVE_HOURS_INFO_DISMISSED);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('重置用户偏好设置失败:', error);
|
console.error('重置用户偏好设置失败:', error);
|
||||||
throw error;
|
throw error;
|
||||||
|
|||||||
Reference in New Issue
Block a user