custom tags
This commit is contained in:
@@ -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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user