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: {
|
||||
|
||||
Reference in New Issue
Block a user