Files
carry-your-live/carry-your-live/app/(tabs)/calendar.tsx
T
2026-08-23 11:36:17 +02:00

599 lines
20 KiB
TypeScript

import React, { useMemo, useRef, useState, useCallback, useEffect } from 'react';
import { View, Text, StyleSheet, SafeAreaView, ScrollView, TouchableOpacity, Animated, Dimensions, KeyboardAvoidingView } from 'react-native';
import { useRouter, useFocusEffect } from 'expo-router';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import { Header } from '@/components/Header';
import { useTasksByDate, useTasksInMonth } from '@/hooks/useTasks';
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 { QuickAddBar } from '@/components/QuickAddBar';
import { OptionPickerModal } from '@/components/OptionPickerModal';
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 type { ThemeColors } from '@/theme';
const WIDTH = Dimensions.get('window').width;
const GERMAN_WEEKDAYS = ['mo', 'di', 'mi', 'do', 'fr', 'sa', 'so'];
const MONTH_NAMES = [
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December',
];
export default function CalendarScreen() {
const router = useRouter();
const { theme } = useSettings();
const styles = React.useMemo(() => createStyles(theme), [theme]);
const categories = useCategories();
const { modals, openTaskEdit } = useTaskModals();
const [visibleMonth, setVisibleMonth] = useState(() => new Date());
const [selectedDate, setSelectedDate] = useState(() => new Date());
const [monthPickerVisible, setMonthPickerVisible] = useState(false);
const [yearPickerVisible, setYearPickerVisible] = useState(false);
const { map: subtasksByTask, refresh: refreshSubtasks } = useSubtasks();
const visibleMonthRef = useRef(visibleMonth);
const selectedDateRef = useRef(selectedDate);
const translateX = useRef<Animated.Value>(new Animated.Value(0));
const animatingRef = useRef(false);
const gridWidthRef = useRef<number>(WIDTH);
useEffect(() => {
visibleMonthRef.current = visibleMonth;
}, [visibleMonth]);
useEffect(() => {
selectedDateRef.current = selectedDate;
}, [selectedDate]);
const { tasks: selectedDayTasks, refresh: refreshDayTasks } = useTasksByDate(selectedDate);
const monthTasks = useTasksInMonth(visibleMonth);
const refreshMonthTasks = monthTasks.refresh;
useFocusEffect(
useCallback(() => {
refreshDayTasks();
refreshMonthTasks();
refreshSubtasks();
}, [refreshDayTasks, refreshMonthTasks, refreshSubtasks])
);
const weeks = useMemo(() => {
const first = startOfMonth(visibleMonth);
const offset = (first.getDay() + 6) % 7; // week starts Monday
const gridStart = addDays(first, -offset);
const cells = Array.from({ length: 42 }, (_, i) => addDays(gridStart, i));
const rows: Date[][] = [];
for (let i = 0; i < 42; i += 7) rows.push(cells.slice(i, i + 7));
return rows;
}, [visibleMonth]);
const transitionTo = useCallback(
(dir: 1 | -1, animate = true) => {
if (animatingRef.current) return;
const next = addMonths(visibleMonthRef.current, dir);
if (!isSameMonth(selectedDateRef.current, next)) {
setSelectedDate(startOfMonth(next));
}
if (!animate) {
setVisibleMonth(next);
translateX.current.setValue(0);
return;
}
animatingRef.current = true;
const w = gridWidthRef.current || WIDTH;
const target = dir === 1 ? -w : w;
Animated.timing(translateX.current, { toValue: target, duration: 220, useNativeDriver: false }).start(() => {
setVisibleMonth(next);
translateX.current.setValue(-target);
Animated.timing(translateX.current, { toValue: 0, duration: 180, useNativeDriver: false }).start(() => {
animatingRef.current = false;
});
});
},
[translateX]
);
const handleDayPress = useCallback(
(day: Date) => {
setSelectedDate(day);
if (!isSameMonth(day, visibleMonthRef.current)) {
transitionTo(day > visibleMonthRef.current ? 1 : -1, true);
} else {
router.push({ pathname: '/day-view', params: { date: day.toISOString() } });
}
},
[router, transitionTo]
);
const pan = useMemo(
() =>
Gesture.Pan()
.activeOffsetX([-16, 16])
.minDistance(6)
.runOnJS(true)
// eslint-disable-next-line react-hooks/refs
.onUpdate((e) => {
if (!animatingRef.current) translateX.current.setValue(e.translationX);
})
// eslint-disable-next-line react-hooks/refs
.onEnd((e) => {
if (animatingRef.current) return;
const w = gridWidthRef.current || WIDTH;
const dx = e.translationX;
if (dx <= -w / 4) {
translateX.current.stopAnimation();
transitionTo(1, false);
} else if (dx >= w / 4) {
translateX.current.stopAnimation();
transitionTo(-1, false);
} else {
Animated.spring(translateX.current, { toValue: 0, useNativeDriver: false }).start();
}
}),
[translateX, transitionTo]
);
const handleSelectMonth = useCallback((value: string | string[]) => {
const monthIndex = parseInt(Array.isArray(value) ? value[0] : value, 10);
setVisibleMonth(new Date(visibleMonthRef.current.getFullYear(), monthIndex, 1));
}, []);
const handleSelectYear = useCallback((value: string | string[]) => {
const year = parseInt(Array.isArray(value) ? value[0] : value, 10);
setVisibleMonth(new Date(year, visibleMonthRef.current.getMonth(), 1));
}, []);
const currentYear = new Date().getFullYear();
const monthOptions = MONTH_NAMES.map((label, i) => ({ value: String(i), label }));
const yearOptions = useMemo(() => {
const options: { value: string; label: string }[] = [];
for (let y = currentYear - 20; y <= currentYear + 10; y++) options.push({ value: String(y), label: String(y) });
return options;
}, [currentYear]);
const handleToggleComplete = useCallback(async (taskId: string) => {
await confirmAndToggleTaskComplete(taskId, () => {
refreshDayTasks();
refreshMonthTasks();
refreshSubtasks();
});
}, [refreshDayTasks, refreshMonthTasks, refreshSubtasks]);
return (
<SafeAreaView style={[styles.container, { backgroundColor: theme.background }]}>
<Header title="Calendar" showLogo={false} />
<KeyboardAvoidingView style={styles.kbAvoid} behavior="padding">
<ScrollView style={styles.scrollBody} showsVerticalScrollIndicator={false} contentContainerStyle={styles.scrollContent}>
<GestureDetector gesture={pan}>
<Animated.View
onLayout={(e) => {
gridWidthRef.current = e.nativeEvent.layout.width;
}}
// eslint-disable-next-line react-hooks/refs
style={[styles.calendarArea, { transform: [{ translateX: translateX.current }] }]}
>
<View style={styles.monthRow}>
<TouchableOpacity
onPress={() => transitionTo(-1)}
style={[styles.monthNav, { borderColor: theme.borderStrong, backgroundColor: theme.card }]}
activeOpacity={0.7}
>
<Svg width={20} height={20} viewBox="0 0 24 24">
<Path d="M15 18l-6-6 6-6" stroke={theme.textSecondary} strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
</Svg>
</TouchableOpacity>
<View style={styles.monthSelectorGroup}>
<TouchableOpacity onPress={() => setMonthPickerVisible(true)} activeOpacity={0.7} style={styles.monthButton}>
<Text style={[styles.monthLabel, { color: theme.text }]}>{format(visibleMonth, 'MMMM')}</Text>
<Svg width={14} height={14} 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>
</TouchableOpacity>
</View>
<TouchableOpacity onPress={() => setYearPickerVisible(true)} activeOpacity={0.7} style={styles.yearButton}>
<Text style={[styles.yearLabel, { color: theme.textMuted }]}>{format(visibleMonth, 'yyyy')}</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={() => transitionTo(1)}
style={[styles.monthNav, { borderColor: theme.borderStrong, backgroundColor: theme.card }]}
activeOpacity={0.7}
>
<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 style={styles.weekdayRow}>
{GERMAN_WEEKDAYS.map((day, i) => (
<Text key={day + i} style={[styles.weekday, { color: theme.textMuted }]}>
{day}
</Text>
))}
</View>
{weeks.map((week, wi) => (
<View key={wi} style={styles.weekRow}>
{week.map((day) => (
<DayCell
key={day.toISOString()}
day={day}
label={cellTitle(byDayOf(monthTasks.byDay, day))}
dotColor={cellColor(byDayOf(monthTasks.byDay, day), categories, theme)}
selected={isSameDay(day, selectedDate)}
today={isToday(day)}
inMonth={isSameMonth(day, visibleMonth)}
theme={theme}
onPress={handleDayPress}
/>
))}
</View>
))}
</Animated.View>
</GestureDetector>
<View style={styles.panelHeader}>
<Text style={[styles.panelDate, { color: theme.text }]}>{format(selectedDate, 'EEEE, MMMM d')}</Text>
{selectedDayTasks.length > 0 && (
<Text style={[styles.panelCount, { color: theme.textFaint }]}>
{selectedDayTasks.length} event{selectedDayTasks.length === 1 ? '' : 's'}
</Text>
)}
</View>
{selectedDayTasks.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>
</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>
<QuickAddBar
dueDate={selectedDate.getTime()}
placeholder={`Add event for ${format(selectedDate, 'MMM d')}`}
/>
</KeyboardAvoidingView>
{modals(() => {})}
<OptionPickerModal
visible={monthPickerVisible}
title="Select Month"
options={monthOptions}
selectedValue={String(visibleMonth.getMonth())}
onSelect={handleSelectMonth}
onClose={() => setMonthPickerVisible(false)}
/>
<OptionPickerModal
visible={yearPickerVisible}
title="Select Year"
options={yearOptions}
selectedValue={String(visibleMonth.getFullYear())}
onSelect={handleSelectYear}
onClose={() => setYearPickerVisible(false)}
/>
</SafeAreaView>
);
}
function byDayOf(byDay: Record<number, Task[]>, day: Date): Task[] {
return byDay[day.getDate()] ?? [];
}
function cellTitle(tasks: Task[]): string | null {
if (tasks.length === 0) return null;
if (tasks.length === 1) return tasks[0].title;
return `${tasks[0].title} +${tasks.length - 1}`;
}
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;
}
interface DayCellProps {
day: Date;
label: string | null;
dotColor: string;
selected: boolean;
today: boolean;
inMonth: boolean;
theme: ThemeColors;
onPress: (day: Date) => void;
}
const DayCell = React.memo(function DayCell({ day, label, dotColor, selected, today, inMonth, theme, onPress }: DayCellProps) {
const styles = React.useMemo(() => createStyles(theme), [theme]);
return (
<TouchableOpacity
style={[styles.dayCell, selected && { backgroundColor: theme.accentSoft, borderColor: theme.accentBorder }]}
onPress={() => onPress(day)}
activeOpacity={0.7}
>
<Text
style={[
styles.dayNumber,
{ color: theme.text },
!inMonth && { color: theme.textMuted },
today && !selected && { color: theme.accent },
selected && styles.dayNumberSelected,
]}
>
{format(day, 'd')}
</Text>
{label ? (
<View style={[styles.chip, { backgroundColor: selected ? 'rgba(0,0,0,0.22)' : dotColor }]}>
<Text style={styles.chipText} numberOfLines={1}>
{label}
</Text>
</View>
) : !inMonth ? (
<View style={[styles.chipPlaceholder, { backgroundColor: theme.borderStrong }]} />
) : null}
</TouchableOpacity>
);
});
const createStyles = (theme: ThemeColors) => StyleSheet.create({
container: {
flex: 1,
},
scrollContent: {
paddingBottom: 16,
flexGrow: 1,
},
scrollBody: {
flex: 1,
},
kbAvoid: {
flex: 1,
},
calendarArea: {
paddingHorizontal: 12,
paddingTop: 6,
paddingBottom: 4,
},
monthRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 4,
paddingBottom: 14,
},
monthNav: {
width: 36,
height: 36,
borderRadius: 12,
borderWidth: 1,
alignItems: 'center',
justifyContent: 'center',
},
monthSelectorGroup: {
alignItems: 'flex-start',
flex: 1,
},
monthButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 6,
paddingHorizontal: 10,
},
monthLabel: {
fontSize: 22,
fontWeight: '700',
},
yearButton: {
marginTop: -2,
paddingHorizontal: 6,
},
yearLabel: {
fontSize: 17,
fontWeight: '700',
},
weekdayRow: {
flexDirection: 'row',
marginBottom: 4,
paddingHorizontal: 2,
},
weekday: {
flex: 1,
textAlign: 'center',
fontSize: 11,
fontWeight: '600',
textTransform: 'uppercase',
},
weekRow: {
flexDirection: 'row',
gap: 6,
marginBottom: 6,
},
dayCell: {
flex: 1,
height: 56,
borderRadius: 12,
borderWidth: 1,
borderColor: 'transparent',
paddingVertical: 4,
alignItems: 'center',
},
dayNumber: {
fontSize: 14,
fontWeight: '600',
},
dayNumberSelected: {
color: theme.text,
},
chip: {
marginTop: 3,
paddingHorizontal: 4,
paddingVertical: 2,
borderRadius: 5,
maxWidth: '92%',
},
chipText: {
color: '#FFFFFF',
fontSize: 8,
fontWeight: '600',
},
chipPlaceholder: {
marginTop: 3,
width: 6,
height: 2,
borderRadius: 1,
opacity: 0.4,
},
panelHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 18,
paddingTop: 15,
paddingBottom: 4,
},
panelDate: {
fontSize: 16,
fontWeight: '700',
},
panelCount: {
fontSize: 13,
},
emptyState: {
alignItems: 'center',
paddingVertical: 36,
},
emptyText: {
fontSize: 15,
fontWeight: '600',
marginBottom: 4,
},
emptySubtext: {
fontSize: 13,
},
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,
},
});