functioning apk with reworked calendar
This commit is contained in:
@@ -1,224 +1,448 @@
|
||||
import React, { useMemo, useRef, useState, useCallback } from 'react';
|
||||
import { View, Text, StyleSheet, SafeAreaView, ScrollView, TouchableOpacity, FlatList } from 'react-native';
|
||||
import React, { useMemo, useRef, useState, useCallback, useEffect } from 'react';
|
||||
import { View, Text, StyleSheet, SafeAreaView, ScrollView, TouchableOpacity, Animated, Dimensions } from 'react-native';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
|
||||
import { Header } from '@/components/Header';
|
||||
import { useTasksByDate } from '@/hooks/useTasks';
|
||||
import { useTasksByDate, useTasksInMonth } from '@/hooks/useTasks';
|
||||
import { useTaskModals } from '@/hooks/useTaskModals';
|
||||
import { toggleTaskComplete } from '@/utils/taskActions';
|
||||
import { useSettings } from '@/theme';
|
||||
import { TaskItem } from '@/components/TaskItem';
|
||||
import { useCategories, useDatabase } from '@/hooks/useDatabase';
|
||||
import { toggleTaskComplete } from '@/utils/taskActions';
|
||||
import { QuickAddBar } from '@/components/QuickAddBar';
|
||||
import { TaskData } from '@/types';
|
||||
import { format, startOfMonth, endOfMonth, eachDayOfInterval, isSameDay, isSameMonth, addMonths, isToday, startOfDay } from 'date-fns';
|
||||
import Svg, { Path } from 'react-native-svg';
|
||||
import { OptionPickerModal } from '@/components/OptionPickerModal';
|
||||
import { SubtaskData } from '@/types';
|
||||
import Task from '@/models/Task';
|
||||
import { format, addMonths, addDays, startOfMonth, isSameDay, isSameMonth, isToday } from 'date-fns';
|
||||
import { Q } from '@nozbe/watermelondb';
|
||||
import Svg, { Path, Circle } from 'react-native-svg';
|
||||
import type { ThemeColors } from '@/theme';
|
||||
|
||||
const DAY_WIDTH = 44;
|
||||
const DAY_GAP = 6;
|
||||
const WIDTH = Dimensions.get('window').width;
|
||||
const ORANGE = '#FF7043';
|
||||
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 { modals, openTaskMenu, openTaskDelete } = useTaskModals();
|
||||
const { collections } = useDatabase();
|
||||
const categories = useCategories();
|
||||
const { modals, openTaskMenu } = useTaskModals();
|
||||
|
||||
const [visibleMonth, setVisibleMonth] = useState(() => new Date());
|
||||
const [selectedDate, setSelectedDate] = useState(() => new Date());
|
||||
const stripRef = useRef<ScrollView>(null);
|
||||
const [monthPickerVisible, setMonthPickerVisible] = useState(false);
|
||||
const [yearPickerVisible, setYearPickerVisible] = useState(false);
|
||||
const [subtasksMap, setSubtasksMap] = useState<Record<string, SubtaskData[]>>({});
|
||||
|
||||
const days = useMemo(
|
||||
() => eachDayOfInterval({ start: startOfMonth(visibleMonth), end: endOfMonth(visibleMonth) }),
|
||||
[visibleMonth]
|
||||
const visibleMonthRef = useRef(visibleMonth);
|
||||
visibleMonthRef.current = visibleMonth;
|
||||
const selectedDateRef = useRef(selectedDate);
|
||||
selectedDateRef.current = selectedDate;
|
||||
|
||||
const translateX = useRef(new Animated.Value(0)).current;
|
||||
const animatingRef = useRef(false);
|
||||
const gridWidthRef = useRef<number>(WIDTH);
|
||||
|
||||
const { tasks: selectedDayTasks } = useTasksByDate(selectedDate);
|
||||
const monthTasks = useTasksInMonth(visibleMonth);
|
||||
|
||||
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 loadSubtasks = useCallback(
|
||||
async (taskId: string): Promise<[string, SubtaskData[]]> => {
|
||||
const subs = await collections.subtasks
|
||||
.query(Q.where('task_id', taskId), Q.where('parent_subtask_id', null), Q.sortBy('order', 'asc'))
|
||||
.fetch();
|
||||
const mapped: SubtaskData[] = subs.map((s: any) => ({
|
||||
id: s.id,
|
||||
taskId: s.taskId,
|
||||
parentSubtaskId: s.parentSubtaskId || null,
|
||||
title: s.title,
|
||||
description: s.description || '',
|
||||
priority: (s.priority || 'none') as SubtaskData['priority'],
|
||||
completed: s.completed,
|
||||
dueDate: s.dueDate || 0,
|
||||
dueTime: s.dueTime || '',
|
||||
endTime: s.endTime || '',
|
||||
allDay: s.allDay ?? false,
|
||||
repeat: (s.repeat || 'none') as SubtaskData['repeat'],
|
||||
repeatInterval: s.repeatInterval ?? 1,
|
||||
repeatDays: s.repeatDays || '',
|
||||
seriesId: s.seriesId || '',
|
||||
reminder: (s.reminder || 'none') as SubtaskData['reminder'],
|
||||
assigneeId: s.assigneeId ?? null,
|
||||
order: s.order,
|
||||
subtasks: [],
|
||||
}));
|
||||
return [taskId, mapped];
|
||||
},
|
||||
[collections.subtasks]
|
||||
);
|
||||
|
||||
const { tasks, loading } = useTasksByDate(selectedDate);
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
setSubtasksMap({});
|
||||
Promise.all(selectedDayTasks.map((t) => loadSubtasks(t.id))).then((entries) => {
|
||||
if (!active) return;
|
||||
setSubtasksMap(Object.fromEntries(entries));
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [selectedDayTasks, loadSubtasks]);
|
||||
|
||||
const handleDayPress = useCallback((day: Date) => {
|
||||
setSelectedDate(day);
|
||||
if (!isSameMonth(day, visibleMonth)) {
|
||||
if (!isSameMonth(day, visibleMonthRef.current)) {
|
||||
setVisibleMonth(day);
|
||||
}
|
||||
}, [visibleMonth]);
|
||||
}, []);
|
||||
|
||||
const handlePrevMonth = useCallback(() => {
|
||||
const prev = addMonths(visibleMonth, -1);
|
||||
setVisibleMonth(prev);
|
||||
if (!isSameMonth(selectedDate, prev)) {
|
||||
setSelectedDate(startOfMonth(prev));
|
||||
}
|
||||
}, [visibleMonth, selectedDate]);
|
||||
const transitionTo = useCallback(
|
||||
(dir: 1 | -1) => {
|
||||
if (animatingRef.current) return;
|
||||
animatingRef.current = true;
|
||||
const w = gridWidthRef.current || WIDTH;
|
||||
const target = dir === 1 ? -w : w;
|
||||
Animated.timing(translateX, { toValue: target, duration: 220, useNativeDriver: false }).start(() => {
|
||||
const next = addMonths(visibleMonthRef.current, dir);
|
||||
if (!isSameMonth(selectedDateRef.current, next)) {
|
||||
setSelectedDate(startOfMonth(next));
|
||||
}
|
||||
setVisibleMonth(next);
|
||||
translateX.setValue(-target);
|
||||
Animated.timing(translateX, { toValue: 0, duration: 180, useNativeDriver: false }).start(() => {
|
||||
animatingRef.current = false;
|
||||
});
|
||||
});
|
||||
},
|
||||
[translateX]
|
||||
);
|
||||
|
||||
const handleNextMonth = useCallback(() => {
|
||||
const next = addMonths(visibleMonth, 1);
|
||||
setVisibleMonth(next);
|
||||
if (!isSameMonth(selectedDate, next)) {
|
||||
setSelectedDate(startOfMonth(next));
|
||||
}
|
||||
}, [visibleMonth, selectedDate]);
|
||||
const pan = useMemo(
|
||||
() =>
|
||||
Gesture.Pan()
|
||||
.activeOffsetX([-16, 16])
|
||||
.minDistance(6)
|
||||
.runOnJS(true)
|
||||
.onUpdate((e) => {
|
||||
if (!animatingRef.current) translateX.setValue(e.translationX);
|
||||
})
|
||||
.onEnd((e) => {
|
||||
if (animatingRef.current) return;
|
||||
const w = gridWidthRef.current || WIDTH;
|
||||
const dx = e.translationX;
|
||||
if (dx <= -w / 4) {
|
||||
translateX.stopAnimation();
|
||||
transitionTo(1);
|
||||
} else if (dx >= w / 4) {
|
||||
translateX.stopAnimation();
|
||||
transitionTo(-1);
|
||||
} else {
|
||||
Animated.spring(translateX, { 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 toggleTaskComplete(taskId);
|
||||
}, []);
|
||||
|
||||
const scrollToDay = useCallback((day: Date) => {
|
||||
const index = days.findIndex((d) => isSameDay(d, day));
|
||||
if (index >= 0) {
|
||||
stripRef.current?.scrollTo({ x: Math.max(0, index * (DAY_WIDTH + DAY_GAP) - 24), animated: true });
|
||||
}
|
||||
}, [days]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const target = isSameMonth(selectedDate, visibleMonth) ? selectedDate : startOfDay(new Date());
|
||||
scrollToDay(target);
|
||||
}, [visibleMonth, scrollToDay, selectedDate]);
|
||||
|
||||
const renderTask = useCallback(
|
||||
({ item }: { item: TaskData }) => (
|
||||
<TaskItem
|
||||
task={item}
|
||||
onToggle={() => handleToggleComplete(item.id)}
|
||||
onDelete={() => openTaskDelete(item)}
|
||||
onPress={() => router.push({ pathname: '/task-detail', params: { id: item.id } })}
|
||||
onMenuOpen={() => openTaskMenu(item)}
|
||||
/>
|
||||
),
|
||||
[handleToggleComplete, openTaskDelete, openTaskMenu, router]
|
||||
);
|
||||
|
||||
return (
|
||||
<SafeAreaView style={[styles.container, { backgroundColor: theme.background }]}>
|
||||
<Header title="Calendar" showLogo={false} />
|
||||
<GestureDetector gesture={pan}>
|
||||
<View style={styles.flex}>
|
||||
<Header title="Calendar" showLogo={false} />
|
||||
|
||||
<ScrollView
|
||||
ref={stripRef}
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.dateStrip}
|
||||
>
|
||||
{days.map((day) => (
|
||||
<DayButton
|
||||
key={day.toISOString()}
|
||||
day={day}
|
||||
selected={isSameDay(day, selectedDate)}
|
||||
current={isToday(day) && !isSameDay(day, selectedDate)}
|
||||
onPress={handleDayPress}
|
||||
theme={theme}
|
||||
<ScrollView showsVerticalScrollIndicator={false} contentContainerStyle={styles.scrollContent}>
|
||||
<Animated.View
|
||||
onLayout={(e) => {
|
||||
gridWidthRef.current = e.nativeEvent.layout.width;
|
||||
}}
|
||||
style={[styles.calendarArea, { transform: [{ translateX }] }]}
|
||||
>
|
||||
<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.textFaint} strokeWidth={2} 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.textMuted} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||
</Svg>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity onPress={() => setYearPickerVisible(true)} activeOpacity={0.7} style={styles.yearButton}>
|
||||
<Text style={[styles.yearLabel, { color: theme.textMuted }]}>{format(visibleMonth, 'yyyy')}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<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.textFaint} strokeWidth={2} 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)}
|
||||
selected={isSameDay(day, selectedDate)}
|
||||
today={isToday(day)}
|
||||
inMonth={isSameMonth(day, visibleMonth)}
|
||||
theme={theme}
|
||||
onPress={handleDayPress}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
))}
|
||||
</Animated.View>
|
||||
|
||||
<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={22} height={22} 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.textMuted} strokeWidth={2} 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={() => openTaskMenu(task)} activeOpacity={0.7}>
|
||||
<Svg width={20} height={20} viewBox="0 0 24 24">
|
||||
<Circle cx={12} cy={5} r={1.5} fill={theme.textMuted} />
|
||||
<Circle cx={12} cy={12} r={1.5} fill={theme.textMuted} />
|
||||
<Circle cx={12} cy={19} r={1.5} fill={theme.textMuted} />
|
||||
</Svg>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{(subtasksMap[task.id] ?? []).length > 0 && (
|
||||
<View style={styles.bullets}>
|
||||
{(subtasksMap[task.id] ?? []).map((sub) => (
|
||||
<TouchableOpacity
|
||||
key={sub.id}
|
||||
style={styles.bulletRow}
|
||||
onPress={() => router.push({ pathname: '/subtask-detail', params: { id: sub.id } })}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<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>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
))
|
||||
)}
|
||||
</ScrollView>
|
||||
|
||||
<QuickAddBar dueDate={selectedDate.getTime()} placeholder={`Add event for ${format(selectedDate, 'MMM d')}`} />
|
||||
|
||||
{modals(() => {})}
|
||||
|
||||
<OptionPickerModal
|
||||
visible={monthPickerVisible}
|
||||
title="Select Month"
|
||||
options={monthOptions}
|
||||
selectedValue={String(visibleMonth.getMonth())}
|
||||
onSelect={handleSelectMonth}
|
||||
onClose={() => setMonthPickerVisible(false)}
|
||||
/>
|
||||
))}
|
||||
</ScrollView>
|
||||
|
||||
<View style={styles.monthRow}>
|
||||
<TouchableOpacity onPress={handlePrevMonth} 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.textFaint} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||
</Svg>
|
||||
</TouchableOpacity>
|
||||
<Text style={[styles.monthLabel, { color: theme.text }]}>{format(visibleMonth, 'MMM yyyy').toUpperCase()}</Text>
|
||||
<TouchableOpacity onPress={handleNextMonth} 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.textFaint} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||
</Svg>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<FlatList
|
||||
data={tasks}
|
||||
keyExtractor={(item) => item.id}
|
||||
renderItem={renderTask}
|
||||
ItemSeparatorComponent={MemoSeparator}
|
||||
ListEmptyComponent={
|
||||
loading ? (
|
||||
<View style={styles.emptyState}>
|
||||
<Text style={[styles.emptyText, { color: theme.textFaint }]}>Loading...</Text>
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.emptyState}>
|
||||
<Text style={[styles.emptyText, { color: theme.textSecondary }]}>No tasks scheduled.</Text>
|
||||
<Text style={[styles.emptySubtext, { color: theme.textMuted }]}>Use the bar below to add one</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
contentContainerStyle={styles.listContent}
|
||||
/>
|
||||
|
||||
<QuickAddBar
|
||||
dueDate={selectedDate.getTime()}
|
||||
placeholder={`Add task for ${format(selectedDate, 'MMM d')}`}
|
||||
/>
|
||||
|
||||
{modals(() => {})}
|
||||
<OptionPickerModal
|
||||
visible={yearPickerVisible}
|
||||
title="Select Year"
|
||||
options={yearOptions}
|
||||
selectedValue={String(visibleMonth.getFullYear())}
|
||||
onSelect={handleSelectYear}
|
||||
onClose={() => setYearPickerVisible(false)}
|
||||
/>
|
||||
</View>
|
||||
</GestureDetector>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
interface DayButtonProps {
|
||||
day: Date;
|
||||
selected: boolean;
|
||||
current: boolean;
|
||||
onPress: (day: Date) => void;
|
||||
theme: ThemeColors;
|
||||
function byDayOf(byDay: Record<number, Task[]>, day: Date): Task[] {
|
||||
return byDay[day.getDate()] ?? [];
|
||||
}
|
||||
|
||||
const DayButton = React.memo(function DayButton({ day, selected, current, onPress, theme }: DayButtonProps) {
|
||||
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 }[]): string {
|
||||
if (tasks.length === 0) return '#8E8E8E';
|
||||
const cat = categories.find((c) => c.id === tasks[0].categoryId);
|
||||
return cat?.color ?? '#8E8E8E';
|
||||
}
|
||||
|
||||
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) {
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.dayButton,
|
||||
{ backgroundColor: theme.card, borderColor: theme.borderStrong },
|
||||
current && { borderColor: theme.accent, borderWidth: 1.5 },
|
||||
selected && { backgroundColor: theme.accent, borderColor: theme.accent },
|
||||
]}
|
||||
style={[styles.dayCell, selected && { backgroundColor: ORANGE }]}
|
||||
onPress={() => onPress(day)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={[styles.dayWeekday, { color: theme.textMuted }, selected && styles.dayTextSelected]}>
|
||||
{format(day, 'EEE').charAt(0)}
|
||||
</Text>
|
||||
<Text style={[styles.dayNumber, { color: theme.text }, selected && styles.dayTextSelected]}>
|
||||
<Text
|
||||
style={[
|
||||
styles.dayNumber,
|
||||
{ color: theme.text },
|
||||
!inMonth && { color: theme.borderStrong },
|
||||
today && !selected && { color: ORANGE },
|
||||
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 MemoSeparator = React.memo(function Separator() {
|
||||
return <View style={styles.separator} />;
|
||||
});
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
dateStrip: {
|
||||
paddingHorizontal: 16,
|
||||
paddingTop: 12,
|
||||
gap: DAY_GAP,
|
||||
flex: {
|
||||
flex: 1,
|
||||
},
|
||||
dayButton: {
|
||||
width: DAY_WIDTH,
|
||||
height: 60,
|
||||
borderRadius: 16,
|
||||
borderWidth: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 2,
|
||||
scrollContent: {
|
||||
paddingBottom: 96,
|
||||
flexGrow: 1,
|
||||
},
|
||||
dayTextSelected: {
|
||||
color: '#FFFFFF',
|
||||
},
|
||||
dayWeekday: {
|
||||
fontSize: 11,
|
||||
fontWeight: '600',
|
||||
textTransform: 'uppercase',
|
||||
},
|
||||
dayNumber: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
calendarArea: {
|
||||
paddingHorizontal: 12,
|
||||
paddingTop: 6,
|
||||
paddingBottom: 4,
|
||||
},
|
||||
monthRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 24,
|
||||
paddingVertical: 12,
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: 4,
|
||||
paddingBottom: 14,
|
||||
},
|
||||
monthNav: {
|
||||
width: 36,
|
||||
@@ -228,34 +452,158 @@ const styles = StyleSheet.create({
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
monthLabel: {
|
||||
fontSize: 15,
|
||||
fontWeight: '700',
|
||||
letterSpacing: 1,
|
||||
minWidth: 120,
|
||||
textAlign: 'center',
|
||||
monthSelectorGroup: {
|
||||
alignItems: 'center',
|
||||
},
|
||||
listContent: {
|
||||
paddingHorizontal: 16,
|
||||
paddingTop: 4,
|
||||
paddingBottom: 100,
|
||||
flexGrow: 1,
|
||||
},
|
||||
separator: {
|
||||
height: 8,
|
||||
},
|
||||
emptyState: {
|
||||
flex: 1,
|
||||
monthButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: 64,
|
||||
gap: 6,
|
||||
paddingHorizontal: 10,
|
||||
},
|
||||
monthLabel: {
|
||||
fontSize: 22,
|
||||
fontWeight: '700',
|
||||
},
|
||||
yearButton: {
|
||||
marginTop: -2,
|
||||
},
|
||||
yearLabel: {
|
||||
fontSize: 13,
|
||||
fontWeight: '500',
|
||||
},
|
||||
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: '#FFFFFF',
|
||||
},
|
||||
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: 16,
|
||||
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: 24,
|
||||
marginRight: 10,
|
||||
},
|
||||
eventTitleTouch: {
|
||||
flex: 1,
|
||||
},
|
||||
eventTitle: {
|
||||
fontSize: 15,
|
||||
fontWeight: '500',
|
||||
},
|
||||
eventCompleted: {
|
||||
textDecorationLine: 'line-through',
|
||||
color: '#9E9E9E',
|
||||
},
|
||||
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: '#6E6E6E',
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user