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 { 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: {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -41,6 +41,9 @@ export const ROUTES = {
|
||||
// 体重记录相关路由
|
||||
WEIGHT_RECORDS: '/weight-records',
|
||||
|
||||
// 健康相关路由
|
||||
FITNESS_RINGS_DETAIL: '/fitness-rings-detail',
|
||||
|
||||
// 任务相关路由
|
||||
TASK_DETAIL: '/task-detail',
|
||||
|
||||
|
||||
@@ -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
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.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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user