Files
digital-pilates/app/mood/calendar.tsx
richarjiang 83b77615cf feat: Enhance Oxygen Saturation Card with health permissions and loading state management
feat(i18n): Add common translations and mood-related strings in English and Chinese

fix(i18n): Update metabolism titles for consistency in health translations

chore: Update Podfile.lock to include SDWebImage 5.21.4 and other dependency versions

refactor(moodCheckins): Improve mood configuration retrieval with optional translation support

refactor(sleepHealthKit): Replace useI18n with direct i18n import for sleep quality descriptions
2025-11-28 23:48:38 +08:00

746 lines
21 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { HeaderBar } from '@/components/ui/HeaderBar';
import { useAppSelector } from '@/hooks/redux';
import { useI18n } from '@/hooks/useI18n';
import { useMoodData } from '@/hooks/useMoodData';
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
import { getMoodOptions } from '@/services/moodCheckins';
import { selectLatestMoodRecordByDate } from '@/store/moodSlice';
import dayjs from 'dayjs';
import { LinearGradient } from 'expo-linear-gradient';
import { router, useFocusEffect, useLocalSearchParams } from 'expo-router';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import {
Dimensions, Image,
ScrollView,
StyleSheet,
Text,
TouchableOpacity,
View
} from 'react-native';
const { width } = Dimensions.get('window');
// 心情日历数据生成函数
const generateCalendarData = (targetDate: Date) => {
// 使用 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 < firstDayAdjusted; i++) {
weeks.push(null);
}
// 添加实际日期
for (let day = 1; day <= daysInMonth; day++) {
weeks.push(day);
}
// 按周分组
for (let i = 0; i < weeks.length; i += 7) {
calendar.push(weeks.slice(i, i + 7));
}
// 使用 dayjs 获取今天的日期,确保时区一致
const today = dayjs();
return {
calendar,
today: today.date(),
month: month + 1, // 转回1-based用于显示
year
};
};
export default function MoodCalendarScreen() {
const { t } = useI18n();
const safeAreaTop = useSafeAreaTop()
const params = useLocalSearchParams();
const { fetchMoodRecords, fetchMoodHistoryRecords } = useMoodData();
// 使用 useRef 来存储函数引用,避免依赖循环
const fetchMoodRecordsRef = useRef(fetchMoodRecords);
const fetchMoodHistoryRecordsRef = useRef(fetchMoodHistoryRecords);
// 更新 ref 值
fetchMoodRecordsRef.current = fetchMoodRecords;
fetchMoodHistoryRecordsRef.current = fetchMoodHistoryRecords;
const { selectedDate } = params;
const initialDate = selectedDate ? dayjs(selectedDate as string).toDate() : new Date();
const [currentMonth, setCurrentMonth] = useState(initialDate);
const [selectedDay, setSelectedDay] = useState<number | null>(null);
// 使用 Redux store 中的数据
const moodRecords = useAppSelector(state => state.mood.moodRecords);
// 获取选中日期的数据
const selectedDateMood = useAppSelector(state => {
if (!selectedDay) return null;
const selectedDateString = dayjs(currentMonth).date(selectedDay).format('YYYY-MM-DD');
return selectLatestMoodRecordByDate(selectedDateString)(state);
});
const moodOptions = getMoodOptions(t);
const weekDays = [
t('mood.calendar.weekDays.monday'),
t('mood.calendar.weekDays.tuesday'),
t('mood.calendar.weekDays.wednesday'),
t('mood.calendar.weekDays.thursday'),
t('mood.calendar.weekDays.friday'),
t('mood.calendar.weekDays.saturday'),
t('mood.calendar.weekDays.sunday'),
];
const monthNames = [
t('mood.calendar.months.january'),
t('mood.calendar.months.february'),
t('mood.calendar.months.march'),
t('mood.calendar.months.april'),
t('mood.calendar.months.may'),
t('mood.calendar.months.june'),
t('mood.calendar.months.july'),
t('mood.calendar.months.august'),
t('mood.calendar.months.september'),
t('mood.calendar.months.october'),
t('mood.calendar.months.november'),
t('mood.calendar.months.december'),
];
// 生成当前月份的日历数据
const { calendar, today, month, year } = generateCalendarData(currentMonth);
// 加载整个月份的心情数据
const loadMonthMoodData = useCallback(async (targetMonth: Date) => {
try {
const startDate = dayjs(targetMonth).startOf('month').format('YYYY-MM-DD');
const endDate = dayjs(targetMonth).endOf('month').format('YYYY-MM-DD');
await fetchMoodHistoryRecordsRef.current({ startDate, endDate });
} catch (error) {
console.error(t('mood.calendar.errors.loadMonthDataFailed'), error);
}
}, []);
// 加载选中日期的心情记录
const loadDailyMoodCheckins = useCallback(async (dateString: string) => {
try {
await fetchMoodRecordsRef.current(dateString);
} catch (error) {
console.error(t('mood.calendar.errors.loadDailyDataFailed'), error);
}
}, []);
// 初始化选中日期
useEffect(() => {
if (selectedDate) {
const date = dayjs(selectedDate as string);
setCurrentMonth(date.toDate());
setSelectedDay(date.date());
const dateString = date.format('YYYY-MM-DD');
loadDailyMoodCheckins(dateString);
loadMonthMoodData(date.toDate());
} else {
const today = dayjs().toDate();
setCurrentMonth(today);
setSelectedDay(dayjs().date());
const dateString = dayjs().format('YYYY-MM-DD');
loadDailyMoodCheckins(dateString);
loadMonthMoodData(today);
}
}, [selectedDate, loadDailyMoodCheckins, loadMonthMoodData]);
// 监听页面焦点变化,当从编辑页面返回时刷新数据
useFocusEffect(
useCallback(() => {
// 当页面获得焦点时,刷新当前月份的数据和选中日期的数据
const refreshData = async () => {
if (selectedDay) {
const selectedDateString = dayjs(currentMonth).date(selectedDay).format('YYYY-MM-DD');
await fetchMoodRecordsRef.current(selectedDateString);
}
const startDate = dayjs(currentMonth).startOf('month').format('YYYY-MM-DD');
const endDate = dayjs(currentMonth).endOf('month').format('YYYY-MM-DD');
await fetchMoodHistoryRecordsRef.current({ startDate, endDate });
};
refreshData();
}, [currentMonth, selectedDay])
);
// 月份切换函数
const goToPreviousMonth = () => {
const newMonth = dayjs(currentMonth).subtract(1, 'month').toDate();
setCurrentMonth(newMonth);
setSelectedDay(null);
loadMonthMoodData(newMonth);
};
const goToNextMonth = () => {
const newMonth = dayjs(currentMonth).add(1, 'month').toDate();
setCurrentMonth(newMonth);
setSelectedDay(null);
loadMonthMoodData(newMonth);
};
// 日期选择函数
const onSelectDate = (day: number) => {
setSelectedDay(day);
const selectedDateString = dayjs(currentMonth).date(day).format('YYYY-MM-DD');
loadDailyMoodCheckins(selectedDateString);
};
// 跳转到心情编辑页面
const openMoodEdit = () => {
const selectedDateString = selectedDay ? dayjs(currentMonth).date(selectedDay).format('YYYY-MM-DD') : dayjs().format('YYYY-MM-DD');
const moodId = selectedDateMood?.id;
router.push({
pathname: '/mood/edit',
params: {
date: selectedDateString,
...(moodId && { moodId })
}
});
};
const renderMoodRing = (day: number | null, isSelected: boolean) => {
if (!day) return null;
// 检查该日期是否有心情记录 - 现在从 Redux store 中获取
const dayDateString = dayjs(currentMonth).date(day).format('YYYY-MM-DD');
const dayRecords = moodRecords[dayDateString] || [];
const moodRecord = dayRecords.length > 0 ? dayRecords[0] : null;
const isToday = day === dayjs().date() &&
month === dayjs().month() + 1 &&
year === dayjs().year();
if (moodRecord) {
const mood = moodOptions.find(m => m.type === moodRecord.moodType);
return (
<View style={isToday ? styles.todayMoodIconContainer : styles.moodIconContainer}>
<View style={styles.moodIcon}>
<Image
source={mood?.image}
style={styles.moodIconImage}
/>
</View>
</View>
);
}
return (
<View style={isToday ? styles.todayDefaultMoodIcon : styles.defaultMoodIcon}>
</View>
);
};
return (
<View style={styles.container}>
<LinearGradient
colors={['#fafaff', '#f4f3ff']} // 使用紫色主题的浅色渐变
style={styles.gradientBackground}
start={{ x: 0, y: 0 }}
end={{ x: 0, y: 1 }}
/>
{/* 装饰性圆圈 */}
<View style={styles.decorativeCircle1} />
<View style={styles.decorativeCircle2} />
<View style={styles.safeArea}>
<HeaderBar
title={t('mood.calendar.title')}
onBack={() => router.back()}
withSafeTop={false}
transparent={true}
tone="light"
/>
<ScrollView style={styles.content} contentContainerStyle={{
paddingTop: safeAreaTop
}}>
{/* 日历视图 */}
<View style={styles.calendar}>
{/* 月份导航 */}
<View style={styles.monthNavigation}>
<TouchableOpacity
style={styles.navButton}
onPress={goToPreviousMonth}
>
<Text style={styles.navButtonText}></Text>
</TouchableOpacity>
<Text style={styles.monthTitle}>{year} {monthNames[month - 1]}</Text>
<TouchableOpacity
style={styles.navButton}
onPress={goToNextMonth}
>
<Text style={styles.navButtonText}></Text>
</TouchableOpacity>
</View>
<View style={styles.weekHeader}>
{weekDays.map((day, index) => (
<Text key={index} style={styles.weekDay}>{day}</Text>
))}
</View>
<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>
))}
</View>
</View>
{/* 选中日期的记录 */}
<View style={styles.selectedDateSection}>
<View style={styles.selectedDateHeader}>
<Text style={styles.selectedDateTitle}>
{selectedDay ? dayjs(currentMonth).date(selectedDay).format(t('mood.calendar.selectedDate.dateFormat')) : t('mood.calendar.selectedDate.selectDate')}
</Text>
<TouchableOpacity
style={styles.addMoodButton}
onPress={openMoodEdit}
>
<Text style={styles.addMoodButtonText}>{t('mood.calendar.selectedDate.record')}</Text>
</TouchableOpacity>
</View>
{selectedDay ? (
selectedDateMood ? (
<TouchableOpacity
style={styles.moodRecord}
onPress={openMoodEdit}
>
<View style={styles.recordIcon}>
<View style={styles.moodIcon}>
<Image
source={moodOptions.find(m => m.type === selectedDateMood.moodType)?.image}
style={styles.moodIconImage}
/>
</View>
</View>
<View style={styles.recordContent}>
<Text style={styles.recordMood}>
{moodOptions.find(m => m.type === selectedDateMood.moodType)?.label}
</Text>
<Text style={styles.recordIntensity}>{t('mood.calendar.selectedDate.intensity')}: {selectedDateMood.intensity}</Text>
{selectedDateMood.description && (
<Text style={styles.recordDescription}>{selectedDateMood.description}</Text>
)}
</View>
<View style={styles.spacer} />
<Text style={styles.recordTime}>
{dayjs(selectedDateMood.createdAt).format('HH:mm')}
</Text>
</TouchableOpacity>
) : (
<View style={styles.emptyRecord}>
<Text style={styles.emptyRecordText}>{t('mood.calendar.selectedDate.noRecord')}</Text>
<Text style={styles.emptyRecordSubtext}>{t('mood.calendar.selectedDate.noRecordHint')}</Text>
</View>
)
) : (
<View style={styles.emptyRecord}>
<Text style={styles.emptyRecordText}>{t('mood.calendar.selectedDate.noDateSelected')}</Text>
<Text style={styles.emptyRecordSubtext}>{t('mood.calendar.selectedDate.noDateSelectedHint')}</Text>
</View>
)}
</View>
</ScrollView>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
gradientBackground: {
position: 'absolute',
left: 0,
right: 0,
top: 0,
bottom: 0,
},
decorativeCircle1: {
position: 'absolute',
top: 40,
right: 20,
width: 60,
height: 60,
borderRadius: 30,
backgroundColor: '#7a5af8',
opacity: 0.08,
},
decorativeCircle2: {
position: 'absolute',
bottom: -15,
left: -15,
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: '#7a5af8',
opacity: 0.04,
},
safeArea: {
flex: 1,
},
content: {
flex: 1,
},
calendar: {
backgroundColor: 'rgba(255,255,255,0.95)',
margin: 16,
borderRadius: 20,
justifyContent: 'center',
alignItems: 'center',
padding: 20,
shadowColor: '#7a5af8',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.1,
shadowRadius: 12,
elevation: 6,
},
monthNavigation: {
flexDirection: 'row',
width: '100%',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 24,
},
navButton: {
width: 44,
height: 44,
borderRadius: 22,
backgroundColor: 'rgba(122,90,248,0.1)',
justifyContent: 'center',
alignItems: 'center',
shadowColor: '#7a5af8',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 2,
},
navButtonText: {
fontSize: 24,
color: '#7a5af8',
fontWeight: '700',
},
monthTitle: {
fontSize: 20,
fontWeight: '800',
color: '#192126',
},
weekHeader: {
flexDirection: 'row',
justifyContent: 'flex-start',
marginBottom: 20,
},
weekDay: {
fontSize: 13,
color: '#5d6676',
textAlign: 'center',
width: (width - 96) / 7,
fontWeight: '600',
},
weekRow: {
flexDirection: 'row',
justifyContent: 'flex-start',
marginBottom: 16,
},
dayContainer: {
width: (width - 96) / 7,
alignItems: 'center',
},
dayButton: {
width: 44,
height: 44,
borderRadius: 22,
justifyContent: 'center',
alignItems: 'center',
marginBottom: 8,
backgroundColor: 'transparent',
},
dayButtonSelected: {
backgroundColor: '#FFFFFF',
shadowColor: '#7a5af8',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.15,
shadowRadius: 6,
elevation: 4,
},
dayButtonToday: {
borderWidth: 2,
borderColor: '#7a5af8',
},
dayContent: {
position: 'relative',
width: '100%',
height: '100%',
justifyContent: 'center',
alignItems: 'center',
},
dayNumber: {
fontSize: 14,
color: '#777f8c',
fontWeight: '600',
position: 'absolute',
top: 2,
zIndex: 1,
},
dayNumberSelected: {
color: '#192126',
fontWeight: '700',
},
dayNumberToday: {
color: '#7a5af8',
fontWeight: '700',
},
dayNumberDisabled: {
color: '#c0c4ca',
},
moodIconContainer: {
position: 'absolute',
bottom: 2,
width: 22,
height: 22,
borderRadius: 11,
justifyContent: 'center',
alignItems: 'center',
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.1,
shadowRadius: 2,
elevation: 1,
},
todayMoodIconContainer: {
position: 'absolute',
bottom: 1,
width: 20,
height: 20,
borderRadius: 10,
justifyContent: 'center',
alignItems: 'center',
shadowColor: '#7a5af8',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.2,
shadowRadius: 2,
elevation: 2,
},
moodIcon: {
width: 18,
height: 18,
borderRadius: 9,
backgroundColor: 'rgba(255,255,255,0.95)',
justifyContent: 'center',
alignItems: 'center',
},
moodIconImage: {
width: 28,
height: 28,
borderRadius: 9,
},
defaultMoodIcon: {
position: 'absolute',
bottom: 2,
width: 22,
height: 22,
borderRadius: 11,
borderWidth: 1.5,
borderColor: 'rgba(122,90,248,0.3)',
borderStyle: 'dashed',
justifyContent: 'center',
alignItems: 'center',
backgroundColor: 'rgba(122,90,248,0.05)',
},
todayDefaultMoodIcon: {
position: 'absolute',
bottom: 1,
width: 20,
height: 20,
borderRadius: 10,
borderWidth: 1.5,
borderColor: 'rgba(122,90,248,0.4)',
borderStyle: 'dashed',
justifyContent: 'center',
alignItems: 'center',
backgroundColor: 'rgba(122,90,248,0.08)',
},
moodRingContainer: {
position: 'absolute',
bottom: 2,
width: 22,
height: 22,
justifyContent: 'center',
alignItems: 'center',
},
moodIntensityText: {
fontSize: 8,
fontWeight: '800',
textAlign: 'center',
position: 'absolute',
zIndex: 1,
textShadowColor: 'rgba(0,0,0,0.3)',
textShadowOffset: { width: 0, height: 0.5 },
textShadowRadius: 1,
},
selectedDateSection: {
backgroundColor: 'rgba(255,255,255,0.95)',
margin: 16,
marginTop: 0,
borderRadius: 20,
padding: 20,
shadowColor: '#7a5af8',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.1,
shadowRadius: 12,
elevation: 6,
},
selectedDateHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 20,
},
selectedDateTitle: {
fontSize: 22,
fontWeight: '800',
color: '#192126',
},
addMoodButton: {
paddingHorizontal: 20,
height: 36,
borderRadius: 18,
backgroundColor: '#7a5af8',
justifyContent: 'center',
alignItems: 'center',
shadowColor: '#7a5af8',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.2,
shadowRadius: 4,
elevation: 3,
},
addMoodButtonText: {
color: '#fff',
fontSize: 14,
fontWeight: '700',
},
moodRecord: {
flexDirection: 'row',
alignItems: 'flex-start',
paddingVertical: 16,
backgroundColor: 'rgba(122,90,248,0.05)',
borderRadius: 16,
paddingHorizontal: 16,
},
recordIcon: {
width: 52,
height: 52,
borderRadius: 26,
backgroundColor: '#e9e7f1ff',
justifyContent: 'center',
alignItems: 'center',
marginRight: 16,
shadowColor: '#7a5af8',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.15,
shadowRadius: 4,
elevation: 2,
},
recordContent: {
flex: 1,
},
recordMood: {
fontSize: 18,
color: '#192126',
fontWeight: '700',
marginBottom: 4,
},
recordIntensity: {
fontSize: 14,
color: '#5d6676',
marginTop: 2,
fontWeight: '500',
},
recordDescription: {
fontSize: 14,
color: '#5d6676',
marginTop: 6,
fontStyle: 'italic',
lineHeight: 20,
},
spacer: {
flex: 1,
},
recordTime: {
fontSize: 14,
color: '#777f8c',
fontWeight: '500',
},
emptyRecord: {
alignItems: 'center',
paddingVertical: 32,
},
emptyRecordText: {
fontSize: 16,
color: '#5d6676',
marginBottom: 8,
fontWeight: '600',
},
emptyRecordSubtext: {
fontSize: 13,
color: '#777f8c',
textAlign: 'center',
lineHeight: 18,
},
});