feat: 集成推送通知功能及相关组件
- 在项目中引入expo-notifications库,支持本地推送通知功能 - 实现通知权限管理,用户可选择开启或关闭通知 - 新增通知发送、定时通知和重复通知功能 - 更新个人页面,集成通知开关和权限请求逻辑 - 编写推送通知功能实现文档,详细描述功能和使用方法 - 优化心情日历页面,确保数据实时刷新
This commit is contained in:
302
components/NotificationTest.tsx
Normal file
302
components/NotificationTest.tsx
Normal file
@@ -0,0 +1,302 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
Alert,
|
||||
ScrollView,
|
||||
} from 'react-native';
|
||||
import { useNotifications } from '../hooks/useNotifications';
|
||||
import { ThemedText } from './ThemedText';
|
||||
import { ThemedView } from './ThemedView';
|
||||
|
||||
export const NotificationTest: React.FC = () => {
|
||||
const {
|
||||
isInitialized,
|
||||
permissionStatus,
|
||||
requestPermission,
|
||||
sendNotification,
|
||||
scheduleNotification,
|
||||
scheduleRepeatingNotification,
|
||||
cancelAllNotifications,
|
||||
getAllScheduledNotifications,
|
||||
sendWorkoutReminder,
|
||||
sendGoalAchievement,
|
||||
sendMoodCheckinReminder,
|
||||
} = useNotifications();
|
||||
|
||||
const [scheduledNotifications, setScheduledNotifications] = useState<any[]>([]);
|
||||
|
||||
const handleRequestPermission = async () => {
|
||||
try {
|
||||
const status = await requestPermission();
|
||||
Alert.alert('权限状态', `通知权限: ${status}`);
|
||||
} catch (error) {
|
||||
Alert.alert('错误', '请求权限失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSendImmediateNotification = async () => {
|
||||
try {
|
||||
await sendNotification({
|
||||
title: '测试通知',
|
||||
body: '这是一个立即发送的测试通知',
|
||||
sound: true,
|
||||
priority: 'high',
|
||||
});
|
||||
Alert.alert('成功', '通知已发送');
|
||||
} catch (error) {
|
||||
Alert.alert('错误', '发送通知失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleScheduleNotification = async () => {
|
||||
try {
|
||||
const futureDate = new Date(Date.now() + 5000); // 5秒后
|
||||
await scheduleNotification(
|
||||
{
|
||||
title: '定时通知',
|
||||
body: '这是一个5秒后发送的定时通知',
|
||||
sound: true,
|
||||
priority: 'normal',
|
||||
},
|
||||
futureDate
|
||||
);
|
||||
Alert.alert('成功', '定时通知已安排');
|
||||
} catch (error) {
|
||||
Alert.alert('错误', '安排定时通知失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleScheduleRepeatingNotification = async () => {
|
||||
try {
|
||||
await scheduleRepeatingNotification(
|
||||
{
|
||||
title: '重复通知',
|
||||
body: '这是一个每分钟重复的通知',
|
||||
sound: true,
|
||||
priority: 'normal',
|
||||
},
|
||||
{ minutes: 1 }
|
||||
);
|
||||
Alert.alert('成功', '重复通知已安排');
|
||||
} catch (error) {
|
||||
Alert.alert('错误', '安排重复通知失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSendWorkoutReminder = async () => {
|
||||
try {
|
||||
await sendWorkoutReminder('运动提醒', '该开始今天的普拉提训练了!');
|
||||
Alert.alert('成功', '运动提醒已发送');
|
||||
} catch (error) {
|
||||
Alert.alert('错误', '发送运动提醒失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSendGoalAchievement = async () => {
|
||||
try {
|
||||
await sendGoalAchievement('目标达成', '恭喜您完成了本周的运动目标!');
|
||||
Alert.alert('成功', '目标达成通知已发送');
|
||||
} catch (error) {
|
||||
Alert.alert('错误', '发送目标达成通知失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSendMoodCheckinReminder = async () => {
|
||||
try {
|
||||
await sendMoodCheckinReminder('心情打卡', '记得记录今天的心情状态哦');
|
||||
Alert.alert('成功', '心情打卡提醒已发送');
|
||||
} catch (error) {
|
||||
Alert.alert('错误', '发送心情打卡提醒失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelAllNotifications = async () => {
|
||||
try {
|
||||
await cancelAllNotifications();
|
||||
Alert.alert('成功', '所有通知已取消');
|
||||
} catch (error) {
|
||||
Alert.alert('错误', '取消通知失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleGetScheduledNotifications = async () => {
|
||||
try {
|
||||
const notifications = await getAllScheduledNotifications();
|
||||
setScheduledNotifications(notifications);
|
||||
Alert.alert('成功', `找到 ${notifications.length} 个已安排的通知`);
|
||||
} catch (error) {
|
||||
Alert.alert('错误', '获取已安排通知失败');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ThemedView style={styles.container}>
|
||||
<ScrollView showsVerticalScrollIndicator={false}>
|
||||
<ThemedText style={styles.title}>推送通知测试</ThemedText>
|
||||
|
||||
<View style={styles.statusContainer}>
|
||||
<ThemedText style={styles.statusText}>
|
||||
初始化状态: {isInitialized ? '已初始化' : '未初始化'}
|
||||
</ThemedText>
|
||||
<ThemedText style={styles.statusText}>
|
||||
权限状态: {permissionStatus || '未知'}
|
||||
</ThemedText>
|
||||
</View>
|
||||
|
||||
<View style={styles.buttonContainer}>
|
||||
<TouchableOpacity
|
||||
style={styles.button}
|
||||
onPress={handleRequestPermission}
|
||||
>
|
||||
<ThemedText style={styles.buttonText}>请求通知权限</ThemedText>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.button}
|
||||
onPress={handleSendImmediateNotification}
|
||||
>
|
||||
<ThemedText style={styles.buttonText}>发送立即通知</ThemedText>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.button}
|
||||
onPress={handleScheduleNotification}
|
||||
>
|
||||
<ThemedText style={styles.buttonText}>安排定时通知(5秒后)</ThemedText>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.button}
|
||||
onPress={handleScheduleRepeatingNotification}
|
||||
>
|
||||
<ThemedText style={styles.buttonText}>安排重复通知(每分钟)</ThemedText>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.button}
|
||||
onPress={handleSendWorkoutReminder}
|
||||
>
|
||||
<ThemedText style={styles.buttonText}>发送运动提醒</ThemedText>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.button}
|
||||
onPress={handleSendGoalAchievement}
|
||||
>
|
||||
<ThemedText style={styles.buttonText}>发送目标达成通知</ThemedText>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.button}
|
||||
onPress={handleSendMoodCheckinReminder}
|
||||
>
|
||||
<ThemedText style={styles.buttonText}>发送心情打卡提醒</ThemedText>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.button}
|
||||
onPress={handleGetScheduledNotifications}
|
||||
>
|
||||
<ThemedText style={styles.buttonText}>获取已安排通知</ThemedText>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.button, styles.dangerButton]}
|
||||
onPress={handleCancelAllNotifications}
|
||||
>
|
||||
<ThemedText style={styles.buttonText}>取消所有通知</ThemedText>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{scheduledNotifications.length > 0 && (
|
||||
<View style={styles.notificationsContainer}>
|
||||
<ThemedText style={styles.sectionTitle}>已安排的通知:</ThemedText>
|
||||
{scheduledNotifications.map((notification, index) => (
|
||||
<View key={index} style={styles.notificationItem}>
|
||||
<ThemedText style={styles.notificationTitle}>
|
||||
{notification.content.title}
|
||||
</ThemedText>
|
||||
<ThemedText style={styles.notificationBody}>
|
||||
{notification.content.body}
|
||||
</ThemedText>
|
||||
<ThemedText style={styles.notificationId}>
|
||||
ID: {notification.identifier}
|
||||
</ThemedText>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
</ThemedView>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
padding: 20,
|
||||
},
|
||||
title: {
|
||||
fontSize: 24,
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 20,
|
||||
textAlign: 'center',
|
||||
},
|
||||
statusContainer: {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.05)',
|
||||
padding: 15,
|
||||
borderRadius: 10,
|
||||
marginBottom: 20,
|
||||
},
|
||||
statusText: {
|
||||
fontSize: 14,
|
||||
marginBottom: 5,
|
||||
},
|
||||
buttonContainer: {
|
||||
gap: 10,
|
||||
},
|
||||
button: {
|
||||
backgroundColor: '#007AFF',
|
||||
padding: 15,
|
||||
borderRadius: 10,
|
||||
alignItems: 'center',
|
||||
},
|
||||
dangerButton: {
|
||||
backgroundColor: '#FF3B30',
|
||||
},
|
||||
buttonText: {
|
||||
color: 'white',
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
notificationsContainer: {
|
||||
marginTop: 20,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 10,
|
||||
},
|
||||
notificationItem: {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.05)',
|
||||
padding: 15,
|
||||
borderRadius: 10,
|
||||
marginBottom: 10,
|
||||
},
|
||||
notificationTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
marginBottom: 5,
|
||||
},
|
||||
notificationBody: {
|
||||
fontSize: 14,
|
||||
marginBottom: 5,
|
||||
},
|
||||
notificationId: {
|
||||
fontSize: 12,
|
||||
opacity: 0.7,
|
||||
},
|
||||
});
|
||||
@@ -1,40 +1,25 @@
|
||||
import { useGlobalDialog } from '@/components/ui/DialogProvider';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useAppDispatch } from '@/hooks/redux';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { completeTask, skipTask } from '@/store/tasksSlice';
|
||||
import { TaskListItem } from '@/types/goals';
|
||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||
import React from 'react';
|
||||
import { Image, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
import { Alert, Image, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
|
||||
interface TaskCardProps {
|
||||
task: TaskListItem;
|
||||
onPress?: (task: TaskListItem) => void;
|
||||
onComplete?: (task: TaskListItem) => void;
|
||||
onSkip?: (task: TaskListItem) => void;
|
||||
}
|
||||
|
||||
export const TaskCard: React.FC<TaskCardProps> = ({
|
||||
task,
|
||||
onPress,
|
||||
onComplete,
|
||||
onSkip,
|
||||
}) => {
|
||||
const theme = useColorScheme() ?? 'light';
|
||||
const colorTokens = Colors[theme];
|
||||
const dispatch = useAppDispatch();
|
||||
const { showConfirm } = useGlobalDialog();
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return '#10B981';
|
||||
case 'in_progress':
|
||||
return '#F59E0B';
|
||||
case 'overdue':
|
||||
return '#EF4444';
|
||||
case 'skipped':
|
||||
return '#6B7280';
|
||||
default:
|
||||
return '#3B82F6';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusText = (status: string) => {
|
||||
switch (status) {
|
||||
@@ -77,8 +62,6 @@ export const TaskCard: React.FC<TaskCardProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
const month = date.toLocaleDateString('zh-CN', { month: 'short' });
|
||||
@@ -86,25 +69,105 @@ export const TaskCard: React.FC<TaskCardProps> = ({
|
||||
return `${day} ${month}`;
|
||||
};
|
||||
|
||||
const handleCompleteTask = async () => {
|
||||
// 如果任务已经完成,不执行任何操作
|
||||
if (task.status === 'completed') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 调用完成任务 API
|
||||
await dispatch(completeTask({
|
||||
taskId: task.id,
|
||||
completionData: {
|
||||
count: 1,
|
||||
notes: '通过任务卡片完成'
|
||||
}
|
||||
})).unwrap();
|
||||
|
||||
} catch (error) {
|
||||
Alert.alert('错误', '完成任务失败,请重试');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSkipTask = async () => {
|
||||
// 如果任务已经完成或已跳过,不执行任何操作
|
||||
if (task.status === 'completed' || task.status === 'skipped') {
|
||||
return;
|
||||
}
|
||||
|
||||
// 显示确认弹窗
|
||||
showConfirm(
|
||||
{
|
||||
title: '确认跳过任务',
|
||||
message: `确定要跳过任务"${task.title}"吗?跳过后将无法恢复。`,
|
||||
confirmText: '跳过',
|
||||
cancelText: '取消',
|
||||
destructive: true,
|
||||
icon: 'warning',
|
||||
iconColor: '#F59E0B',
|
||||
},
|
||||
async () => {
|
||||
try {
|
||||
// 调用跳过任务 API
|
||||
await dispatch(skipTask({
|
||||
taskId: task.id,
|
||||
skipData: {
|
||||
reason: '用户主动跳过'
|
||||
}
|
||||
})).unwrap();
|
||||
} catch (error) {
|
||||
Alert.alert('错误', '跳过任务失败,请重试');
|
||||
}
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const renderActionIcons = () => {
|
||||
if (task.status === 'completed' || task.status === 'overdue' || task.status === 'skipped') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.actionIconsContainer}>
|
||||
{/* 完成任务图标 */}
|
||||
<TouchableOpacity
|
||||
style={styles.iconContainer}
|
||||
onPress={handleCompleteTask}
|
||||
>
|
||||
<Image
|
||||
source={require('@/assets/images/task/iconTaskHeader.png')}
|
||||
style={styles.taskIcon}
|
||||
resizeMode="contain"
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* 跳过任务图标 - 仅对进行中的任务显示 */}
|
||||
{task.status === 'pending' && (
|
||||
<TouchableOpacity
|
||||
style={styles.skipIconContainer}
|
||||
onPress={handleSkipTask}
|
||||
>
|
||||
<MaterialIcons name="skip-next" size={20} color="#6B7280" />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[styles.container, { backgroundColor: colorTokens.background }]}
|
||||
onPress={() => onPress?.(task)}
|
||||
onPress={() => {}}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
{/* 头部区域 */}
|
||||
<View style={styles.header}>
|
||||
<View style={styles.titleSection}>
|
||||
<View style={styles.iconContainer}>
|
||||
<Image
|
||||
source={require('@/assets/images/task/iconTaskHeader.png')}
|
||||
style={styles.taskIcon}
|
||||
resizeMode="contain"
|
||||
/>
|
||||
</View>
|
||||
<Text style={[styles.title, { color: colorTokens.text }]} numberOfLines={2}>
|
||||
{task.title}
|
||||
</Text>
|
||||
{renderActionIcons()}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -184,6 +247,18 @@ const styles = StyleSheet.create({
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
},
|
||||
title: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
lineHeight: 22,
|
||||
flex: 1,
|
||||
},
|
||||
actionIconsContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
flexShrink: 0,
|
||||
},
|
||||
iconContainer: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
@@ -191,18 +266,21 @@ const styles = StyleSheet.create({
|
||||
backgroundColor: '#7A5AF8',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
},
|
||||
skipIconContainer: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 16,
|
||||
backgroundColor: '#F3F4F6',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderWidth: 1,
|
||||
borderColor: '#E5E7EB',
|
||||
},
|
||||
taskIcon: {
|
||||
width: 20,
|
||||
height: 20,
|
||||
},
|
||||
title: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
lineHeight: 22,
|
||||
flex: 1,
|
||||
},
|
||||
tagsContainer: {
|
||||
flexDirection: 'row',
|
||||
gap: 8,
|
||||
|
||||
@@ -21,7 +21,7 @@ const OxygenSaturationCard: React.FC<OxygenSaturationCardProps> = ({
|
||||
return (
|
||||
<HealthDataCard
|
||||
title="血氧饱和度"
|
||||
value={oxygenSaturation !== null && oxygenSaturation !== undefined ? oxygenSaturation.toFixed(1) : '--'}
|
||||
value={oxygenSaturation !== null && oxygenSaturation !== undefined ? (oxygenSaturation * 100).toFixed(1) : '--'}
|
||||
unit="%"
|
||||
icon={oxygenIcon}
|
||||
style={style}
|
||||
|
||||
Reference in New Issue
Block a user