feat: 实现目标通知功能及相关组件
- 新增目标通知功能,支持根据用户创建目标时选择的频率和开始时间自动创建本地定时推送通知 - 实现每日、每周和每月的重复类型,用户可自定义选择提醒时间和重复规则 - 集成目标通知测试组件,方便开发者测试不同类型的通知 - 更新相关文档,详细描述目标通知功能的实现和使用方法 - 优化目标页面,确保用户体验和界面一致性
This commit is contained in:
@@ -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',
|
||||
},
|
||||
});
|
||||
@@ -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={[
|
||||
195
components/GoalNotificationTest.tsx
Normal file
195
components/GoalNotificationTest.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user