feat: 集成推送通知功能及相关组件

- 在项目中引入expo-notifications库,支持本地推送通知功能
- 实现通知权限管理,用户可选择开启或关闭通知
- 新增通知发送、定时通知和重复通知功能
- 更新个人页面,集成通知开关和权限请求逻辑
- 编写推送通知功能实现文档,详细描述功能和使用方法
- 优化心情日历页面,确保数据实时刷新
This commit is contained in:
2025-08-22 22:00:05 +08:00
parent c12329bc96
commit 7d28b79d86
20 changed files with 2368 additions and 280 deletions

View 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,
},
});

View File

@@ -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,

View File

@@ -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}