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>
);

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

View File

@@ -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 (
<TouchableOpacity
@@ -95,7 +92,9 @@ export function TodoCard({ item, onPress, onToggleComplete }: TodoCardProps) {
<Text style={styles.categoryText}>{item.category}</Text>
</View>
<Ionicons name="star-outline" size={18} color={colorTokens.textMuted} />
{item.priority && (
<View style={[styles.priorityDot, { backgroundColor: priorityColor }]} />
)}
</View>
{/* 主要内容 */}
@@ -111,7 +110,7 @@ export function TodoCard({ item, onPress, onToggleComplete }: TodoCardProps) {
)}
</View>
{/* 底部时间和完成状态 */}
{/* 底部时间 */}
<View style={styles.footer}>
<View style={styles.timeContainer}>
<Ionicons
@@ -119,33 +118,11 @@ export function TodoCard({ item, onPress, onToggleComplete }: TodoCardProps) {
size={14}
color={colorTokens.textMuted}
/>
<Text style={[styles.timeText]}>
<Text style={[styles.timeText, { color: colorTokens.textMuted }]}>
{timeFormatted}
</Text>
</View>
<TouchableOpacity
style={[
styles.completeButton,
{
backgroundColor: item.isCompleted ? colorTokens.primary : 'transparent',
borderColor: item.isCompleted ? colorTokens.primary : colorTokens.border
}
]}
onPress={() => onToggleComplete?.(item)}
>
{item.isCompleted && (
<Ionicons name="checkmark" size={16} color={colorTokens.onPrimary} />
)}
</TouchableOpacity>
</View>
{/* 完成状态遮罩 */}
{item.isCompleted && (
<View style={styles.completedOverlay}>
<Ionicons name="checkmark-circle" size={24} color={colorTokens.primary} />
</View>
)}
</TouchableOpacity>
);
}
@@ -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',
},
});

View File

@@ -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<ScrollView>(null);
if (!todos || todos.length === 0) {
if (!goals || goals.length === 0) {
return (
<View style={styles.emptyContainer}>
<Text style={[styles.emptyText, { color: colorTokens.textMuted }]}>
</Text>
</View>
);
@@ -39,12 +38,11 @@ export function TodoCarousel({ todos, onTodoPress, onToggleComplete }: TodoCarou
contentContainerStyle={styles.scrollContent}
style={styles.scrollView}
>
{todos.map((item, index) => (
<TodoCard
{goals.map((item, index) => (
<GoalCard
key={item.id}
item={item}
onPress={onTodoPress}
onToggleComplete={onToggleComplete}
onPress={onGoalPress}
/>
))}
{/* 占位符,确保最后一张卡片有足够的滑动空间 */}
@@ -53,7 +51,7 @@ export function TodoCarousel({ todos, onTodoPress, onToggleComplete }: TodoCarou
{/* 底部指示器 */}
{/* <View style={styles.indicatorContainer}>
{todos.map((_, index) => (
{goals.map((_, index) => (
<View
key={index}
style={[

View File

@@ -0,0 +1,195 @@
import { GoalNotificationHelpers } from '@/utils/notificationHelpers';
import React, { useState } from 'react';
import {
ScrollView,
StyleSheet,
TouchableOpacity,
View,
} from 'react-native';
import { ThemedText } from './ThemedText';
import { ThemedView } from './ThemedView';
export const GoalNotificationTest: React.FC = () => {
const [testResults, setTestResults] = useState<string[]>([]);
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 (
<ThemedView style={styles.container}>
<ScrollView style={styles.scrollView}>
<ThemedText style={styles.title}></ThemedText>
<View style={styles.buttonContainer}>
<TouchableOpacity style={styles.button} onPress={testDailyGoalNotification}>
<ThemedText style={styles.buttonText}></ThemedText>
</TouchableOpacity>
<TouchableOpacity style={styles.button} onPress={testWeeklyGoalNotification}>
<ThemedText style={styles.buttonText}></ThemedText>
</TouchableOpacity>
<TouchableOpacity style={styles.button} onPress={testMonthlyGoalNotification}>
<ThemedText style={styles.buttonText}></ThemedText>
</TouchableOpacity>
<TouchableOpacity style={styles.button} onPress={testGoalAchievementNotification}>
<ThemedText style={styles.buttonText}></ThemedText>
</TouchableOpacity>
<TouchableOpacity style={styles.button} onPress={testCancelGoalNotifications}>
<ThemedText style={styles.buttonText}></ThemedText>
</TouchableOpacity>
<TouchableOpacity style={styles.clearButton} onPress={clearResults}>
<ThemedText style={styles.buttonText}></ThemedText>
</TouchableOpacity>
</View>
<View style={styles.resultsContainer}>
<ThemedText style={styles.resultsTitle}>:</ThemedText>
{testResults.map((result, index) => (
<ThemedText key={index} style={styles.resultText}>
{result}
</ThemedText>
))}
</View>
</ScrollView>
</ThemedView>
);
};
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,
},
});

View File

@@ -156,7 +156,7 @@ export const TaskCard: React.FC<TaskCardProps> = ({
onPress={handleCompleteTask}
>
<Image
source={require('@/assets/images/task/iconTaskHeader.png')}
source={require('@/assets/images/task/icon-complete-gradient.png')}
style={styles.taskIcon}
resizeMode="contain"
/>
@@ -168,7 +168,11 @@ export const TaskCard: React.FC<TaskCardProps> = ({
style={styles.skipIconContainer}
onPress={handleSkipTask}
>
<MaterialIcons name="skip-next" size={20} color="#6B7280" />
<Image
source={require('@/assets/images/task/icon-skip.png')}
style={styles.taskIcon}
resizeMode="contain"
/>
</TouchableOpacity>
)}
</View>
@@ -197,10 +201,6 @@ export const TaskCard: React.FC<TaskCardProps> = ({
<MaterialIcons name="schedule" size={12} color="#6B7280" />
<Text style={styles.statusTagText}>{getStatusText(task.status)}</Text>
</View>
<View style={[styles.priorityTag, { backgroundColor: getPriorityColor(task.status) }]}>
<MaterialIcons name="flag" size={12} color="#FFFFFF" />
<Text style={styles.priorityTagText}>{getPriorityText(task.status)}</Text>
</View>
</View>
{/* 进度条 */}
@@ -225,33 +225,6 @@ export const TaskCard: React.FC<TaskCardProps> = ({
<Text style={styles.progressText}>{task.currentCount}/{task.targetCount}</Text>
</View>
</View>
{/* 底部信息 */}
<View style={styles.footer}>
<View style={styles.teamSection}>
{/* 团队成员头像 */}
<View style={styles.avatars}>
<View style={styles.avatar}>
<Image
source={require('@/assets/images/Sealife.jpeg')}
style={styles.avatarImage}
resizeMode="cover"
/>
</View>
</View>
</View>
<View style={styles.infoSection}>
<View style={styles.infoTag}>
<MaterialIcons name="event" size={12} color="#6B7280" />
<Text style={styles.infoTagText}>{formatDate(task.startDate)}</Text>
</View>
<View style={styles.infoTag}>
<MaterialIcons name="chat-bubble-outline" size={12} color="#6B7280" />
<Text style={styles.infoTagText}>2</Text>
</View>
</View>
</View>
</TouchableOpacity>
);
};
@@ -291,9 +264,9 @@ const styles = StyleSheet.create({
width: 32,
height: 32,
borderRadius: 16,
backgroundColor: '#7A5AF8',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#F3F4F6',
},
skipIconContainer: {
width: 32,
@@ -302,8 +275,6 @@ const styles = StyleSheet.create({
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,

View File

@@ -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';

View File

@@ -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-60为周日仅用于weekly类型
dayOfMonth?: number; // 1-31仅用于monthly类型
}
): Promise<string>
```
#### 通知处理扩展
```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<string[]>
/**
* 取消特定目标的所有通知
*/
static async cancelGoalNotifications(goalTitle: string): Promise<void>
```
#### 支持的重复类型
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. **自定义声音**: 支持自定义通知声音

View File

@@ -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. **自定义声音**: 支持自定义通知声音
## 总结
目标通知功能已完全实现,能够根据用户的目标设置自动创建本地定时推送,支持多种重复类型和自定义规则,并提供了完整的测试和错误处理机制。

View File

@@ -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-60为周日仅用于weekly类型
dayOfMonth?: number; // 1-31仅用于monthly类型
}
): Promise<string> {
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;
}
}
/**
* 取消特定通知
*/

View File

@@ -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<string[]> {
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<void> {
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;
}
}
}
/**