Files
carry-your-live/carry-your-live/src/components/TaskItem.tsx
T
2026-08-25 16:26:10 +02:00

604 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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',
},
});