feat: 实现目标通知功能及相关组件

- 新增目标通知功能,支持根据用户创建目标时选择的频率和开始时间自动创建本地定时推送通知
- 实现每日、每周和每月的重复类型,用户可自定义选择提醒时间和重复规则
- 集成目标通知测试组件,方便开发者测试不同类型的通知
- 更新相关文档,详细描述目标通知功能的实现和使用方法
- 优化目标页面,确保用户体验和界面一致性
This commit is contained in:
2025-08-23 17:13:04 +08:00
parent 4382fb804f
commit 20a244e375
15 changed files with 957 additions and 156 deletions

View File

@@ -13,6 +13,7 @@ import { clearErrors, createGoal } from '@/store/goalsSlice';
import { clearErrors as clearTaskErrors, fetchTasks, loadMoreTasks } from '@/store/tasksSlice';
import { CreateGoalRequest, TaskListItem } from '@/types/goals';
import { checkGuideCompleted, markGuideCompleted } from '@/utils/guideHelpers';
import { GoalNotificationHelpers } from '@/utils/notificationHelpers';
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
import { useFocusEffect } from '@react-navigation/native';
import dayjs from 'dayjs';
@@ -45,6 +46,8 @@ export default function GoalsScreen() {
createError
} = useAppSelector((state) => state.goals);
const userProfile = useAppSelector((state) => state.user.profile);
const [showCreateModal, setShowCreateModal] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const [selectedFilter, setSelectedFilter] = useState<TaskFilterType>('all');
@@ -145,6 +148,30 @@ export default function GoalsScreen() {
await dispatch(createGoal(goalData)).unwrap();
setShowCreateModal(false);
// 获取用户名
const userName = userProfile?.name || '小海豹';
// 创建目标成功后,设置定时推送
try {
const notificationIds = await GoalNotificationHelpers.scheduleGoalNotifications(
{
title: goalData.title,
repeatType: goalData.repeatType,
frequency: goalData.frequency,
hasReminder: goalData.hasReminder,
reminderTime: goalData.reminderTime,
customRepeatRule: goalData.customRepeatRule,
startTime: goalData.startTime,
},
userName
);
console.log(`目标"${goalData.title}"的定时推送已创建通知ID`, notificationIds);
} catch (notificationError) {
console.error('创建目标定时推送失败:', notificationError);
// 通知创建失败不影响目标创建的成功
}
// 使用确认弹窗显示成功消息
showConfirm(
{
@@ -389,6 +416,82 @@ export default function GoalsScreen() {
{/* 开发测试按钮 */}
<GuideTestButton visible={__DEV__} />
{/* 目标通知测试按钮 */}
{__DEV__ && (
<TouchableOpacity
style={styles.testButton}
onPress={() => {
// 这里可以导航到测试页面或显示测试弹窗
Alert.alert(
'目标通知测试',
'选择要测试的通知类型',
[
{ text: '取消', style: 'cancel' },
{
text: '每日目标通知',
onPress: async () => {
try {
const userName = userProfile?.name || '小海豹';
const notificationIds = await GoalNotificationHelpers.scheduleGoalNotifications(
{
title: '每日运动目标',
repeatType: 'daily',
frequency: 1,
hasReminder: true,
reminderTime: '09:00',
},
userName
);
Alert.alert('成功', `每日目标通知已创建ID: ${notificationIds.join(', ')}`);
} catch (error) {
Alert.alert('错误', `创建通知失败: ${error}`);
}
}
},
{
text: '每周目标通知',
onPress: async () => {
try {
const userName = userProfile?.name || '小海豹';
const notificationIds = await GoalNotificationHelpers.scheduleGoalNotifications(
{
title: '每周运动目标',
repeatType: 'weekly',
frequency: 1,
hasReminder: true,
reminderTime: '10:00',
customRepeatRule: {
weekdays: [1, 3, 5], // 周一、三、五
},
},
userName
);
Alert.alert('成功', `每周目标通知已创建ID: ${notificationIds.join(', ')}`);
} catch (error) {
Alert.alert('错误', `创建通知失败: ${error}`);
}
}
},
{
text: '目标达成通知',
onPress: async () => {
try {
const userName = userProfile?.name || '小海豹';
await GoalNotificationHelpers.sendGoalAchievementNotification(userName, '每日运动目标');
Alert.alert('成功', '目标达成通知已发送');
} catch (error) {
Alert.alert('错误', `发送通知失败: ${error}`);
}
}
},
]
);
}}
>
<Text style={styles.testButtonText}></Text>
</TouchableOpacity>
)}
</View>
</SafeAreaView>
);
@@ -567,4 +670,19 @@ const styles = StyleSheet.create({
fontWeight: '600',
lineHeight: 18,
},
testButton: {
position: 'absolute',
top: 100,
right: 20,
backgroundColor: '#10B981',
paddingHorizontal: 12,
paddingVertical: 8,
borderRadius: 8,
zIndex: 1000,
},
testButtonText: {
color: '#FFFFFF',
fontSize: 12,
fontWeight: '600',
},
});

View File

@@ -1,13 +1,13 @@
import CreateGoalModal from '@/components/CreateGoalModal';
import { DateSelector } from '@/components/DateSelector';
import { GoalItem } from '@/components/GoalCard';
import { GoalCarousel } from '@/components/GoalCarousel';
import { TimeTabSelector, TimeTabType } from '@/components/TimeTabSelector';
import { TimelineSchedule } from '@/components/TimelineSchedule';
import { TodoItem } from '@/components/TodoCard';
import { TodoCarousel } from '@/components/TodoCarousel';
import { Colors } from '@/constants/Colors';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useColorScheme } from '@/hooks/useColorScheme';
import { clearErrors, completeGoal, createGoal, fetchGoals } from '@/store/goalsSlice';
import { clearErrors, createGoal, fetchGoals } from '@/store/goalsSlice';
import { CreateGoalRequest, GoalListItem } from '@/types/goals';
import { getMonthDaysZh, getMonthTitleZh, getTodayIndexInMonth } from '@/utils/date';
import { useFocusEffect } from '@react-navigation/native';
@@ -20,8 +20,8 @@ import { Alert, SafeAreaView, ScrollView, StatusBar, StyleSheet, Text, Touchable
dayjs.extend(isBetween);
// 将目标转换为TodoItem的辅助函数
const goalToTodoItem = (goal: GoalListItem): TodoItem => {
// 将目标转换为GoalItem的辅助函数
const goalToGoalItem = (goal: GoalListItem): GoalItem => {
return {
id: goal.id,
title: goal.title,
@@ -29,7 +29,6 @@ const goalToTodoItem = (goal: GoalListItem): TodoItem => {
time: dayjs().startOf('day').add(goal.startTime, 'minute').toISOString() || '',
category: getCategoryFromGoal(goal.category),
priority: getPriorityFromGoal(goal.priority),
isCompleted: goal.status === 'completed',
};
};
@@ -43,8 +42,8 @@ const getRepeatTypeLabel = (repeatType: string): string => {
}
};
// 从目标分类获取TodoItem分类
const getCategoryFromGoal = (category?: string): TodoItem['category'] => {
// 从目标分类获取GoalItem分类
const getCategoryFromGoal = (category?: string): GoalItem['category'] => {
if (!category) return 'personal';
if (category.includes('运动') || category.includes('健身')) return 'workout';
if (category.includes('工作')) return 'work';
@@ -53,8 +52,8 @@ const getCategoryFromGoal = (category?: string): TodoItem['category'] => {
return 'personal';
};
// 从目标优先级获取TodoItem优先级
const getPriorityFromGoal = (priority: number): TodoItem['priority'] => {
// 从目标优先级获取GoalItem优先级
const getPriorityFromGoal = (priority: number): GoalItem['priority'] => {
if (priority >= 8) return 'high';
if (priority >= 5) return 'medium';
return 'low';
@@ -206,8 +205,8 @@ export default function GoalsDetailScreen() {
}
};
// 将目标转换为TodoItem数据
const todayTodos = useMemo(() => {
// 将目标转换为GoalItem数据
const todayGoals = useMemo(() => {
const today = dayjs();
const activeGoals = goals.filter(goal =>
goal.status === 'active' &&
@@ -215,7 +214,7 @@ export default function GoalsDetailScreen() {
(goal.repeatType === 'weekly' && today.day() !== 0) ||
(goal.repeatType === 'monthly' && today.date() <= 28))
);
return activeGoals.map(goalToTodoItem);
return activeGoals.map(goalToGoalItem);
}, [goals]);
// 将目标转换为时间轴事件数据
@@ -251,25 +250,11 @@ export default function GoalsDetailScreen() {
console.log('filteredTimelineEvents', filteredTimelineEvents);
const handleTodoPress = (item: TodoItem) => {
const handleGoalPress = (item: GoalItem) => {
console.log('Goal pressed:', item.title);
// 这里可以导航到目标详情页面
};
const handleToggleComplete = async (item: TodoItem) => {
try {
await dispatch(completeGoal({
goalId: item.id,
completionData: {
completionCount: 1,
notes: '通过待办卡片完成'
}
})).unwrap();
} catch (error) {
Alert.alert('错误', '记录完成失败');
}
};
const handleEventPress = (event: any) => {
console.log('Event pressed:', event.title);
// 这里可以处理时间轴事件点击
@@ -318,11 +303,10 @@ export default function GoalsDetailScreen() {
</TouchableOpacity>
</View>
{/* 今日待办事项卡片 */}
<TodoCarousel
todos={todayTodos}
onTodoPress={handleTodoPress}
onToggleComplete={handleToggleComplete}
{/* 今日目标卡片 */}
<GoalCarousel
goals={todayGoals}
onGoalPress={handleGoalPress}
/>
{/* 时间筛选选项卡 */}

View File

@@ -312,6 +312,19 @@ export default function TaskDetailScreen() {
</View>
</View>
{/* 底部操作按钮 */}
{task.status !== 'completed' && task.status !== 'skipped' && task.currentCount < task.targetCount && (
<View style={styles.actionButtons}>
<TouchableOpacity
style={[styles.actionButton, styles.skipButton]}
onPress={handleSkipTask}
>
<MaterialIcons name="skip-next" size={20} color="#6B7280" />
<Text style={styles.skipButtonText}></Text>
</TouchableOpacity>
</View>
)}
{/* 评论区域 */}
<View style={styles.commentSection}>
<Text style={[styles.sectionTitle, { color: colorTokens.text }]}></Text>
@@ -348,19 +361,6 @@ export default function TaskDetailScreen() {
</View>
</View>
</View>
{/* 底部操作按钮 */}
{task.status !== 'completed' && task.status !== 'skipped' && task.currentCount < task.targetCount && (
<View style={styles.actionButtons}>
<TouchableOpacity
style={[styles.actionButton, styles.skipButton]}
onPress={handleSkipTask}
>
<MaterialIcons name="skip-next" size={20} color="#6B7280" />
<Text style={styles.skipButtonText}></Text>
</TouchableOpacity>
</View>
)}
</ScrollView>
</View>
);