feat: 集成推送通知功能及相关组件
- 在项目中引入expo-notifications库,支持本地推送通知功能 - 实现通知权限管理,用户可选择开启或关闭通知 - 新增通知发送、定时通知和重复通知功能 - 更新个人页面,集成通知开关和权限请求逻辑 - 编写推送通知功能实现文档,详细描述功能和使用方法 - 优化心情日历页面,确保数据实时刷新
This commit is contained in:
@@ -6,7 +6,7 @@ import { Colors } from '@/constants/Colors';
|
||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { clearErrors, createGoal } from '@/store/goalsSlice';
|
||||
import { clearErrors as clearTaskErrors, completeTask, fetchTasks, loadMoreTasks, skipTask } from '@/store/tasksSlice';
|
||||
import { clearErrors as clearTaskErrors, fetchTasks, loadMoreTasks } from '@/store/tasksSlice';
|
||||
import { CreateGoalRequest, TaskListItem } from '@/types/goals';
|
||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||
import { useFocusEffect } from '@react-navigation/native';
|
||||
@@ -122,40 +122,7 @@ export default function GoalsScreen() {
|
||||
}
|
||||
};
|
||||
|
||||
// 任务点击处理
|
||||
const handleTaskPress = (task: TaskListItem) => {
|
||||
console.log('Task pressed:', task.title);
|
||||
// 这里可以导航到任务详情页面
|
||||
};
|
||||
|
||||
// 完成任务处理
|
||||
const handleCompleteTask = async (task: TaskListItem) => {
|
||||
try {
|
||||
await dispatch(completeTask({
|
||||
taskId: task.id,
|
||||
completionData: {
|
||||
count: 1,
|
||||
notes: '通过任务卡片完成'
|
||||
}
|
||||
})).unwrap();
|
||||
} catch (error) {
|
||||
Alert.alert('错误', '完成任务失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 跳过任务处理
|
||||
const handleSkipTask = async (task: TaskListItem) => {
|
||||
try {
|
||||
await dispatch(skipTask({
|
||||
taskId: task.id,
|
||||
skipData: {
|
||||
reason: '用户主动跳过'
|
||||
}
|
||||
})).unwrap();
|
||||
} catch (error) {
|
||||
Alert.alert('错误', '跳过任务失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 导航到目标管理页面
|
||||
const handleNavigateToGoals = () => {
|
||||
@@ -190,9 +157,6 @@ export default function GoalsScreen() {
|
||||
const renderTaskItem = ({ item }: { item: TaskListItem }) => (
|
||||
<TaskCard
|
||||
task={item}
|
||||
onPress={handleTaskPress}
|
||||
onComplete={handleCompleteTask}
|
||||
onSkip={handleSkipTask}
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
@@ -5,12 +5,13 @@ import { getTabBarBottomPadding } from '@/constants/TabBar';
|
||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { useNotifications } from '@/hooks/useNotifications';
|
||||
import { DEFAULT_MEMBER_NAME, fetchActivityHistory, fetchMyProfile } from '@/store/userSlice';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs';
|
||||
import { useFocusEffect } from '@react-navigation/native';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { Image, Linking, SafeAreaView, ScrollView, StatusBar, StyleSheet, Switch, Text, TouchableOpacity, View } from 'react-native';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { Alert, Image, Linking, SafeAreaView, ScrollView, StatusBar, StyleSheet, Switch, Text, TouchableOpacity, View } from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
||||
const DEFAULT_AVATAR_URL = 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/avatar/avatarGirl01.jpeg';
|
||||
@@ -21,7 +22,16 @@ export default function PersonalScreen() {
|
||||
const insets = useSafeAreaInsets();
|
||||
const tabBarHeight = useBottomTabBarHeight();
|
||||
const colorScheme = useColorScheme();
|
||||
const [notificationEnabled, setNotificationEnabled] = useState(true);
|
||||
|
||||
// 推送通知相关
|
||||
const {
|
||||
isInitialized,
|
||||
permissionStatus,
|
||||
requestPermission,
|
||||
sendNotification,
|
||||
} = useNotifications();
|
||||
|
||||
const [notificationEnabled, setNotificationEnabled] = useState(false);
|
||||
|
||||
// 计算底部间距
|
||||
const bottomPadding = useMemo(() => {
|
||||
@@ -64,6 +74,41 @@ export default function PersonalScreen() {
|
||||
// 显示名称
|
||||
const displayName = (userProfile.name?.trim()) ? userProfile.name : DEFAULT_MEMBER_NAME;
|
||||
|
||||
// 监听通知权限状态变化
|
||||
useEffect(() => {
|
||||
if (permissionStatus === 'granted') {
|
||||
setNotificationEnabled(true);
|
||||
} else {
|
||||
setNotificationEnabled(false);
|
||||
}
|
||||
}, [permissionStatus]);
|
||||
|
||||
// 处理通知开关变化
|
||||
const handleNotificationToggle = async (value: boolean) => {
|
||||
if (value) {
|
||||
try {
|
||||
const status = await requestPermission();
|
||||
if (status === 'granted') {
|
||||
setNotificationEnabled(true);
|
||||
// 发送测试通知
|
||||
await sendNotification({
|
||||
title: '通知已开启',
|
||||
body: '您将收到运动提醒和重要通知',
|
||||
sound: true,
|
||||
priority: 'normal',
|
||||
});
|
||||
} else {
|
||||
Alert.alert('权限被拒绝', '请在系统设置中开启通知权限');
|
||||
}
|
||||
} catch (error) {
|
||||
Alert.alert('错误', '请求通知权限失败');
|
||||
}
|
||||
} else {
|
||||
setNotificationEnabled(false);
|
||||
Alert.alert('通知已关闭', '您将不会收到推送通知');
|
||||
}
|
||||
};
|
||||
|
||||
// 用户信息头部
|
||||
const UserHeader = () => (
|
||||
<View style={styles.sectionContainer}>
|
||||
@@ -137,14 +182,8 @@ export default function PersonalScreen() {
|
||||
</View>
|
||||
{item.type === 'switch' ? (
|
||||
<Switch
|
||||
value={isLoggedIn ? notificationEnabled : false}
|
||||
onValueChange={(value) => {
|
||||
if (!isLoggedIn) {
|
||||
pushIfAuthedElseLogin('/profile/notification-settings');
|
||||
return;
|
||||
}
|
||||
setNotificationEnabled(value);
|
||||
}}
|
||||
value={item.switchValue || false}
|
||||
onValueChange={item.onSwitchChange || (() => {})}
|
||||
trackColor={{ false: '#E5E5E5', true: '#9370DB' }}
|
||||
thumbColor="#FFFFFF"
|
||||
style={styles.switch}
|
||||
@@ -177,6 +216,8 @@ export default function PersonalScreen() {
|
||||
icon: 'notifications-outline' as const,
|
||||
title: '消息推送',
|
||||
type: 'switch' as const,
|
||||
switchValue: notificationEnabled,
|
||||
onSwitchChange: handleNotificationToggle,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { useMoodData } from '@/hooks/useMoodData';
|
||||
import { getMoodOptions } from '@/services/moodCheckins';
|
||||
import { selectLatestMoodRecordByDate } from '@/store/moodSlice';
|
||||
import dayjs from 'dayjs';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { router, useLocalSearchParams } from 'expo-router';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { router, useFocusEffect, useLocalSearchParams } from 'expo-router';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
Dimensions,
|
||||
SafeAreaView,
|
||||
@@ -51,6 +53,7 @@ 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();
|
||||
|
||||
const { selectedDate } = params;
|
||||
@@ -58,8 +61,16 @@ export default function MoodCalendarScreen() {
|
||||
|
||||
const [currentMonth, setCurrentMonth] = useState(initialDate);
|
||||
const [selectedDay, setSelectedDay] = useState<number | null>(null);
|
||||
const [selectedDateMood, setSelectedDateMood] = useState<any>(null);
|
||||
const [moodRecords, setMoodRecords] = useState<Record<string, any[]>>({});
|
||||
|
||||
// 使用 Redux store 中的数据
|
||||
const moodRecords = useAppSelector(state => state.mood.moodRecords);
|
||||
|
||||
// 获取选中日期的数据
|
||||
const selectedDateString = selectedDay ? dayjs(currentMonth).date(selectedDay).format('YYYY-MM-DD') : null;
|
||||
const selectedDateMood = useAppSelector(state => {
|
||||
if (!selectedDateString) return null;
|
||||
return selectLatestMoodRecordByDate(selectedDateString)(state);
|
||||
});
|
||||
|
||||
const moodOptions = getMoodOptions();
|
||||
const weekDays = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'];
|
||||
@@ -68,6 +79,31 @@ export default function MoodCalendarScreen() {
|
||||
// 生成当前月份的日历数据
|
||||
const { calendar, today, month, year } = generateCalendarData(currentMonth);
|
||||
|
||||
// 加载整个月份的心情数据
|
||||
const loadMonthMoodData = async (targetMonth: Date) => {
|
||||
try {
|
||||
const startDate = dayjs(targetMonth).startOf('month').format('YYYY-MM-DD');
|
||||
const endDate = dayjs(targetMonth).endOf('month').format('YYYY-MM-DD');
|
||||
|
||||
const historyData = await fetchMoodHistoryRecords({ startDate, endDate });
|
||||
|
||||
// 历史记录已经通过 fetchMoodHistoryRecords 自动存储到 Redux store 中
|
||||
// 不需要额外的处理
|
||||
} catch (error) {
|
||||
console.error('加载月份心情数据失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 加载选中日期的心情记录
|
||||
const loadDailyMoodCheckins = async (dateString: string) => {
|
||||
try {
|
||||
await fetchMoodRecords(dateString);
|
||||
// 不需要手动设置 selectedDateMood,因为它现在从 Redux store 中获取
|
||||
} catch (error) {
|
||||
console.error('加载心情记录失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 初始化选中日期
|
||||
useEffect(() => {
|
||||
if (selectedDate) {
|
||||
@@ -86,44 +122,22 @@ export default function MoodCalendarScreen() {
|
||||
loadMonthMoodData(currentMonth);
|
||||
}, [selectedDate]);
|
||||
|
||||
// 加载整个月份的心情数据
|
||||
const loadMonthMoodData = async (targetMonth: Date) => {
|
||||
try {
|
||||
const startDate = dayjs(targetMonth).startOf('month').format('YYYY-MM-DD');
|
||||
const endDate = dayjs(targetMonth).endOf('month').format('YYYY-MM-DD');
|
||||
|
||||
const historyData = await fetchMoodHistoryRecords({ startDate, endDate });
|
||||
|
||||
// 将历史记录按日期分组
|
||||
const monthData: Record<string, any[]> = {};
|
||||
historyData.forEach(checkin => {
|
||||
const date = checkin.checkinDate;
|
||||
if (!monthData[date]) {
|
||||
monthData[date] = [];
|
||||
// 监听页面焦点变化,当从编辑页面返回时刷新数据
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
// 当页面获得焦点时,刷新当前月份的数据和选中日期的数据
|
||||
const refreshData = async () => {
|
||||
if (selectedDay) {
|
||||
const selectedDateString = dayjs(currentMonth).date(selectedDay).format('YYYY-MM-DD');
|
||||
await fetchMoodRecords(selectedDateString);
|
||||
}
|
||||
monthData[date].push(checkin);
|
||||
});
|
||||
|
||||
setMoodRecords(monthData);
|
||||
} catch (error) {
|
||||
console.error('加载月份心情数据失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 加载选中日期的心情记录
|
||||
const loadDailyMoodCheckins = async (dateString: string) => {
|
||||
try {
|
||||
const checkins = await fetchMoodRecords(dateString);
|
||||
if (checkins.length > 0) {
|
||||
setSelectedDateMood(checkins[0]); // 取最新的记录
|
||||
} else {
|
||||
setSelectedDateMood(null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载心情记录失败:', error);
|
||||
setSelectedDateMood(null);
|
||||
}
|
||||
};
|
||||
const startDate = dayjs(currentMonth).startOf('month').format('YYYY-MM-DD');
|
||||
const endDate = dayjs(currentMonth).endOf('month').format('YYYY-MM-DD');
|
||||
await fetchMoodHistoryRecords({ startDate, endDate });
|
||||
};
|
||||
refreshData();
|
||||
}, [currentMonth, selectedDay, fetchMoodRecords, fetchMoodHistoryRecords])
|
||||
);
|
||||
|
||||
// 月份切换函数
|
||||
const goToPreviousMonth = () => {
|
||||
@@ -166,7 +180,7 @@ export default function MoodCalendarScreen() {
|
||||
const renderMoodIcon = (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;
|
||||
|
||||
Reference in New Issue
Block a user