604 lines
19 KiB
TypeScript
604 lines
19 KiB
TypeScript
import React from 'react';
|
||
import { View, Text, StyleSheet, TouchableOpacity, Animated, Platform, Dimensions } from 'react-native';
|
||
import { Swipeable, Gesture, GestureDetector, PanGestureHandler } from 'react-native-gesture-handler';
|
||
import { TaskData, SubtaskData, Priority, Repeat, Reminder } from '@/types';
|
||
import { PRIORITY_COLORS } from '@/constants';
|
||
import { useSettings, mixHex } from '@/theme';
|
||
import type { ThemeColors } from '@/theme';
|
||
import Svg, { Path, Circle } from 'react-native-svg';
|
||
|
||
export type TaskLike = {
|
||
id: string;
|
||
title: string;
|
||
priority: Priority;
|
||
assigneeId: string | null;
|
||
repeat: Repeat;
|
||
allDay: boolean;
|
||
completed: boolean;
|
||
dueDate: number;
|
||
dueTime: string;
|
||
endTime: string;
|
||
reminder: Reminder;
|
||
};
|
||
|
||
interface TaskItemProps {
|
||
task: TaskData | SubtaskData | TaskLike;
|
||
onToggle: () => void | Promise<void>;
|
||
onDelete?: () => void;
|
||
onPress: () => void;
|
||
onLongPress?: () => void;
|
||
onMenuOpen?: () => void;
|
||
selected?: boolean;
|
||
selectionMode?: boolean;
|
||
completedSection?: boolean;
|
||
draggable?: boolean;
|
||
hovered?: boolean;
|
||
onDragStart?: () => void;
|
||
onDragUpdate?: (absoluteY: number) => void;
|
||
onDragEnd?: (absoluteY: number) => void;
|
||
onReorderStart?: () => void;
|
||
onReorderUpdate?: (absoluteY: number) => void;
|
||
onReorderEnd?: (absoluteY: number, translationY: number) => void;
|
||
expanded?: boolean;
|
||
indented?: boolean;
|
||
depth?: number;
|
||
categoryColor?: string;
|
||
categoryColors?: (string | undefined)[];
|
||
}
|
||
|
||
export const TaskItem = React.memo(function TaskItem({ task, onToggle, onDelete, onPress, onLongPress, onMenuOpen, selected, selectionMode, completedSection, draggable, hovered, onDragStart, onDragUpdate, onDragEnd, onReorderStart, onReorderUpdate, onReorderEnd, expanded, indented, depth = 0, categoryColor, categoryColors }: TaskItemProps) {
|
||
const { theme, deleteBySwipe } = useSettings();
|
||
const styles = React.useMemo(() => createStyles(theme), [theme]);
|
||
const [opacityAnim] = React.useState(new Animated.Value(task.completed ? 0.5 : 1));
|
||
const [dragTranslateX] = React.useState(new Animated.Value(0));
|
||
const [dragTranslateY] = React.useState(new Animated.Value(0));
|
||
const [dragging, setDragging] = React.useState(false);
|
||
const [dragStartY, setDragStartY] = React.useState(0);
|
||
const swipeableRef = React.useRef<Swipeable>(null);
|
||
const [rotateAnim] = React.useState(new Animated.Value(0));
|
||
|
||
React.useEffect(() => {
|
||
Animated.timing(rotateAnim, {
|
||
toValue: expanded ? 1 : 0,
|
||
duration: 200,
|
||
useNativeDriver: true,
|
||
}).start();
|
||
}, [expanded, rotateAnim]);
|
||
|
||
const rotate = rotateAnim.interpolate({ inputRange: [0, 1], outputRange: ['0deg', '180deg'] });
|
||
|
||
const reorderGesture = React.useMemo(
|
||
() =>
|
||
Gesture.Pan()
|
||
.activateAfterLongPress(400)
|
||
.minDistance(5)
|
||
.runOnJS(true)
|
||
.onStart((e) => {
|
||
setDragging(true);
|
||
setDragStartY(e.absoluteY);
|
||
onReorderStart?.();
|
||
})
|
||
.onUpdate((e) => {
|
||
dragTranslateX.setValue(e.translationX);
|
||
dragTranslateY.setValue(e.translationY);
|
||
onReorderUpdate?.(e.absoluteY);
|
||
})
|
||
.onEnd((e) => {
|
||
onReorderEnd?.(e.absoluteY, e.translationY);
|
||
})
|
||
.onFinalize(() => {
|
||
setDragging(false);
|
||
dragTranslateX.setValue(0);
|
||
dragTranslateY.setValue(0);
|
||
}),
|
||
[onReorderStart, onReorderUpdate, onReorderEnd, dragTranslateX, dragTranslateY]
|
||
);
|
||
|
||
const dragGesture = React.useMemo(
|
||
() =>
|
||
Gesture.Pan()
|
||
.activateAfterLongPress(400)
|
||
.minDistance(2)
|
||
.runOnJS(true)
|
||
.onStart(() => {
|
||
setDragging(true);
|
||
onDragStart?.();
|
||
})
|
||
.onUpdate((e) => {
|
||
dragTranslateX.setValue(e.translationX);
|
||
dragTranslateY.setValue(e.translationY);
|
||
onDragUpdate?.(e.absoluteY);
|
||
})
|
||
.onEnd((e) => {
|
||
onDragEnd?.(e.absoluteY);
|
||
})
|
||
.onFinalize(() => {
|
||
setDragging(false);
|
||
dragTranslateX.setValue(0);
|
||
dragTranslateY.setValue(0);
|
||
}),
|
||
[onDragStart, onDragUpdate, onDragEnd, dragTranslateX, dragTranslateY]
|
||
);
|
||
|
||
React.useEffect(() => {
|
||
Animated.timing(opacityAnim, {
|
||
toValue: task.completed ? 0.5 : 1,
|
||
duration: 200,
|
||
useNativeDriver: true,
|
||
}).start();
|
||
}, [task.completed, opacityAnim]);
|
||
|
||
const dueInfo = React.useMemo(() => {
|
||
const startOfToday = new Date();
|
||
startOfToday.setHours(0, 0, 0, 0);
|
||
const endOfToday = new Date();
|
||
endOfToday.setHours(23, 59, 59, 999);
|
||
const hasDueDate = task.dueDate > 0;
|
||
const isOverdue = hasDueDate && !task.completed && task.dueDate < startOfToday.getTime();
|
||
const isDueToday = hasDueDate && !task.completed && task.dueDate >= startOfToday.getTime() && task.dueDate <= endOfToday.getTime();
|
||
return { hasDueDate, isOverdue, isDueToday };
|
||
}, [task.dueDate, task.completed]);
|
||
|
||
const { hasDueDate, isOverdue, isDueToday } = dueInfo;
|
||
|
||
const formattedDueDate = React.useMemo(
|
||
() => formatDueDate(task.dueDate, task.dueTime, task.endTime || ''),
|
||
[task.dueDate, task.dueTime, task.endTime]
|
||
);
|
||
|
||
const renderRightActions = (progress: Animated.AnimatedInterpolation<number>) => {
|
||
const translateX = progress.interpolate({ inputRange: [0, 1], outputRange: [80, 0] });
|
||
return (
|
||
<Animated.View style={[styles.swipeAction, styles.swipeDelete, { transform: [{ translateX }] }]}>
|
||
<Svg width={24} height={24} viewBox="0 0 24 24">
|
||
<Path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13" stroke="#FFFFFF" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||
</Svg>
|
||
<Text style={styles.swipeActionText}>Delete</Text>
|
||
</Animated.View>
|
||
);
|
||
};
|
||
|
||
const renderLeftActions = (progress: Animated.AnimatedInterpolation<number>) => {
|
||
const translateX = progress.interpolate({ inputRange: [0, 1], outputRange: [-80, 0] });
|
||
return (
|
||
<Animated.View style={[styles.swipeAction, styles.swipeComplete, { transform: [{ translateX }] }]}>
|
||
<Svg width={24} height={24} viewBox="0 0 24 24">
|
||
<Path d="M5 12l5 5 9-10" stroke="#FFFFFF" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||
</Svg>
|
||
<Text style={styles.swipeActionText}>{task.completed ? 'Undo' : 'Done'}</Text>
|
||
</Animated.View>
|
||
);
|
||
};
|
||
|
||
return (
|
||
<Swipeable
|
||
ref={swipeableRef}
|
||
renderRightActions={renderRightActions}
|
||
renderLeftActions={renderLeftActions}
|
||
onSwipeableRightOpen={() => {
|
||
if (deleteBySwipe) {
|
||
onDelete?.();
|
||
}
|
||
swipeableRef.current?.close();
|
||
}}
|
||
onSwipeableLeftOpen={() => {
|
||
onToggle();
|
||
swipeableRef.current?.close();
|
||
}}
|
||
overshootRight={false}
|
||
overshootLeft={false}
|
||
>
|
||
<GestureDetector gesture={draggable ? (onReorderStart ? reorderGesture : dragGesture) : Gesture.Native()}>
|
||
<Animated.View
|
||
style={[
|
||
styles.container,
|
||
{ backgroundColor: theme.card, borderColor: theme.border },
|
||
(indented || depth > 0) && { marginLeft: depth > 0 ? depth * 12 : 32, marginBottom: 4 },
|
||
task.completed && styles.taskCompleted,
|
||
isOverdue && styles.taskOverdue,
|
||
isDueToday && styles.taskDueToday,
|
||
selectionMode && styles.taskSelected,
|
||
selected && { borderColor: theme.accent, borderWidth: 2 },
|
||
completedSection && { backgroundColor: theme.cardAlt },
|
||
dragging && styles.dragLifted,
|
||
hovered && styles.taskHovered,
|
||
{ transform: [{ translateX: dragTranslateX }, { translateY: dragTranslateY }] },
|
||
]}
|
||
pointerEvents={dragging ? 'none' : 'auto'}
|
||
>
|
||
<TouchableOpacity
|
||
style={styles.contentWrapper}
|
||
onPress={onPress}
|
||
onLongPress={onLongPress}
|
||
delayLongPress={350}
|
||
activeOpacity={0.8}
|
||
accessibilityRole={selectionMode ? 'checkbox' : 'button'}
|
||
accessibilityLabel={selectionMode ? `Select ${task.title}` : task.title}
|
||
accessibilityState={selectionMode ? { checked: selected } : { expanded }}
|
||
accessibilityHint={selectionMode ? undefined : 'Expands the task to show subtasks'}
|
||
>
|
||
<View style={styles.content}>
|
||
<View style={styles.titleRow}>
|
||
<View style={styles.categoryDotSlot}>
|
||
{categoryColor && (
|
||
<View style={[styles.categoryDot, { backgroundColor: categoryColor }]} />
|
||
)}
|
||
</View>
|
||
<TouchableOpacity
|
||
style={styles.checkCircle}
|
||
onPress={onToggle}
|
||
activeOpacity={0.7}
|
||
accessibilityRole="checkbox"
|
||
accessibilityLabel={task.completed ? 'Mark incomplete' : 'Mark complete'}
|
||
accessibilityState={{ checked: task.completed }}
|
||
>
|
||
<Svg width={30} height={30} viewBox="0 0 24 24">
|
||
{task.completed ? (
|
||
<>
|
||
<Circle cx={12} cy={12} r={10} fill={theme.accent} />
|
||
<Path d="M7 12l3 3 7-7" stroke="#FFFFFF" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||
</>
|
||
) : (
|
||
<Circle cx={12} cy={12} r={10} stroke={isOverdue ? theme.danger : theme.textSecondary} strokeWidth={2.5} fill="none" />
|
||
)}
|
||
</Svg>
|
||
</TouchableOpacity>
|
||
<Animated.Text
|
||
style={[
|
||
styles.title,
|
||
{ color: theme.text },
|
||
task.completed && styles.titleCompleted,
|
||
isOverdue && styles.titleOverdue,
|
||
]}
|
||
>
|
||
{task.title}
|
||
</Animated.Text>
|
||
{task.priority !== 'none' && (
|
||
<View
|
||
style={[
|
||
styles.priorityBadge,
|
||
{ backgroundColor: PRIORITY_COLORS[task.priority] },
|
||
]}
|
||
>
|
||
<Text style={styles.priorityText}>
|
||
{task.priority.charAt(0).toUpperCase()}
|
||
</Text>
|
||
</View>
|
||
)}
|
||
{task.assigneeId && (
|
||
<View style={styles.assigneeBadge}>
|
||
<Text style={styles.assigneeText}>
|
||
{task.assigneeId.charAt(0).toUpperCase()}
|
||
</Text>
|
||
</View>
|
||
)}
|
||
{task.repeat && task.repeat !== 'none' && (
|
||
<View style={styles.repeatIcon}>
|
||
<Svg width={14} height={14} viewBox="0 0 24 24">
|
||
<Path
|
||
d="M17 2l4 4-4 4M3 11v-1a4 4 0 0 1 4-4h14M7 22l-4-4 4-4M21 13v1a4 4 0 0 1-4 4H3"
|
||
stroke={theme.textSecondary}
|
||
strokeWidth={2.2}
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
fill="none"
|
||
/>
|
||
</Svg>
|
||
</View>
|
||
)}
|
||
{task.allDay && (
|
||
<View style={styles.allDayBadge}>
|
||
<Svg width={14} height={14} viewBox="0 0 24 24">
|
||
<Path
|
||
d="M12 2v2M12 20v2M2 12h2M20 12h2M5.6 5.6l1.4 1.4M17 17l1.4 1.4M5.6 18.4l1.4-1.4M17 7l1.4-1.4"
|
||
stroke={theme.accent}
|
||
strokeWidth={1.8}
|
||
strokeLinecap="round"
|
||
fill="none"
|
||
/>
|
||
<Circle cx={12} cy={12} r={4} stroke={theme.accent} strokeWidth={1.5} fill="none" />
|
||
</Svg>
|
||
<Text style={styles.allDayText}>All Day</Text>
|
||
</View>
|
||
)}
|
||
<Animated.View style={[styles.chevron, { transform: [{ rotate }] }]}>
|
||
<Svg width={16} height={16} viewBox="0 0 24 24">
|
||
<Path
|
||
d="M6 9l6 6 6-6"
|
||
stroke={theme.textSecondary}
|
||
strokeWidth={2.5}
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
fill="none"
|
||
/>
|
||
</Svg>
|
||
</Animated.View>
|
||
</View>
|
||
{hasDueDate && (
|
||
<View style={styles.dueRow}>
|
||
<Svg width={14} height={14} viewBox="0 0 24 24">
|
||
<Circle cx={12} cy={12} r={10} stroke={theme.textSecondary} strokeWidth={2} fill="none" />
|
||
<Path d="M12 6v6l4 2" stroke={theme.textSecondary} strokeWidth={2} strokeLinecap="round" />
|
||
</Svg>
|
||
<Animated.Text
|
||
style={[
|
||
styles.dueText,
|
||
{ color: theme.textFaint },
|
||
isOverdue && styles.dueTextOverdue,
|
||
isDueToday && styles.dueTextDueToday,
|
||
]}
|
||
>
|
||
{formattedDueDate}
|
||
</Animated.Text>
|
||
{task.reminder && task.reminder !== 'none' && (
|
||
<View style={styles.reminderIcon}>
|
||
<Svg width={13} height={13} viewBox="0 0 24 24">
|
||
<Path
|
||
d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9M10.3 21a1.94 1.94 0 0 0 3.4 0"
|
||
stroke={theme.textSecondary}
|
||
strokeWidth={1.8}
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
fill="none"
|
||
/>
|
||
</Svg>
|
||
</View>
|
||
)}
|
||
</View>
|
||
)}
|
||
</View>
|
||
</TouchableOpacity>
|
||
|
||
<TouchableOpacity
|
||
style={styles.menuButton}
|
||
onPress={onMenuOpen}
|
||
activeOpacity={0.7}
|
||
accessibilityRole="button"
|
||
accessibilityLabel={`Edit ${task.title}`}
|
||
accessibilityHint="Opens the task editor"
|
||
hitSlop={8}
|
||
>
|
||
<Svg width={24} height={24} viewBox="0 0 24 24">
|
||
<Circle cx={12} cy={12} r={1.5} fill={theme.textMuted} />
|
||
<Circle cx={12} cy={6} r={1.5} fill={theme.textMuted} />
|
||
<Circle cx={12} cy={18} r={1.5} fill={theme.textMuted} />
|
||
</Svg>
|
||
</TouchableOpacity>
|
||
</Animated.View>
|
||
</GestureDetector>
|
||
</Swipeable>
|
||
);
|
||
});
|
||
|
||
function formatDueDate(dueDate: number, dueTime: string, endTime?: string): string {
|
||
const date = new Date(dueDate);
|
||
const today = new Date();
|
||
today.setHours(0, 0, 0, 0);
|
||
const tomorrow = new Date(today);
|
||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||
|
||
let dateStr = '';
|
||
if (date.getTime() === today.getTime()) {
|
||
dateStr = 'Today';
|
||
} else if (date.getTime() === tomorrow.getTime()) {
|
||
dateStr = 'Tomorrow';
|
||
} else {
|
||
dateStr = date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||
}
|
||
|
||
if (dueTime && endTime) {
|
||
return `${dateStr} · ${formatTime12(dueTime)} – ${formatTime12(endTime)}`;
|
||
}
|
||
return dueTime ? `${dateStr} at ${formatTime12(dueTime)}` : dateStr;
|
||
}
|
||
|
||
function formatTime12(time: string): string {
|
||
const [hours, minutes] = time.split(':').map(Number);
|
||
if (Number.isNaN(hours) || Number.isNaN(minutes)) return time;
|
||
const period = hours >= 12 ? 'PM' : 'AM';
|
||
const h = hours % 12 === 0 ? 12 : hours % 12;
|
||
return `${h}:${String(minutes).padStart(2, '0')} ${period}`;
|
||
}
|
||
|
||
const createStyles = (theme: ThemeColors) => StyleSheet.create({
|
||
container: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
paddingHorizontal: 12,
|
||
paddingVertical: 14,
|
||
borderRadius: 16,
|
||
borderWidth: 1,
|
||
marginVertical: 4,
|
||
shadowColor: '#000',
|
||
shadowOffset: { width: 0, height: 1 },
|
||
shadowOpacity: 0.05,
|
||
shadowRadius: 6,
|
||
elevation: 1,
|
||
},
|
||
taskCompleted: {
|
||
opacity: 0.5,
|
||
},
|
||
indented: {
|
||
marginLeft: 32,
|
||
marginBottom: 4,
|
||
},
|
||
taskOverdue: {
|
||
borderColor: theme.dangerBorder,
|
||
},
|
||
taskDueToday: {
|
||
borderColor: mixHex(theme.info, theme.background, 0.55),
|
||
},
|
||
taskSelected: {
|
||
shadowColor: '#000',
|
||
shadowOpacity: 0.1,
|
||
shadowRadius: 6,
|
||
elevation: 2,
|
||
},
|
||
taskHovered: {
|
||
borderColor: mixHex(theme.info, theme.background, 0.55),
|
||
backgroundColor: mixHex(theme.info, theme.background, 0.85),
|
||
},
|
||
dragLifted: {
|
||
zIndex: 100,
|
||
elevation: 12,
|
||
shadowColor: '#000',
|
||
shadowOpacity: 0.25,
|
||
shadowRadius: 12,
|
||
shadowOffset: { width: 0, height: 8 },
|
||
},
|
||
contentWrapper: {
|
||
flex: 1,
|
||
},
|
||
content: {
|
||
gap: 12,
|
||
},
|
||
titleRow: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
justifyContent: 'space-between',
|
||
},
|
||
dragHandle: {
|
||
width: 28,
|
||
height: 28,
|
||
borderRadius: 14,
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
marginRight: 8,
|
||
},
|
||
categoryDotSlot: {
|
||
width: 28,
|
||
height: 28,
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
marginRight: 8,
|
||
},
|
||
categoryDot: {
|
||
width: 12,
|
||
height: 12,
|
||
borderRadius: 6,
|
||
},
|
||
title: {
|
||
fontSize: 17,
|
||
fontWeight: '500',
|
||
flex: 1,
|
||
marginRight: 16,
|
||
},
|
||
titleCompleted: {
|
||
textDecorationLine: 'line-through',
|
||
color: theme.textMuted,
|
||
},
|
||
repeatIcon: {
|
||
marginLeft: 8,
|
||
width: 14,
|
||
height: 14,
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
},
|
||
chevron: {
|
||
marginLeft: 8,
|
||
width: 16,
|
||
height: 16,
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
},
|
||
reminderIcon: {
|
||
marginLeft: 6,
|
||
width: 13,
|
||
height: 13,
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
},
|
||
titleOverdue: {
|
||
color: theme.danger,
|
||
},
|
||
priorityBadge: {
|
||
paddingHorizontal: 6,
|
||
paddingVertical: 2,
|
||
borderRadius: 8,
|
||
minWidth: 20,
|
||
alignItems: 'center',
|
||
},
|
||
priorityText: {
|
||
fontSize: 9,
|
||
fontWeight: '700',
|
||
color: '#FFFFFF',
|
||
},
|
||
assigneeBadge: {
|
||
width: 20,
|
||
height: 20,
|
||
borderRadius: 10,
|
||
backgroundColor: theme.dangerSoft,
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
},
|
||
assigneeText: {
|
||
fontSize: 9,
|
||
fontWeight: '700',
|
||
color: theme.danger,
|
||
},
|
||
dueRow: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
gap: 8,
|
||
},
|
||
dueText: {
|
||
fontSize: 13,
|
||
},
|
||
dueTextOverdue: {
|
||
color: theme.danger,
|
||
fontWeight: '600',
|
||
},
|
||
dueTextDueToday: {
|
||
color: theme.info,
|
||
fontWeight: '600',
|
||
},
|
||
menuButton: {
|
||
padding: 4,
|
||
marginLeft: 8,
|
||
},
|
||
allDayBadge: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
gap: 4,
|
||
marginLeft: 8,
|
||
paddingHorizontal: 6,
|
||
paddingVertical: 2,
|
||
borderRadius: 8,
|
||
backgroundColor: mixHex(theme.info, theme.background, 0.85),
|
||
},
|
||
allDayText: {
|
||
fontSize: 11,
|
||
fontWeight: '600',
|
||
color: theme.info,
|
||
},
|
||
checkCircle: {
|
||
width: 30,
|
||
height: 30,
|
||
borderRadius: 15,
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
marginRight: 12,
|
||
},
|
||
swipeAction: {
|
||
width: 80,
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
gap: 4,
|
||
},
|
||
swipeDelete: {
|
||
backgroundColor: theme.dangerStrong,
|
||
borderTopRightRadius: 16,
|
||
borderBottomRightRadius: 16,
|
||
},
|
||
swipeComplete: {
|
||
backgroundColor: theme.successStrong,
|
||
borderTopLeftRadius: 16,
|
||
borderBottomLeftRadius: 16,
|
||
},
|
||
swipeActionText: {
|
||
color: '#FFFFFF',
|
||
fontSize: 12,
|
||
fontWeight: '600',
|
||
},
|
||
});
|