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

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

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