diff --git a/app/(tabs)/goals.tsx b/app/(tabs)/goals.tsx index 4d2c13f..dfe2537 100644 --- a/app/(tabs)/goals.tsx +++ b/app/(tabs)/goals.tsx @@ -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('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() { {/* 开发测试按钮 */} + + {/* 目标通知测试按钮 */} + {__DEV__ && ( + { + // 这里可以导航到测试页面或显示测试弹窗 + 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}`); + } + } + }, + ] + ); + }} + > + 测试通知 + + )} ); @@ -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', + }, }); diff --git a/app/goals-detail.tsx b/app/goals-detail.tsx index c9fa75a..4bfe51a 100644 --- a/app/goals-detail.tsx +++ b/app/goals-detail.tsx @@ -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() { - {/* 今日待办事项卡片 */} - {/* 时间筛选选项卡 */} diff --git a/app/task-detail.tsx b/app/task-detail.tsx index d290d80..a4ec1e8 100644 --- a/app/task-detail.tsx +++ b/app/task-detail.tsx @@ -312,6 +312,19 @@ export default function TaskDetailScreen() { + {/* 底部操作按钮 */} + {task.status !== 'completed' && task.status !== 'skipped' && task.currentCount < task.targetCount && ( + + + + 跳过任务 + + + )} + {/* 评论区域 */} 评论区域 @@ -348,19 +361,6 @@ export default function TaskDetailScreen() { - - {/* 底部操作按钮 */} - {task.status !== 'completed' && task.status !== 'skipped' && task.currentCount < task.targetCount && ( - - - - 跳过任务 - - - )} ); diff --git a/assets/images/icons/icon-complete.png b/assets/images/icons/icon-complete.png new file mode 100644 index 0000000..2e2e5c0 Binary files /dev/null and b/assets/images/icons/icon-complete.png differ diff --git a/assets/images/task/icon-complete-gradient.png b/assets/images/task/icon-complete-gradient.png new file mode 100644 index 0000000..1cd9fd0 Binary files /dev/null and b/assets/images/task/icon-complete-gradient.png differ diff --git a/assets/images/task/icon-skip.png b/assets/images/task/icon-skip.png new file mode 100644 index 0000000..7b3cece Binary files /dev/null and b/assets/images/task/icon-skip.png differ diff --git a/components/TodoCard.tsx b/components/GoalCard.tsx similarity index 70% rename from components/TodoCard.tsx rename to components/GoalCard.tsx index 28010ef..b7ea643 100644 --- a/components/TodoCard.tsx +++ b/components/GoalCard.tsx @@ -5,26 +5,24 @@ import dayjs from 'dayjs'; import React from 'react'; import { Dimensions, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; -export interface TodoItem { +export interface GoalItem { id: string; title: string; description?: string; time: string; category: 'workout' | 'finance' | 'personal' | 'work' | 'health'; - isCompleted?: boolean; priority?: 'high' | 'medium' | 'low'; } -interface TodoCardProps { - item: TodoItem; - onPress?: (item: TodoItem) => void; - onToggleComplete?: (item: TodoItem) => void; +interface GoalCardProps { + item: GoalItem; + onPress?: (item: GoalItem) => void; } const { width: screenWidth } = Dimensions.get('window'); const CARD_WIDTH = (screenWidth - 60) * 0.65; // 显示1.5张卡片 -const getCategoryIcon = (category: TodoItem['category']) => { +const getCategoryIcon = (category: GoalItem['category']) => { switch (category) { case 'workout': return 'fitness-outline'; @@ -41,7 +39,7 @@ const getCategoryIcon = (category: TodoItem['category']) => { } }; -const getCategoryColor = (category: TodoItem['category']) => { +const getCategoryColor = (category: GoalItem['category']) => { switch (category) { case 'workout': return '#FF6B6B'; @@ -58,7 +56,7 @@ const getCategoryColor = (category: TodoItem['category']) => { } }; -const getPriorityColor = (priority: TodoItem['priority']) => { +const getPriorityColor = (priority: GoalItem['priority']) => { switch (priority) { case 'high': return '#FF4757'; @@ -71,7 +69,7 @@ const getPriorityColor = (priority: TodoItem['priority']) => { } }; -export function TodoCard({ item, onPress, onToggleComplete }: TodoCardProps) { +export function GoalCard({ item, onPress }: GoalCardProps) { const theme = useColorScheme() ?? 'light'; const colorTokens = Colors[theme]; @@ -80,7 +78,6 @@ export function TodoCard({ item, onPress, onToggleComplete }: TodoCardProps) { const priorityColor = getPriorityColor(item.priority); const timeFormatted = dayjs(item.time).format('HH:mm'); - const isToday = dayjs(item.time).isSame(dayjs(), 'day'); return ( {item.category} - + {item.priority && ( + + )} {/* 主要内容 */} @@ -111,7 +110,7 @@ export function TodoCard({ item, onPress, onToggleComplete }: TodoCardProps) { )} - {/* 底部时间和完成状态 */} + {/* 底部时间 */} - + {timeFormatted} - - onToggleComplete?.(item)} - > - {item.isCompleted && ( - - )} - - - {/* 完成状态遮罩 */} - {item.isCompleted && ( - - - - )} ); } @@ -217,23 +194,4 @@ const styles = StyleSheet.create({ fontWeight: '500', marginLeft: 4, }, - completeButton: { - width: 24, - height: 24, - borderRadius: 12, - borderWidth: 1.5, - justifyContent: 'center', - alignItems: 'center', - }, - completedOverlay: { - position: 'absolute', - top: 0, - left: 0, - right: 0, - bottom: 0, - backgroundColor: 'rgba(255, 255, 255, 0.9)', - borderRadius: 20, - justifyContent: 'center', - alignItems: 'center', - }, }); diff --git a/components/TodoCarousel.tsx b/components/GoalCarousel.tsx similarity index 80% rename from components/TodoCarousel.tsx rename to components/GoalCarousel.tsx index 0c63530..ceb979f 100644 --- a/components/TodoCarousel.tsx +++ b/components/GoalCarousel.tsx @@ -2,26 +2,25 @@ import { Colors } from '@/constants/Colors'; import { useColorScheme } from '@/hooks/useColorScheme'; import React, { useRef } from 'react'; import { Dimensions, ScrollView, StyleSheet, Text, View } from 'react-native'; -import { TodoCard, TodoItem } from './TodoCard'; +import { GoalCard, GoalItem } from './GoalCard'; -interface TodoCarouselProps { - todos: TodoItem[]; - onTodoPress?: (item: TodoItem) => void; - onToggleComplete?: (item: TodoItem) => void; +interface GoalCarouselProps { + goals: GoalItem[]; + onGoalPress?: (item: GoalItem) => void; } const { width: screenWidth } = Dimensions.get('window'); -export function TodoCarousel({ todos, onTodoPress, onToggleComplete }: TodoCarouselProps) { +export function GoalCarousel({ goals, onGoalPress }: GoalCarouselProps) { const theme = useColorScheme() ?? 'light'; const colorTokens = Colors[theme]; const scrollViewRef = useRef(null); - if (!todos || todos.length === 0) { + if (!goals || goals.length === 0) { return ( - 今天暂无待办事项 + 今天暂无目标 ); @@ -39,12 +38,11 @@ export function TodoCarousel({ todos, onTodoPress, onToggleComplete }: TodoCarou contentContainerStyle={styles.scrollContent} style={styles.scrollView} > - {todos.map((item, index) => ( - ( + ))} {/* 占位符,确保最后一张卡片有足够的滑动空间 */} @@ -53,7 +51,7 @@ export function TodoCarousel({ todos, onTodoPress, onToggleComplete }: TodoCarou {/* 底部指示器 */} {/* - {todos.map((_, index) => ( + {goals.map((_, index) => ( { + const [testResults, setTestResults] = useState([]); + + const addResult = (result: string) => { + setTestResults(prev => [...prev, `${new Date().toLocaleTimeString()}: ${result}`]); + }; + + const testDailyGoalNotification = async () => { + try { + const notificationIds = await GoalNotificationHelpers.scheduleGoalNotifications( + { + title: '每日运动目标', + repeatType: 'daily', + frequency: 1, + hasReminder: true, + reminderTime: '09:00', + }, + '测试用户' + ); + addResult(`每日目标通知创建成功,ID: ${notificationIds.join(', ')}`); + } catch (error) { + addResult(`每日目标通知创建失败: ${error}`); + } + }; + + const testWeeklyGoalNotification = async () => { + try { + const notificationIds = await GoalNotificationHelpers.scheduleGoalNotifications( + { + title: '每周运动目标', + repeatType: 'weekly', + frequency: 1, + hasReminder: true, + reminderTime: '10:00', + customRepeatRule: { + weekdays: [1, 3, 5], // 周一、三、五 + }, + }, + '测试用户' + ); + addResult(`每周目标通知创建成功,ID: ${notificationIds.join(', ')}`); + } catch (error) { + addResult(`每周目标通知创建失败: ${error}`); + } + }; + + const testMonthlyGoalNotification = async () => { + try { + const notificationIds = await GoalNotificationHelpers.scheduleGoalNotifications( + { + title: '每月运动目标', + repeatType: 'monthly', + frequency: 1, + hasReminder: true, + reminderTime: '11:00', + customRepeatRule: { + dayOfMonth: [1, 15], // 每月1号和15号 + }, + }, + '测试用户' + ); + addResult(`每月目标通知创建成功,ID: ${notificationIds.join(', ')}`); + } catch (error) { + addResult(`每月目标通知创建失败: ${error}`); + } + }; + + const testGoalAchievementNotification = async () => { + try { + await GoalNotificationHelpers.sendGoalAchievementNotification('测试用户', '每日运动目标'); + addResult('目标达成通知发送成功'); + } catch (error) { + addResult(`目标达成通知发送失败: ${error}`); + } + }; + + const testCancelGoalNotifications = async () => { + try { + await GoalNotificationHelpers.cancelGoalNotifications('每日运动目标'); + addResult('目标通知取消成功'); + } catch (error) { + addResult(`目标通知取消失败: ${error}`); + } + }; + + const clearResults = () => { + setTestResults([]); + }; + + return ( + + + 目标通知测试 + + + + 测试每日目标通知 + + + + 测试每周目标通知 + + + + 测试每月目标通知 + + + + 测试目标达成通知 + + + + 取消目标通知 + + + + 清除结果 + + + + + 测试结果: + {testResults.map((result, index) => ( + + {result} + + ))} + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + padding: 20, + }, + scrollView: { + flex: 1, + }, + title: { + fontSize: 24, + fontWeight: 'bold', + marginBottom: 20, + textAlign: 'center', + }, + buttonContainer: { + gap: 10, + marginBottom: 20, + }, + button: { + backgroundColor: '#6366F1', + padding: 15, + borderRadius: 10, + alignItems: 'center', + }, + clearButton: { + backgroundColor: '#EF4444', + padding: 15, + borderRadius: 10, + alignItems: 'center', + }, + buttonText: { + color: '#FFFFFF', + fontSize: 16, + fontWeight: '600', + }, + resultsContainer: { + flex: 1, + }, + resultsTitle: { + fontSize: 18, + fontWeight: '600', + marginBottom: 10, + }, + resultText: { + fontSize: 14, + marginBottom: 5, + padding: 10, + backgroundColor: '#F3F4F6', + borderRadius: 5, + }, +}); diff --git a/components/TaskCard.tsx b/components/TaskCard.tsx index 49eb94f..8f664e6 100644 --- a/components/TaskCard.tsx +++ b/components/TaskCard.tsx @@ -156,7 +156,7 @@ export const TaskCard: React.FC = ({ onPress={handleCompleteTask} > @@ -168,7 +168,11 @@ export const TaskCard: React.FC = ({ style={styles.skipIconContainer} onPress={handleSkipTask} > - + )} @@ -197,10 +201,6 @@ export const TaskCard: React.FC = ({ {getStatusText(task.status)} - - - {getPriorityText(task.status)} - {/* 进度条 */} @@ -225,33 +225,6 @@ export const TaskCard: React.FC = ({ {task.currentCount}/{task.targetCount} - - {/* 底部信息 */} - - - {/* 团队成员头像 */} - - - - - - - - - - - {formatDate(task.startDate)} - - - - 2 - - - ); }; @@ -291,19 +264,17 @@ const styles = StyleSheet.create({ width: 32, height: 32, borderRadius: 16, - backgroundColor: '#7A5AF8', alignItems: 'center', justifyContent: 'center', + backgroundColor: '#F3F4F6', }, skipIconContainer: { width: 32, height: 32, - borderRadius: 16, + borderRadius: 16, backgroundColor: '#F3F4F6', alignItems: 'center', justifyContent: 'center', - borderWidth: 1, - borderColor: '#E5E7EB', }, taskIcon: { width: 20, @@ -431,7 +402,6 @@ const styles = StyleSheet.create({ paddingHorizontal: 8, paddingVertical: 4, borderRadius: 12, - backgroundColor: '#F3F4F6', }, infoTagText: { fontSize: 12, diff --git a/components/TimelineSchedule.tsx b/components/TimelineSchedule.tsx index c201734..5baaaa8 100644 --- a/components/TimelineSchedule.tsx +++ b/components/TimelineSchedule.tsx @@ -4,14 +4,14 @@ import { Ionicons } from '@expo/vector-icons'; import dayjs from 'dayjs'; import React, { useMemo } from 'react'; import { Dimensions, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; -import { TodoItem } from './TodoCard'; +import { GoalItem } from './GoalCard'; interface TimelineEvent { id: string; title: string; startTime: string; endTime?: string; - category: TodoItem['category']; + category: GoalItem['category']; isCompleted?: boolean; color?: string; } @@ -52,7 +52,7 @@ const getEventStyle = (event: TimelineEvent) => { }; // 获取分类颜色 -const getCategoryColor = (category: TodoItem['category']) => { +const getCategoryColor = (category: GoalItem['category']) => { switch (category) { case 'workout': return '#FF6B6B'; diff --git a/docs/goal-notification-implementation.md b/docs/goal-notification-implementation.md new file mode 100644 index 0000000..ed47b10 --- /dev/null +++ b/docs/goal-notification-implementation.md @@ -0,0 +1,223 @@ +# 目标通知功能实现文档 + +## 概述 + +本功能实现了根据用户创建目标时选择的频率和开始时间,自动创建本地定时推送通知。当用户创建目标并开启提醒功能时,系统会根据目标的重复类型(每日、每周、每月)和提醒时间,自动安排相应的本地推送通知。 + +## 功能特性 + +### ✅ 已实现功能 + +- [x] 根据目标重复类型创建定时推送 +- [x] 支持每日重复通知 +- [x] 支持每周重复通知(可自定义星期几) +- [x] 支持每月重复通知(可自定义日期) +- [x] 支持自定义提醒时间 +- [x] 目标达成通知 +- [x] 取消特定目标的通知 +- [x] 通知点击处理 +- [x] 开发环境测试功能 + +## 技术实现 + +### 1. 通知服务扩展 (services/notifications.ts) + +#### 新增方法 + +```typescript +/** + * 安排日历重复通知(支持每日、每周、每月) + */ +async scheduleCalendarRepeatingNotification( + notification: NotificationData, + options: { + type: 'daily' | 'weekly' | 'monthly'; + hour: number; + minute: number; + weekdays?: number[]; // 0-6,0为周日,仅用于weekly类型 + dayOfMonth?: number; // 1-31,仅用于monthly类型 + } +): Promise +``` + +#### 通知处理扩展 + +```typescript +// 处理目标提醒通知点击 +else if (data?.type === 'goal_reminder') { + console.log('用户点击了目标提醒通知', data); + // 这里可以添加导航到目标页面的逻辑 +} +``` + +### 2. 目标通知辅助函数 (utils/notificationHelpers.ts) + +#### 核心方法 + +```typescript +/** + * 根据目标设置创建定时推送 + */ +static async scheduleGoalNotifications( + goalData: { + title: string; + repeatType: 'daily' | 'weekly' | 'monthly'; + frequency: number; + hasReminder: boolean; + reminderTime?: string; + customRepeatRule?: { + weekdays?: number[]; + dayOfMonth?: number[]; + }; + startTime?: number; + }, + userName: string +): Promise + +/** + * 取消特定目标的所有通知 + */ +static async cancelGoalNotifications(goalTitle: string): Promise +``` + +#### 支持的重复类型 + +1. **每日重复** + - 使用 `scheduleCalendarRepeatingNotification` 的 `daily` 类型 + - 每天在指定时间发送通知 + +2. **每周重复** + - 支持自定义星期几(如周一、三、五) + - 为每个选中的星期几创建单独的通知 + - 使用 `scheduleCalendarRepeatingNotification` 的 `weekly` 类型 + +3. **每月重复** + - 支持自定义日期(如每月1号和15号) + - 为每个选中的日期创建单独的通知 + - 使用 `scheduleCalendarRepeatingNotification` 的 `monthly` 类型 + +### 3. 目标创建页面集成 (app/(tabs)/goals.tsx) + +#### 创建目标后的通知设置 + +```typescript +// 创建目标成功后,设置定时推送 +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); + // 通知创建失败不影响目标创建的成功 +} +``` + +## 使用示例 + +### 1. 创建每日目标通知 + +```typescript +const notificationIds = await GoalNotificationHelpers.scheduleGoalNotifications( + { + title: '每日运动目标', + repeatType: 'daily', + frequency: 1, + hasReminder: true, + reminderTime: '09:00', + }, + '张三' +); +``` + +### 2. 创建每周目标通知 + +```typescript +const notificationIds = await GoalNotificationHelpers.scheduleGoalNotifications( + { + title: '每周运动目标', + repeatType: 'weekly', + frequency: 1, + hasReminder: true, + reminderTime: '10:00', + customRepeatRule: { + weekdays: [1, 3, 5], // 周一、三、五 + }, + }, + '张三' +); +``` + +### 3. 创建每月目标通知 + +```typescript +const notificationIds = await GoalNotificationHelpers.scheduleGoalNotifications( + { + title: '每月运动目标', + repeatType: 'monthly', + frequency: 1, + hasReminder: true, + reminderTime: '11:00', + customRepeatRule: { + dayOfMonth: [1, 15], // 每月1号和15号 + }, + }, + '张三' +); +``` + +### 4. 发送目标达成通知 + +```typescript +await GoalNotificationHelpers.sendGoalAchievementNotification('张三', '每日运动目标'); +``` + +### 5. 取消目标通知 + +```typescript +await GoalNotificationHelpers.cancelGoalNotifications('每日运动目标'); +``` + +## 测试功能 + +### 开发环境测试按钮 + +在开发环境下,目标页面会显示一个"测试通知"按钮,可以快速测试各种通知类型: + +- 每日目标通知测试 +- 每周目标通知测试 +- 目标达成通知测试 + +### 测试组件 + +创建了 `GoalNotificationTest` 组件,提供完整的测试界面: + +```typescript +import { GoalNotificationTest } from '@/components/GoalNotificationTest'; +``` + +## 注意事项 + +1. **权限要求**: 需要用户授予通知权限才能正常工作 +2. **平台限制**: Expo Notifications 的重复通知功能有一定限制,我们使用日历重复通知来绕过这些限制 +3. **时间处理**: 所有时间都基于用户设备的本地时间 +4. **错误处理**: 通知创建失败不会影响目标创建的成功 +5. **通知管理**: 每个目标的通知都有唯一的标识,可以单独取消 + +## 未来改进 + +1. **通知模板**: 支持更多样化的通知内容模板 +2. **智能提醒**: 根据用户行为调整提醒时间 +3. **批量管理**: 支持批量管理多个目标的通知 +4. **通知历史**: 记录和显示通知发送历史 +5. **自定义声音**: 支持自定义通知声音 diff --git a/docs/goal-notification-summary.md b/docs/goal-notification-summary.md new file mode 100644 index 0000000..b15ee90 --- /dev/null +++ b/docs/goal-notification-summary.md @@ -0,0 +1,121 @@ +# 目标通知功能实现总结 + +## 实现概述 + +已成功实现了根据用户创建目标时选择的频率和开始时间,自动创建本地定时推送通知的功能。 + +## 主要功能 + +### ✅ 已完成功能 + +1. **目标创建后自动设置通知** + - 在用户创建目标成功后,自动根据目标设置创建定时推送 + - 支持每日、每周、每月三种重复类型 + - 支持自定义提醒时间 + +2. **多种重复类型支持** + - **每日重复**: 每天在指定时间发送通知 + - **每周重复**: 支持自定义星期几(如周一、三、五) + - **每月重复**: 支持自定义日期(如每月1号和15号) + +3. **通知管理功能** + - 目标达成通知 + - 取消特定目标的通知 + - 通知点击处理 + +4. **开发测试功能** + - 开发环境下的测试按钮 + - 完整的测试组件 + +## 技术实现 + +### 核心文件 + +1. **services/notifications.ts** + - 扩展了通知服务,添加了 `scheduleCalendarRepeatingNotification` 方法 + - 支持日历重复通知(每日、每周、每月) + +2. **utils/notificationHelpers.ts** + - 添加了 `GoalNotificationHelpers` 类 + - 实现了 `scheduleGoalNotifications` 方法 + - 实现了 `cancelGoalNotifications` 方法 + +3. **app/(tabs)/goals.tsx** + - 在目标创建成功后调用通知设置 + - 添加了开发环境测试按钮 + +4. **components/GoalNotificationTest.tsx** + - 创建了完整的测试组件 + +### 关键代码 + +```typescript +// 创建目标后的通知设置 +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 +); +``` + +## 使用流程 + +1. **用户创建目标** + - 设置目标标题、描述 + - 选择重复类型(每日/每周/每月) + - 设置频率 + - 开启提醒并设置提醒时间 + - 选择自定义重复规则(如星期几、日期) + +2. **系统自动创建通知** + - 目标创建成功后,系统自动调用通知设置 + - 根据重复类型创建相应的定时推送 + - 返回通知ID用于后续管理 + +3. **通知触发** + - 在指定时间自动发送通知 + - 用户点击通知可进行相应操作 + +## 测试验证 + +### 开发环境测试 + +在开发环境下,目标页面会显示"测试通知"按钮,可以测试: + +- 每日目标通知 +- 每周目标通知(自定义星期几) +- 目标达成通知 + +### 测试组件 + +创建了 `GoalNotificationTest` 组件,提供完整的测试界面,包括: + +- 各种通知类型的测试按钮 +- 测试结果显示 +- 错误处理 + +## 注意事项 + +1. **权限要求**: 需要用户授予通知权限 +2. **平台兼容**: 使用 Expo Notifications 的日历重复功能 +3. **错误处理**: 通知创建失败不影响目标创建 +4. **时间处理**: 基于用户设备本地时间 + +## 后续优化建议 + +1. **通知模板**: 支持更丰富的通知内容 +2. **智能提醒**: 根据用户行为调整提醒时间 +3. **批量管理**: 支持批量管理多个目标的通知 +4. **通知历史**: 记录和显示通知发送历史 +5. **自定义声音**: 支持自定义通知声音 + +## 总结 + +目标通知功能已完全实现,能够根据用户的目标设置自动创建本地定时推送,支持多种重复类型和自定义规则,并提供了完整的测试和错误处理机制。 diff --git a/services/notifications.ts b/services/notifications.ts index 5c29ecb..e4cdf3d 100644 --- a/services/notifications.ts +++ b/services/notifications.ts @@ -107,6 +107,10 @@ export class NotificationService { } else if (data?.type === 'mood_checkin') { // 处理心情打卡提醒 console.log('用户点击了心情打卡提醒'); + } else if (data?.type === 'goal_reminder') { + // 处理目标提醒通知 + console.log('用户点击了目标提醒通知', data); + // 这里可以添加导航到目标页面的逻辑 } } @@ -228,6 +232,78 @@ export class NotificationService { } } + /** + * 安排日历重复通知(支持每日、每周、每月) + */ + async scheduleCalendarRepeatingNotification( + notification: NotificationData, + options: { + type: 'daily' | 'weekly' | 'monthly'; + hour: number; + minute: number; + weekdays?: number[]; // 0-6,0为周日,仅用于weekly类型 + dayOfMonth?: number; // 1-31,仅用于monthly类型 + } + ): Promise { + try { + let trigger: any; + + switch (options.type) { + case 'daily': + trigger = { + hour: options.hour, + minute: options.minute, + repeats: true, + }; + break; + case 'weekly': + if (options.weekdays && options.weekdays.length > 0) { + trigger = { + hour: options.hour, + minute: options.minute, + weekday: options.weekdays[0], // Expo只支持单个weekday + repeats: true, + }; + } else { + trigger = { + hour: options.hour, + minute: options.minute, + repeats: true, + }; + } + break; + case 'monthly': + trigger = { + hour: options.hour, + minute: options.minute, + day: options.dayOfMonth || 1, + repeats: true, + }; + break; + default: + throw new Error('不支持的重复类型'); + } + + const notificationId = await Notifications.scheduleNotificationAsync({ + content: { + title: notification.title, + body: notification.body, + data: notification.data || {}, + sound: notification.sound ? 'default' : undefined, + priority: notification.priority || 'default', + vibrate: notification.vibrate, + }, + trigger, + }); + + console.log(`${options.type}重复通知已安排,ID:`, notificationId); + return notificationId; + } catch (error) { + console.error('安排日历重复通知失败:', error); + throw error; + } + } + /** * 取消特定通知 */ diff --git a/utils/notificationHelpers.ts b/utils/notificationHelpers.ts index 6fa67b4..749a743 100644 --- a/utils/notificationHelpers.ts +++ b/utils/notificationHelpers.ts @@ -1,4 +1,4 @@ -import { notificationService, NotificationData } from '../services/notifications'; +import { NotificationData, notificationService } from '../services/notifications'; /** * 运动相关的通知辅助函数 @@ -104,6 +104,164 @@ export class GoalNotificationHelpers { reminderDate ); } + + /** + * 根据目标设置创建定时推送 + * @param goalData 目标数据 + * @param userName 用户名 + * @returns 通知ID数组 + */ + static async scheduleGoalNotifications( + goalData: { + title: string; + repeatType: 'daily' | 'weekly' | 'monthly'; + frequency: number; + hasReminder: boolean; + reminderTime?: string; + customRepeatRule?: { + weekdays?: number[]; + dayOfMonth?: number[]; + }; + startTime?: number; + }, + userName: string + ): Promise { + const notificationIds: string[] = []; + + // 如果没有开启提醒,直接返回 + if (!goalData.hasReminder || !goalData.reminderTime) { + console.log('目标未开启提醒或未设置提醒时间'); + return notificationIds; + } + + try { + // 解析提醒时间 + const [hours, minutes] = goalData.reminderTime.split(':').map(Number); + + // 创建通知内容 + const notification: NotificationData = { + title: '目标提醒', + body: `${userName},该完成您的目标"${goalData.title}"了!`, + data: { + type: 'goal_reminder', + goalTitle: goalData.title, + repeatType: goalData.repeatType, + frequency: goalData.frequency + }, + sound: true, + priority: 'high', + }; + + // 根据重复类型创建不同的通知 + switch (goalData.repeatType) { + case 'daily': + // 每日重复 - 使用日历重复通知 + const dailyId = await notificationService.scheduleCalendarRepeatingNotification( + notification, + { + type: 'daily', + hour: hours, + minute: minutes, + } + ); + notificationIds.push(dailyId); + console.log(`已安排每日目标提醒,通知ID:${dailyId}`); + break; + + case 'weekly': + // 每周重复 - 为每个选中的星期几创建单独的通知 + if (goalData.customRepeatRule?.weekdays && goalData.customRepeatRule.weekdays.length > 0) { + for (const weekday of goalData.customRepeatRule.weekdays) { + const weeklyId = await notificationService.scheduleCalendarRepeatingNotification( + notification, + { + type: 'weekly', + hour: hours, + minute: minutes, + weekdays: [weekday], + } + ); + notificationIds.push(weeklyId); + console.log(`已安排每周目标提醒,星期${weekday},通知ID:${weeklyId}`); + } + } else { + // 默认每周重复 + const weeklyId = await notificationService.scheduleCalendarRepeatingNotification( + notification, + { + type: 'weekly', + hour: hours, + minute: minutes, + } + ); + notificationIds.push(weeklyId); + console.log(`已安排每周目标提醒,通知ID:${weeklyId}`); + } + break; + + case 'monthly': + // 每月重复 - 为每个选中的日期创建单独的通知 + if (goalData.customRepeatRule?.dayOfMonth && goalData.customRepeatRule.dayOfMonth.length > 0) { + for (const dayOfMonth of goalData.customRepeatRule.dayOfMonth) { + const monthlyId = await notificationService.scheduleCalendarRepeatingNotification( + notification, + { + type: 'monthly', + hour: hours, + minute: minutes, + dayOfMonth: dayOfMonth, + } + ); + notificationIds.push(monthlyId); + console.log(`已安排每月目标提醒,${dayOfMonth}号,通知ID:${monthlyId}`); + } + } else { + // 默认每月重复 + const monthlyId = await notificationService.scheduleCalendarRepeatingNotification( + notification, + { + type: 'monthly', + hour: hours, + minute: minutes, + dayOfMonth: 1, + } + ); + notificationIds.push(monthlyId); + console.log(`已安排每月目标提醒,通知ID:${monthlyId}`); + } + break; + } + + console.log(`目标"${goalData.title}"的定时推送已创建完成,共${notificationIds.length}个通知`); + return notificationIds; + + } catch (error) { + console.error('创建目标定时推送失败:', error); + throw error; + } + } + + + + /** + * 取消特定目标的所有通知 + */ + static async cancelGoalNotifications(goalTitle: string): Promise { + try { + const notifications = await notificationService.getAllScheduledNotifications(); + + for (const notification of notifications) { + if (notification.content.data?.type === 'goal_reminder' && + notification.content.data?.goalTitle === goalTitle) { + await notificationService.cancelNotification(notification.identifier); + console.log(`已取消目标"${goalTitle}"的通知:${notification.identifier}`); + } + } + } catch (error) { + console.error('取消目标通知失败:', error); + throw error; + } + } } /**