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: {