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:
richarjiang
2025-09-05 15:32:34 +08:00
parent 460a7e4289
commit 83805a4b07
9 changed files with 1337 additions and 79 deletions

View 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',
},
});

View File

@@ -1,7 +1,5 @@
import { HeaderBar } from '@/components/ui/HeaderBar';
import { Colors } from '@/constants/Colors';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useColorScheme } from '@/hooks/useColorScheme';
import { useAppSelector } from '@/hooks/redux';
import { useMoodData } from '@/hooks/useMoodData';
import { getMoodOptions } from '@/services/moodCheckins';
import { selectLatestMoodRecordByDate } from '@/store/moodSlice';
@@ -22,16 +20,22 @@ const { width } = Dimensions.get('window');
// 心情日历数据生成函数
const generateCalendarData = (targetDate: Date) => {
const year = targetDate.getFullYear();
const month = targetDate.getMonth();
const daysInMonth = new Date(year, month + 1, 0).getDate();
const firstDayOfWeek = new Date(year, month, 1).getDay();
// 使用 dayjs 确保时区一致性
const targetDayjs = dayjs(targetDate);
const year = targetDayjs.year();
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 weeks = [];
// 添加空白日期
for (let i = 0; i < firstDayOfWeek; i++) {
// 添加空白日期(基于周一开始)
for (let i = 0; i < firstDayAdjusted; i++) {
weeks.push(null);
}
@@ -45,14 +49,18 @@ const generateCalendarData = (targetDate: Date) => {
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() {
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
const colorTokens = Colors[theme];
const params = useLocalSearchParams();
const dispatch = useAppDispatch();
const { fetchMoodRecords, fetchMoodHistoryRecords } = useMoodData();
// 使用 useRef 来存储函数引用,避免依赖循环
@@ -116,9 +124,9 @@ export default function MoodCalendarScreen() {
loadDailyMoodCheckins(dateString);
loadMonthMoodData(date.toDate());
} else {
const today = new Date();
const today = dayjs().toDate();
setCurrentMonth(today);
setSelectedDay(today.getDate());
setSelectedDay(dayjs().date());
const dateString = dayjs().format('YYYY-MM-DD');
loadDailyMoodCheckins(dateString);
loadMonthMoodData(today);
@@ -144,16 +152,14 @@ export default function MoodCalendarScreen() {
// 月份切换函数
const goToPreviousMonth = () => {
const newMonth = new Date(currentMonth);
newMonth.setMonth(newMonth.getMonth() - 1);
const newMonth = dayjs(currentMonth).subtract(1, 'month').toDate();
setCurrentMonth(newMonth);
setSelectedDay(null);
loadMonthMoodData(newMonth);
};
const goToNextMonth = () => {
const newMonth = new Date(currentMonth);
newMonth.setMonth(newMonth.getMonth() + 1);
const newMonth = dayjs(currentMonth).add(1, 'month').toDate();
setCurrentMonth(newMonth);
setSelectedDay(null);
loadMonthMoodData(newMonth);
@@ -188,9 +194,9 @@ export default function MoodCalendarScreen() {
const dayRecords = moodRecords[dayDateString] || [];
const moodRecord = dayRecords.length > 0 ? dayRecords[0] : null;
const isToday = day === new Date().getDate() &&
month === new Date().getMonth() + 1 &&
year === new Date().getFullYear();
const isToday = day === dayjs().date() &&
month === dayjs().month() + 1 &&
year === dayjs().year();
if (moodRecord) {
const mood = moodOptions.find(m => m.type === moodRecord.moodType);
@@ -260,43 +266,45 @@ export default function MoodCalendarScreen() {
))}
</View>
{calendar.map((week, weekIndex) => (
<View key={weekIndex} style={styles.weekRow}>
{week.map((day, dayIndex) => {
const isSelected = day === selectedDay;
const isToday = day === today && month === new Date().getMonth() + 1 && year === new Date().getFullYear();
const isFutureDate = Boolean(day && dayjs(currentMonth).date(day).isAfter(dayjs(), 'day'));
<View >
{calendar.map((week, weekIndex) => (
<View key={weekIndex} style={styles.weekRow}>
{week.map((day, dayIndex) => {
const isSelected = day === selectedDay;
const isToday = day === today && month === dayjs().month() + 1 && year === dayjs().year();
const isFutureDate = Boolean(day && dayjs(currentMonth).date(day).isAfter(dayjs(), 'day'));
return (
<View key={dayIndex} style={styles.dayContainer}>
{day && (
<TouchableOpacity
style={[
styles.dayButton,
isSelected && styles.dayButtonSelected,
isToday && styles.dayButtonToday
]}
onPress={() => !isFutureDate && day && onSelectDate(day)}
disabled={isFutureDate}
>
<View style={styles.dayContent}>
<Text style={[
styles.dayNumber,
isSelected && styles.dayNumberSelected,
isToday && styles.dayNumberToday,
isFutureDate && styles.dayNumberDisabled
]}>
{day.toString().padStart(2, '0')}
</Text>
{renderMoodRing(day, isSelected)}
</View>
</TouchableOpacity>
)}
</View>
);
})}
</View>
))}
return (
<View key={dayIndex} style={styles.dayContainer}>
{day && (
<TouchableOpacity
style={[
styles.dayButton,
isSelected && styles.dayButtonSelected,
isToday && styles.dayButtonToday
]}
onPress={() => !isFutureDate && day && onSelectDate(day)}
disabled={isFutureDate}
>
<View style={styles.dayContent}>
<Text style={[
styles.dayNumber,
isSelected && styles.dayNumberSelected,
isToday && styles.dayNumberToday,
isFutureDate && styles.dayNumberDisabled
]}>
{day.toString().padStart(2, '0')}
</Text>
{renderMoodRing(day, isSelected)}
</View>
</TouchableOpacity>
)}
</View>
);
})}
</View>
))}
</View>
</View>
{/* 选中日期的记录 */}
@@ -402,6 +410,8 @@ const styles = StyleSheet.create({
backgroundColor: 'rgba(255,255,255,0.95)',
margin: 16,
borderRadius: 20,
justifyContent: 'center',
alignItems: 'center',
padding: 20,
shadowColor: '#7a5af8',
shadowOffset: { width: 0, height: 4 },
@@ -411,6 +421,7 @@ const styles = StyleSheet.create({
},
monthNavigation: {
flexDirection: 'row',
width: '100%',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 24,
@@ -440,7 +451,7 @@ const styles = StyleSheet.create({
},
weekHeader: {
flexDirection: 'row',
justifyContent: 'space-around',
justifyContent: 'flex-start',
marginBottom: 20,
},
weekDay: {
@@ -452,7 +463,7 @@ const styles = StyleSheet.create({
},
weekRow: {
flexDirection: 'row',
justifyContent: 'space-around',
justifyContent: 'flex-start',
marginBottom: 16,
},
dayContainer: {

View File

@@ -1,6 +1,8 @@
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 { ROUTES } from '@/constants/Routes';
type FitnessRingsCardProps = {
style?: any;
@@ -35,8 +37,12 @@ export function FitnessRingsCard({
const exerciseProgress = Math.min(1, Math.max(0, exerciseMinutes / exerciseMinutesGoal));
const standProgress = Math.min(1, Math.max(0, standHours / standHoursGoal));
const handlePress = () => {
router.push(ROUTES.FITNESS_RINGS_DETAIL);
};
return (
<View style={[styles.container, style]}>
<TouchableOpacity style={[styles.container, style]} onPress={handlePress}>
<View style={styles.contentContainer}>
{/* 左侧圆环 */}
<View style={styles.ringsContainer}>
@@ -112,7 +118,7 @@ export function FitnessRingsCard({
</View>
</View>
</View>
</View>
</TouchableOpacity>
);
}

View File

@@ -2,13 +2,12 @@ import { AnimatedNumber } from '@/components/AnimatedNumber';
import { FloatingFoodOverlay } from '@/components/FloatingFoodOverlay';
import { ROUTES } from '@/constants/Routes';
import { NutritionSummary } from '@/services/dietRecords';
import { triggerLightHaptic } from '@/utils/haptics';
import { NutritionGoals, calculateRemainingCalories } from '@/utils/nutrition';
import { Ionicons } from '@expo/vector-icons';
import dayjs from 'dayjs';
import * as Haptics from 'expo-haptics';
import { router } from 'expo-router';
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';
const AnimatedCircle = Animated.createAnimatedComponent(Circle);
@@ -138,25 +137,27 @@ export function NutritionRadarCard({
});
const handleNavigateToRecords = () => {
// ios 下震动反馈
if (Platform.OS === 'ios') {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
}
triggerLightHaptic();
router.push(ROUTES.NUTRITION_RECORDS);
};
const handleAddFood = () => {
triggerLightHaptic();
setShowFoodOverlay(true);
};
return (
<TouchableOpacity style={styles.card} onPress={handleNavigateToRecords} activeOpacity={0.8}>
<View style={styles.cardHeader}>
<Text style={styles.cardTitle}></Text>
<Text style={styles.cardTitle}></Text>
<View style={styles.cardRightContainer}>
<Text style={styles.cardSubtitle}>: {dayjs(nutritionSummary?.updatedAt).format('MM-DD HH:mm')}</Text>
<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>
</View>
</View>
@@ -419,10 +420,10 @@ const styles = StyleSheet.create({
fontSize: 24,
},
addButton: {
width: 22,
height: 22,
borderRadius: 12,
backgroundColor: '#c1c1eeff',
width: 52,
height: 26,
borderRadius: 16,
backgroundColor: '#7b7be2ff',
marginLeft: 8,
alignItems: 'center',
justifyContent: 'center',

View File

@@ -41,6 +41,9 @@ export const ROUTES = {
// 体重记录相关路由
WEIGHT_RECORDS: '/weight-records',
// 健康相关路由
FITNESS_RINGS_DETAIL: '/fitness-rings-detail',
// 任务相关路由
TASK_DETAIL: '/task-detail',

View File

@@ -38,7 +38,7 @@ export class BackgroundTaskManager {
try {
// 配置后台获取
const status = await BackgroundFetch.configure({
minimumFetchInterval: 15000, // 最小间隔15分钟iOS 实际控制间隔)
minimumFetchInterval: 15, // 最小间隔15分钟iOS 实际控制间隔)
}, async (taskId) => {
console.log('[BackgroundFetch] 后台任务执行:', taskId);
await this.executeBackgroundTasks();
@@ -73,6 +73,24 @@ export class BackgroundTaskManager {
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();
@@ -375,6 +393,7 @@ export class BackgroundTaskManager {
console.error('站立提醒后台任务测试失败:', error);
}
}
}
/**

56
utils/haptics.ts Normal file
View 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);
}
};

View File

@@ -20,6 +20,9 @@ const PERMISSIONS: HealthKitPermissions = {
AppleHealthKit.Constants.Permissions.OxygenSaturation,
AppleHealthKit.Constants.Permissions.HeartRate,
AppleHealthKit.Constants.Permissions.Water,
// 添加 Apple Exercise Time 和 Apple Stand Time 权限
AppleHealthKit.Constants.Permissions.AppleExerciseTime,
AppleHealthKit.Constants.Permissions.AppleStandTime,
],
write: [
// 支持体重写入
@@ -35,6 +38,21 @@ export type HourlyStepData = {
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 = {
steps: number;
activeEnergyBurned: number; // kilocalories
@@ -192,8 +210,9 @@ async function fetchHourlyStepSamples(date: Date): Promise<HourlyStepData[]> {
(err: any, res: any[]) => {
if (err) {
logError('每小时步数样本', err);
// 如果主方法失败,尝试使用备用方法
return null
// 如果主方法失败,返回默认数据
resolve(Array.from({ length: 24 }, (_, i) => ({ hour: i, steps: 0 })));
return;
}
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> {
return new Promise((resolve) => {
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;
}
}

View File

@@ -4,18 +4,24 @@ import AsyncStorage from '@react-native-async-storage/async-storage';
const PREFERENCES_KEYS = {
QUICK_WATER_AMOUNT: 'user_preference_quick_water_amount',
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;
// 用户偏好设置接口
export interface UserPreferences {
quickWaterAmount: number;
notificationEnabled: boolean;
fitnessExerciseMinutesInfoDismissed: boolean;
fitnessActiveHoursInfoDismissed: boolean;
}
// 默认的用户偏好设置
const DEFAULT_PREFERENCES: UserPreferences = {
quickWaterAmount: 150, // 默认快速添加饮水量为 150ml
notificationEnabled: true, // 默认开启消息推送
fitnessExerciseMinutesInfoDismissed: false, // 默认显示锻炼分钟说明
fitnessActiveHoursInfoDismissed: false, // 默认显示活动小时说明
};
/**
@@ -25,10 +31,14 @@ export const getUserPreferences = async (): Promise<UserPreferences> => {
try {
const quickWaterAmount = await AsyncStorage.getItem(PREFERENCES_KEYS.QUICK_WATER_AMOUNT);
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 {
quickWaterAmount: quickWaterAmount ? parseInt(quickWaterAmount, 10) : DEFAULT_PREFERENCES.quickWaterAmount,
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) {
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 {
await AsyncStorage.removeItem(PREFERENCES_KEYS.QUICK_WATER_AMOUNT);
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) {
console.error('重置用户偏好设置失败:', error);
throw error;