custom tags

This commit is contained in:
2026-08-25 16:26:10 +02:00
parent 21a2cffcc0
commit 259d21c968
19 changed files with 4349 additions and 194 deletions
+138 -116
View File
@@ -8,17 +8,21 @@ import { useSubtasks } from '@/hooks/useSubtasks';
import { useTaskModals } from '@/hooks/useTaskModals';
import { useSettings } from '@/theme';
import { useCategories } from '@/hooks/useDatabase';
import { confirmAndToggleTaskComplete } from '@/utils/taskActions';
import { useICalImport } from '@/hooks/useICalImport';
import { confirmAndToggleTaskComplete, toggleSubtaskComplete } from '@/utils/taskActions';
import { QuickAddBar } from '@/components/QuickAddBar';
import { OptionPickerModal } from '@/components/OptionPickerModal';
import { TaskItem } from '@/components/TaskItem';
import { SubtaskItem } from '@/components/SubtaskItem';
import Task from '@/models/Task';
import { format, addMonths, addDays, startOfMonth, isSameDay, isSameMonth, isToday } from 'date-fns';
import Svg, { Path, Circle } from 'react-native-svg';
import { desaturate } from '@/theme';
import Svg, { Path } from 'react-native-svg';
import type { ThemeColors } from '@/theme';
import type { TaskData, SubtaskData } from '@/types';
const WIDTH = Dimensions.get('window').width;
const GERMAN_WEEKDAYS = ['mo', 'di', 'mi', 'do', 'fr', 'sa', 'so'];
const PRIORITY_RANK: Record<string, number> = { none: 0, low: 1, medium: 2, high: 3, critical: 4 };
const MONTH_NAMES = [
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December',
@@ -26,9 +30,16 @@ const MONTH_NAMES = [
export default function CalendarScreen() {
const router = useRouter();
const { theme } = useSettings();
const { theme, sortBy } = useSettings();
const styles = React.useMemo(() => createStyles(theme), [theme]);
const categories = useCategories();
const categoryColors = useMemo(() => {
const map = new Map<string, string>();
for (const c of categories) {
map.set(c.id, c.color);
}
return map;
}, [categories]);
const { modals, openTaskEdit } = useTaskModals();
const [visibleMonth, setVisibleMonth] = useState(() => new Date());
@@ -55,6 +66,25 @@ export default function CalendarScreen() {
const monthTasks = useTasksInMonth(visibleMonth);
const refreshMonthTasks = monthTasks.refresh;
const sortedDayTasks = useMemo(() => {
const sorted = [...selectedDayTasks];
switch (sortBy) {
case 'date':
sorted.sort((a, b) => (a.dueDate || Number.MAX_SAFE_INTEGER) - (b.dueDate || Number.MAX_SAFE_INTEGER));
break;
case 'priority':
sorted.sort((a, b) => PRIORITY_RANK[b.priority] - PRIORITY_RANK[a.priority]);
break;
case 'alpha':
sorted.sort((a, b) => a.title.localeCompare(b.title));
break;
case 'created':
sorted.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
break;
}
return sorted;
}, [selectedDayTasks, sortBy]);
useFocusEffect(
useCallback(() => {
refreshDayTasks();
@@ -165,6 +195,27 @@ export default function CalendarScreen() {
});
}, [refreshDayTasks, refreshMonthTasks, refreshSubtasks]);
const [expandedTasks, setExpandedTasks] = useState<Set<string>>(new Set());
const toggleExpand = useCallback((taskId: string) => {
setExpandedTasks((prev) => {
const next = new Set(prev);
if (next.has(taskId)) {
next.delete(taskId);
} else {
next.add(taskId);
}
return next;
});
}, []);
const handleSubtaskToggle = useCallback(async (subtaskId: string) => {
await toggleSubtaskComplete(subtaskId);
refreshDayTasks();
refreshMonthTasks();
refreshSubtasks();
}, [refreshDayTasks, refreshMonthTasks, refreshSubtasks]);
return (
<SafeAreaView style={[styles.container, { backgroundColor: theme.background }]}>
<Header title="Calendar" showLogo={false} />
@@ -244,78 +295,41 @@ export default function CalendarScreen() {
<View style={styles.panelHeader}>
<Text style={[styles.panelDate, { color: theme.text }]}>{format(selectedDate, 'EEEE, MMMM d')}</Text>
{selectedDayTasks.length > 0 && (
{sortedDayTasks.length > 0 && (
<Text style={[styles.panelCount, { color: theme.textFaint }]}>
{selectedDayTasks.length} event{selectedDayTasks.length === 1 ? '' : 's'}
{sortedDayTasks.length} event{sortedDayTasks.length === 1 ? '' : 's'}
</Text>
)}
</View>
{selectedDayTasks.length === 0 ? (
{sortedDayTasks.length === 0 ? (
<View style={styles.emptyState}>
<Text style={[styles.emptyText, { color: theme.textSecondary }]}>No events on this day</Text>
<Text style={[styles.emptySubtext, { color: theme.textMuted }]}>Tap a date or use the bar below</Text>
</View>
) : (
selectedDayTasks.map((task) => (
<View key={task.id} style={[styles.eventCard, { backgroundColor: theme.card, borderColor: theme.border }]}>
<View style={styles.eventRow}>
<TouchableOpacity
style={styles.checkCircle}
onPress={() => handleToggleComplete(task.id)}
activeOpacity={0.7}
>
<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={theme.textSecondary} strokeWidth={2.5} fill="none" />
)}
</Svg>
</TouchableOpacity>
<TouchableOpacity
style={styles.eventTitleTouch}
onPress={() => router.push({ pathname: '/task-detail', params: { id: task.id } })}
activeOpacity={0.7}
>
<Text
style={[styles.eventTitle, { color: theme.text }, task.completed && styles.eventCompleted]}
numberOfLines={1}
>
{task.title}
</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.menuButton} onPress={() => openTaskEdit(task.id)} activeOpacity={0.7} accessibilityRole="button" accessibilityLabel={`Edit ${task.title}`}>
<Svg width={20} height={20} viewBox="0 0 24 24">
<Path d="M9 6l6 6-6 6" stroke={theme.textSecondary} strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
</Svg>
</TouchableOpacity>
sortedDayTasks.map((task) => {
const isExpanded = expandedTasks.has(task.id);
const itemSubtasks = subtasksByTask.get(task.id) ?? [];
return (
<View key={task.id} style={styles.eventCard}>
<CalendarTaskRow
task={task}
expanded={isExpanded}
subtasks={itemSubtasks}
categoryColor={categoryColors.get(task.categoryId)}
onToggle={() => handleToggleComplete(task.id)}
onPress={(id) =>
expandedTasks.has(id)
? router.push({ pathname: '/task-detail', params: { id } })
: toggleExpand(id)
}
onMenuOpen={(id) => openTaskEdit(id)}
onSubtaskToggle={handleSubtaskToggle}
/>
</View>
{(subtasksByTask.get(task.id) ?? []).length > 0 && (
<View style={styles.bullets}>
{(subtasksByTask.get(task.id) ?? []).map((sub) => (
<View key={sub.id} style={styles.bulletRow}>
<Svg width={5} height={5} viewBox="0 0 6 6" style={styles.bulletDot as any}>
<Circle cx={3} cy={3} r={3} fill={theme.textMuted} />
</Svg>
<Text
style={[styles.bulletText, { color: theme.textFaint }, sub.completed && styles.bulletCompleted]}
numberOfLines={1}
>
{sub.title}
</Text>
</View>
))}
</View>
)}
</View>
))
);
})
)}
</ScrollView>
@@ -351,6 +365,60 @@ function byDayOf(byDay: Record<number, Task[]>, day: Date): Task[] {
return byDay[day.getDate()] ?? [];
}
interface CalendarTaskRowProps {
task: Task;
expanded: boolean;
subtasks: SubtaskData[];
categoryColor?: string;
onToggle: (taskId: string) => void | Promise<void>;
onPress: (taskId: string) => void;
onMenuOpen: (taskId: string) => void;
onSubtaskToggle: (subtaskId: string) => void | Promise<void>;
}
const CalendarTaskRow = React.memo(function CalendarTaskRow({
task,
expanded,
subtasks,
categoryColor,
onToggle,
onPress,
onMenuOpen,
onSubtaskToggle,
}: CalendarTaskRowProps) {
const { theme } = useSettings();
const styles = React.useMemo(() => createStyles(theme), [theme]);
const sortedSubtasks = useMemo(
() => subtasks.slice().sort((a, b) => a.order - b.order),
[subtasks]
);
return (
<View>
<TaskItem
task={task as unknown as TaskData}
onToggle={() => onToggle(task.id)}
onPress={() => onPress(task.id)}
onMenuOpen={() => onMenuOpen(task.id)}
expanded={expanded}
categoryColor={categoryColor}
/>
{expanded && sortedSubtasks.length > 0 && (
<View style={styles.subtaskList}>
{sortedSubtasks.map((sub) => (
<SubtaskItem
key={sub.id}
subtask={sub}
depth={1}
categoryColor={categoryColor}
onToggle={() => onSubtaskToggle(sub.id)}
/>
))}
</View>
)}
</View>
);
});
function cellTitle(tasks: Task[]): string | null {
if (tasks.length === 0) return null;
if (tasks.length === 1) return tasks[0].title;
@@ -359,8 +427,7 @@ function cellTitle(tasks: Task[]): string | null {
function cellColor(tasks: Task[], categories: { id: string; color: string }[], theme: ThemeColors): string {
if (tasks.length === 0) return theme.textMuted;
const cat = categories.find((c) => c.id === tasks[0].categoryId);
return cat ? desaturate(cat.color, 0.3) : theme.textMuted;
return theme.accent;
}
interface DayCellProps {
@@ -545,55 +612,10 @@ const createStyles = (theme: ThemeColors) => StyleSheet.create({
eventCard: {
marginHorizontal: 16,
marginTop: 6,
borderRadius: 16,
borderWidth: 1,
paddingHorizontal: 14,
paddingVertical: 12,
},
eventRow: {
flexDirection: 'row',
alignItems: 'center',
},
checkCircle: {
width: 30,
marginRight: 12,
},
eventTitleTouch: {
flex: 1,
},
eventTitle: {
fontSize: 15,
fontWeight: '500',
},
eventCompleted: {
textDecorationLine: 'line-through',
color: theme.textMuted,
},
menuButton: {
padding: 4,
marginLeft: 6,
},
bullets: {
marginTop: 8,
paddingTop: 8,
borderTopWidth: 1,
borderTopColor: 'rgba(255,255,255,0.06)',
gap: 6,
},
bulletRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
bulletDot: {
marginLeft: 8,
},
bulletText: {
flex: 1,
fontSize: 13,
},
bulletCompleted: {
textDecorationLine: 'line-through',
color: theme.textMuted,
subtaskList: {
paddingLeft: 8,
paddingRight: 4,
paddingTop: 4,
},
});