290 lines
11 KiB
TypeScript
290 lines
11 KiB
TypeScript
import React, { useCallback, useMemo } from 'react';
|
||
import { View, Text, StyleSheet, SafeAreaView, ScrollView, TouchableOpacity, KeyboardAvoidingView, BackHandler } from 'react-native';
|
||
import { useRouter, useLocalSearchParams, useFocusEffect } from 'expo-router';
|
||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||
import { Header } from '@/components/Header';
|
||
import { useTasksByDate } from '@/hooks/useTasks';
|
||
import { useSubtasks } from '@/hooks/useSubtasks';
|
||
import { useTaskModals } from '@/hooks/useTaskModals';
|
||
import { useSettings } from '@/theme';
|
||
import { useCategories } from '@/hooks/useDatabase';
|
||
import { confirmAndToggleTaskComplete, toggleSubtaskComplete } from '@/utils/taskActions';
|
||
import { QuickAddBar } from '@/components/QuickAddBar';
|
||
import { SubtaskData } from '@/types';
|
||
import { format, startOfDay } from 'date-fns';
|
||
import Svg, { Path, Circle } from 'react-native-svg';
|
||
import { desaturate, type ThemeColors } from '@/theme';
|
||
|
||
function isDayMatch(timestamp: number, day: Date): boolean {
|
||
const d = new Date(timestamp);
|
||
return d.getFullYear() === day.getFullYear() && d.getMonth() === day.getMonth() && d.getDate() === day.getDate();
|
||
}
|
||
|
||
export default function DayViewScreen() {
|
||
const router = useRouter();
|
||
const { theme } = useSettings();
|
||
const insets = useSafeAreaInsets();
|
||
const styles = useMemo(() => createStyles(theme), [theme]);
|
||
const categories = useCategories();
|
||
const { modals, openTaskEdit } = useTaskModals();
|
||
const { date: dateParam } = useLocalSearchParams<{ date?: string }>();
|
||
|
||
const day = useMemo(() => {
|
||
const parsed = dateParam ? new Date(dateParam) : new Date();
|
||
return Number.isNaN(parsed.getTime()) ? new Date() : parsed;
|
||
}, [dateParam]);
|
||
const dayStart = useMemo(() => startOfDay(day), [day]);
|
||
|
||
const { map: subtasksByTask, refresh: refreshSubtasks } = useSubtasks();
|
||
const { tasks: dayTasks, loading, refresh } = useTasksByDate(day);
|
||
|
||
useFocusEffect(
|
||
useCallback(() => {
|
||
const sub = BackHandler.addEventListener('hardwareBackPress', () => false);
|
||
refreshSubtasks();
|
||
refresh();
|
||
return () => sub.remove();
|
||
}, [refreshSubtasks, refresh])
|
||
);
|
||
|
||
const subtasksOnDay = useMemo(() => {
|
||
const map = new Map<string, SubtaskData[]>();
|
||
for (const [taskId, roots] of subtasksByTask) {
|
||
const due = roots.filter((s) => s.dueDate && isDayMatch(s.dueDate, day));
|
||
if (due.length > 0) map.set(taskId, due);
|
||
}
|
||
return map;
|
||
}, [subtasksByTask, day]);
|
||
|
||
const handleToggleComplete = useCallback(async (taskId: string) => {
|
||
await confirmAndToggleTaskComplete(taskId, () => {
|
||
refresh();
|
||
refreshSubtasks();
|
||
});
|
||
}, [refresh, refreshSubtasks]);
|
||
|
||
const handleToggleSubtask = useCallback(async (subtaskId: string) => {
|
||
await toggleSubtaskComplete(subtaskId);
|
||
refreshSubtasks();
|
||
}, [refreshSubtasks]);
|
||
|
||
return (
|
||
<SafeAreaView style={[styles.container, { backgroundColor: theme.background, paddingBottom: insets.bottom }]}>
|
||
<Header title={format(day, 'EEEE, MMM d')} showLogo={false} />
|
||
<KeyboardAvoidingView style={styles.kbAvoid} behavior="padding">
|
||
<ScrollView style={styles.scrollBody} contentContainerStyle={styles.scrollContent} showsVerticalScrollIndicator={false}>
|
||
{loading ? (
|
||
<View style={styles.emptyState}>
|
||
<Text style={[styles.emptyText, { color: theme.textSecondary }]}>Loading...</Text>
|
||
</View>
|
||
) : dayTasks.length === 0 && subtasksOnDay.size === 0 ? (
|
||
<View style={styles.emptyState}>
|
||
<Text style={[styles.emptyText, { color: theme.textSecondary }]}>No tasks on this day</Text>
|
||
</View>
|
||
) : (
|
||
<>
|
||
{dayTasks.map((task) => {
|
||
const subs = subtasksOnDay.get(task.id) ?? [];
|
||
const openCount = subs.filter((s) => !s.completed).length;
|
||
const cat = categories.find((c) => c.id === task.categoryId);
|
||
return (
|
||
<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}
|
||
accessibilityRole="checkbox"
|
||
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={theme.textSecondary} strokeWidth={2.5} fill="none" />
|
||
)}
|
||
</Svg>
|
||
</TouchableOpacity>
|
||
|
||
<TouchableOpacity
|
||
style={styles.eventTouch}
|
||
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>
|
||
{openCount > 0 && (
|
||
<Text style={[styles.eventSub, { color: theme.textMuted }]} numberOfLines={1}>
|
||
{openCount} open subtask{openCount === 1 ? '' : 's'}
|
||
</Text>
|
||
)}
|
||
{!task.completed && (
|
||
<View style={styles.metaRow}>
|
||
{cat && (
|
||
<View style={[styles.tagChip, { backgroundColor: desaturate(cat.color, 0.3) }]}>
|
||
<Text style={styles.tagText} numberOfLines={1}>{cat.name}</Text>
|
||
</View>
|
||
)}
|
||
{task.dueTime ? (
|
||
<Text style={[styles.timeText, { color: theme.textSecondary }]}>{task.dueTime}{task.endTime ? `–${task.endTime}` : ''}</Text>
|
||
) : task.dueDate ? (
|
||
<Text style={[styles.timeText, { color: theme.textSecondary }]}>{format(task.dueDate, 'HH:mm')}</Text>
|
||
) : null}
|
||
</View>
|
||
)}
|
||
</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>
|
||
</View>
|
||
</View>
|
||
);
|
||
})}
|
||
{Array.from(subtasksOnDay.entries()).flatMap(([taskId, subs]) => {
|
||
const parent = dayTasks.find((t) => t.id === taskId);
|
||
if (parent) return [];
|
||
return subs.map((sub) => (
|
||
<View key={sub.id} style={[styles.eventCard, { backgroundColor: theme.card, borderColor: theme.border }]}>
|
||
<View style={styles.eventRow}>
|
||
<TouchableOpacity
|
||
style={styles.checkCircle}
|
||
onPress={() => handleToggleSubtask(sub.id)}
|
||
activeOpacity={0.7}
|
||
accessibilityRole="checkbox"
|
||
accessibilityState={{ checked: sub.completed }}
|
||
>
|
||
<Svg width={30} height={30} viewBox="0 0 24 24">
|
||
{sub.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.eventTouch}
|
||
onPress={() => router.push({ pathname: '/subtask-detail', params: { id: sub.id } })}
|
||
activeOpacity={0.7}
|
||
>
|
||
<Text
|
||
style={[styles.eventTitle, { color: theme.text }, sub.completed && styles.eventCompleted]}
|
||
numberOfLines={1}
|
||
>
|
||
{sub.title}
|
||
</Text>
|
||
{sub.dueTime ? (
|
||
<Text style={[styles.timeText, { color: theme.textSecondary }]}>{sub.dueTime}{sub.endTime ? `–${sub.endTime}` : ''}</Text>
|
||
) : null}
|
||
</TouchableOpacity>
|
||
</View>
|
||
</View>
|
||
));
|
||
})}
|
||
</>
|
||
)}
|
||
</ScrollView>
|
||
<QuickAddBar dueDate={dayStart.getTime()} placeholder={`Add event for ${format(day, 'MMM d')}`} />
|
||
</KeyboardAvoidingView>
|
||
{modals(() => {})}
|
||
</SafeAreaView>
|
||
);
|
||
}
|
||
|
||
const createStyles = (theme: ThemeColors) => StyleSheet.create({
|
||
container: {
|
||
flex: 1,
|
||
},
|
||
kbAvoid: {
|
||
flex: 1,
|
||
},
|
||
scrollBody: {
|
||
flex: 1,
|
||
},
|
||
scrollContent: {
|
||
paddingTop: 12,
|
||
paddingBottom: 16,
|
||
},
|
||
emptyState: {
|
||
alignItems: 'center',
|
||
paddingVertical: 64,
|
||
},
|
||
emptyText: {
|
||
fontSize: 15,
|
||
fontWeight: '600',
|
||
},
|
||
eventCard: {
|
||
marginHorizontal: 16,
|
||
marginBottom: 8,
|
||
borderRadius: 16,
|
||
borderWidth: 1,
|
||
paddingHorizontal: 14,
|
||
paddingVertical: 12,
|
||
},
|
||
eventRow: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
},
|
||
checkCircle: {
|
||
width: 30,
|
||
marginRight: 12,
|
||
},
|
||
eventTouch: {
|
||
flex: 1,
|
||
},
|
||
eventTitle: {
|
||
fontSize: 15,
|
||
fontWeight: '500',
|
||
},
|
||
eventCompleted: {
|
||
textDecorationLine: 'line-through',
|
||
color: theme.textMuted,
|
||
},
|
||
eventSub: {
|
||
fontSize: 13,
|
||
marginTop: 2,
|
||
},
|
||
metaRow: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
gap: 8,
|
||
marginTop: 4,
|
||
},
|
||
tagChip: {
|
||
paddingHorizontal: 7,
|
||
paddingVertical: 2,
|
||
borderRadius: 6,
|
||
maxWidth: 140,
|
||
},
|
||
tagText: {
|
||
color: '#FFFFFF',
|
||
fontSize: 10,
|
||
fontWeight: '600',
|
||
},
|
||
timeText: {
|
||
fontSize: 12.5,
|
||
fontWeight: '500',
|
||
},
|
||
menuButton: {
|
||
padding: 4,
|
||
marginLeft: 6,
|
||
},
|
||
}); |