- 新增目标通知功能,支持根据用户创建目标时选择的频率和开始时间自动创建本地定时推送通知 - 实现每日、每周和每月的重复类型,用户可自定义选择提醒时间和重复规则 - 集成目标通知测试组件,方便开发者测试不同类型的通知 - 更新相关文档,详细描述目标通知功能的实现和使用方法 - 优化目标页面,确保用户体验和界面一致性
412 lines
10 KiB
TypeScript
412 lines
10 KiB
TypeScript
import { useGlobalDialog } from '@/components/ui/DialogProvider';
|
||
import { Colors } from '@/constants/Colors';
|
||
import { useAppDispatch } from '@/hooks/redux';
|
||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||
import { completeTask, skipTask } from '@/store/tasksSlice';
|
||
import { TaskListItem } from '@/types/goals';
|
||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||
import { useRouter } from 'expo-router';
|
||
import React from 'react';
|
||
import { Alert, Animated, Image, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||
|
||
interface TaskCardProps {
|
||
task: TaskListItem;
|
||
}
|
||
|
||
export const TaskCard: React.FC<TaskCardProps> = ({
|
||
task,
|
||
}) => {
|
||
const theme = useColorScheme() ?? 'light';
|
||
const colorTokens = Colors[theme];
|
||
const dispatch = useAppDispatch();
|
||
const { showConfirm } = useGlobalDialog();
|
||
const router = useRouter();
|
||
|
||
// 创建进度条动画值
|
||
const progressAnimation = React.useRef(new Animated.Value(0)).current;
|
||
|
||
// 当任务进度变化时,启动动画
|
||
React.useEffect(() => {
|
||
const targetProgress = task.progressPercentage > 0 ? Math.min(task.progressPercentage, 100) : 2;
|
||
|
||
Animated.timing(progressAnimation, {
|
||
toValue: targetProgress,
|
||
duration: 800, // 动画持续时间800毫秒
|
||
useNativeDriver: false, // 因为我们要动画width属性,所以不能使用原生驱动
|
||
}).start();
|
||
}, [task.progressPercentage, progressAnimation]);
|
||
|
||
|
||
const getStatusText = (status: string) => {
|
||
switch (status) {
|
||
case 'completed':
|
||
return '已完成';
|
||
case 'in_progress':
|
||
return '进行中';
|
||
case 'overdue':
|
||
return '已过期';
|
||
case 'skipped':
|
||
return '已跳过';
|
||
default:
|
||
return '待开始';
|
||
}
|
||
};
|
||
|
||
const getPriorityColor = (status: string) => {
|
||
switch (status) {
|
||
case 'overdue':
|
||
return '#EF4444'; // High - 过期任务
|
||
case 'in_progress':
|
||
return '#F59E0B'; // Medium - 进行中
|
||
case 'completed':
|
||
return '#10B981'; // Low - 已完成
|
||
default:
|
||
return '#6B7280'; // Default - 待开始
|
||
}
|
||
};
|
||
|
||
const getPriorityText = (status: string) => {
|
||
switch (status) {
|
||
case 'overdue':
|
||
return '高';
|
||
case 'in_progress':
|
||
return '中';
|
||
case 'completed':
|
||
return '低';
|
||
default:
|
||
return '';
|
||
}
|
||
};
|
||
|
||
const formatDate = (dateString: string) => {
|
||
const date = new Date(dateString);
|
||
const month = date.toLocaleDateString('zh-CN', { month: 'short' });
|
||
const day = date.getDate();
|
||
return `${day} ${month}`;
|
||
};
|
||
|
||
const handleCompleteTask = async () => {
|
||
// 如果任务已经完成,不执行任何操作
|
||
if (task.status === 'completed') {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
// 调用完成任务 API
|
||
await dispatch(completeTask({
|
||
taskId: task.id,
|
||
completionData: {
|
||
count: 1,
|
||
notes: '通过任务卡片完成'
|
||
}
|
||
})).unwrap();
|
||
|
||
} catch (error) {
|
||
Alert.alert('错误', '完成任务失败,请重试');
|
||
}
|
||
};
|
||
|
||
const handleSkipTask = async () => {
|
||
// 如果任务已经完成或已跳过,不执行任何操作
|
||
if (task.status === 'completed' || task.status === 'skipped') {
|
||
return;
|
||
}
|
||
|
||
// 显示确认弹窗
|
||
showConfirm(
|
||
{
|
||
title: '确认跳过任务',
|
||
message: `确定要跳过任务"${task.title}"吗?\n\n跳过后的任务将不会显示在任务列表中,且无法恢复。`,
|
||
confirmText: '跳过',
|
||
cancelText: '取消',
|
||
destructive: true,
|
||
icon: 'warning',
|
||
iconColor: '#F59E0B',
|
||
},
|
||
async () => {
|
||
try {
|
||
// 调用跳过任务 API
|
||
await dispatch(skipTask({
|
||
taskId: task.id,
|
||
skipData: {
|
||
reason: '用户主动跳过'
|
||
}
|
||
})).unwrap();
|
||
} catch (error) {
|
||
Alert.alert('错误', '跳过任务失败,请重试');
|
||
}
|
||
}
|
||
);
|
||
};
|
||
|
||
const handleTaskPress = () => {
|
||
router.push(`/task-detail?taskId=${task.id}`);
|
||
};
|
||
|
||
const renderActionIcons = () => {
|
||
if (task.status === 'completed' || task.status === 'overdue' || task.status === 'skipped') {
|
||
return null;
|
||
}
|
||
|
||
return (
|
||
<View style={styles.actionIconsContainer}>
|
||
{/* 完成任务图标 */}
|
||
<TouchableOpacity
|
||
style={styles.iconContainer}
|
||
onPress={handleCompleteTask}
|
||
>
|
||
<Image
|
||
source={require('@/assets/images/task/icon-complete-gradient.png')}
|
||
style={styles.taskIcon}
|
||
resizeMode="contain"
|
||
/>
|
||
</TouchableOpacity>
|
||
|
||
{/* 跳过任务图标 - 仅对进行中的任务显示 */}
|
||
{task.status === 'pending' && (
|
||
<TouchableOpacity
|
||
style={styles.skipIconContainer}
|
||
onPress={handleSkipTask}
|
||
>
|
||
<Image
|
||
source={require('@/assets/images/task/icon-skip.png')}
|
||
style={styles.taskIcon}
|
||
resizeMode="contain"
|
||
/>
|
||
</TouchableOpacity>
|
||
)}
|
||
</View>
|
||
);
|
||
};
|
||
|
||
return (
|
||
<TouchableOpacity
|
||
style={[styles.container, { backgroundColor: colorTokens.background }]}
|
||
onPress={handleTaskPress}
|
||
activeOpacity={0.7}
|
||
>
|
||
{/* 头部区域 */}
|
||
<View style={styles.header}>
|
||
<View style={styles.titleSection}>
|
||
<Text style={[styles.title, { color: colorTokens.text }]} numberOfLines={2}>
|
||
{task.title}
|
||
</Text>
|
||
{renderActionIcons()}
|
||
</View>
|
||
</View>
|
||
|
||
{/* 状态和优先级标签 */}
|
||
<View style={styles.tagsContainer}>
|
||
<View style={styles.statusTag}>
|
||
<MaterialIcons name="schedule" size={12} color="#6B7280" />
|
||
<Text style={styles.statusTagText}>{getStatusText(task.status)}</Text>
|
||
</View>
|
||
</View>
|
||
|
||
{/* 进度条 */}
|
||
<View style={styles.progressBar}>
|
||
<Animated.View
|
||
style={[
|
||
styles.progressFill,
|
||
{
|
||
width: progressAnimation.interpolate({
|
||
inputRange: [0, 100],
|
||
outputRange: ['0%', '100%'],
|
||
}),
|
||
backgroundColor: task.progressPercentage > 0 ? colorTokens.primary : '#E5E7EB',
|
||
},
|
||
]}
|
||
/>
|
||
{task.progressPercentage > 0 && task.progressPercentage < 100 && (
|
||
<View style={styles.progressGlow} />
|
||
)}
|
||
{/* 进度百分比文本 */}
|
||
<View style={styles.progressTextContainer}>
|
||
<Text style={styles.progressText}>{task.currentCount}/{task.targetCount}</Text>
|
||
</View>
|
||
</View>
|
||
</TouchableOpacity>
|
||
);
|
||
};
|
||
|
||
const styles = StyleSheet.create({
|
||
container: {
|
||
padding: 16,
|
||
borderRadius: 12,
|
||
marginBottom: 12,
|
||
shadowColor: '#000',
|
||
shadowOffset: { width: 0, height: 2 },
|
||
shadowOpacity: 0.1,
|
||
shadowRadius: 4,
|
||
elevation: 3,
|
||
},
|
||
header: {
|
||
marginBottom: 12,
|
||
},
|
||
titleSection: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
gap: 12,
|
||
},
|
||
title: {
|
||
fontSize: 16,
|
||
fontWeight: '600',
|
||
lineHeight: 22,
|
||
flex: 1,
|
||
},
|
||
actionIconsContainer: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
gap: 8,
|
||
flexShrink: 0,
|
||
},
|
||
iconContainer: {
|
||
width: 32,
|
||
height: 32,
|
||
borderRadius: 16,
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
backgroundColor: '#F3F4F6',
|
||
},
|
||
skipIconContainer: {
|
||
width: 32,
|
||
height: 32,
|
||
borderRadius: 16,
|
||
backgroundColor: '#F3F4F6',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
},
|
||
taskIcon: {
|
||
width: 20,
|
||
height: 20,
|
||
},
|
||
tagsContainer: {
|
||
flexDirection: 'row',
|
||
gap: 8,
|
||
marginBottom: 12,
|
||
},
|
||
statusTag: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
gap: 4,
|
||
paddingHorizontal: 8,
|
||
paddingVertical: 4,
|
||
borderRadius: 12,
|
||
backgroundColor: '#F3F4F6',
|
||
},
|
||
statusTagText: {
|
||
fontSize: 12,
|
||
fontWeight: '500',
|
||
color: '#374151',
|
||
},
|
||
priorityTag: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
gap: 4,
|
||
paddingHorizontal: 8,
|
||
paddingVertical: 4,
|
||
borderRadius: 12,
|
||
},
|
||
priorityTagText: {
|
||
fontSize: 12,
|
||
fontWeight: '500',
|
||
color: '#FFFFFF',
|
||
},
|
||
progressBar: {
|
||
height: 6,
|
||
backgroundColor: '#F3F4F6',
|
||
borderRadius: 3,
|
||
marginBottom: 16,
|
||
overflow: 'visible',
|
||
shadowColor: '#000',
|
||
shadowOffset: { width: 0, height: 1 },
|
||
shadowOpacity: 0.1,
|
||
shadowRadius: 2,
|
||
elevation: 2,
|
||
position: 'relative',
|
||
},
|
||
progressFill: {
|
||
height: '100%',
|
||
borderRadius: 3,
|
||
shadowColor: '#000',
|
||
shadowOffset: { width: 0, height: 1 },
|
||
shadowOpacity: 0.2,
|
||
shadowRadius: 2,
|
||
elevation: 3,
|
||
},
|
||
progressGlow: {
|
||
position: 'absolute',
|
||
right: 0,
|
||
top: 0,
|
||
width: 8,
|
||
height: '100%',
|
||
backgroundColor: 'rgba(255, 255, 255, 0.6)',
|
||
borderRadius: 3,
|
||
},
|
||
progressTextContainer: {
|
||
position: 'absolute',
|
||
right: 0,
|
||
top: -6,
|
||
backgroundColor: '#FFFFFF',
|
||
paddingHorizontal: 6,
|
||
paddingVertical: 2,
|
||
borderRadius: 8,
|
||
shadowColor: '#000',
|
||
shadowOffset: { width: 0, height: 1 },
|
||
shadowOpacity: 0.1,
|
||
shadowRadius: 2,
|
||
elevation: 2,
|
||
borderWidth: 1,
|
||
borderColor: '#E5E7EB',
|
||
zIndex: 1,
|
||
},
|
||
progressText: {
|
||
fontSize: 10,
|
||
fontWeight: '600',
|
||
color: '#374151',
|
||
},
|
||
footer: {
|
||
flexDirection: 'row',
|
||
justifyContent: 'space-between',
|
||
alignItems: 'center',
|
||
},
|
||
teamSection: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
},
|
||
avatars: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
},
|
||
avatar: {
|
||
width: 24,
|
||
height: 24,
|
||
borderRadius: 24,
|
||
marginRight: -8,
|
||
borderWidth: 2,
|
||
borderColor: '#FFFFFF',
|
||
overflow: 'hidden',
|
||
},
|
||
avatarImage: {
|
||
width: '100%',
|
||
height: '100%',
|
||
},
|
||
infoSection: {
|
||
flexDirection: 'row',
|
||
gap: 8,
|
||
},
|
||
infoTag: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
gap: 4,
|
||
paddingHorizontal: 8,
|
||
paddingVertical: 4,
|
||
borderRadius: 12,
|
||
},
|
||
infoTagText: {
|
||
fontSize: 12,
|
||
fontWeight: '500',
|
||
color: '#374151',
|
||
},
|
||
});
|