621 lines
20 KiB
TypeScript
621 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 { 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 } 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',
|
|
];
|
|
|
|
export default function CalendarScreen() {
|
|
const router = useRouter();
|
|
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());
|
|
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;
|
|
|
|
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();
|
|
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]);
|
|
|
|
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} />
|
|
|
|
<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>
|
|
{sortedDayTasks.length > 0 && (
|
|
<Text style={[styles.panelCount, { color: theme.textFaint }]}>
|
|
{sortedDayTasks.length} event{sortedDayTasks.length === 1 ? '' : 's'}
|
|
</Text>
|
|
)}
|
|
</View>
|
|
|
|
{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>
|
|
) : (
|
|
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>
|
|
);
|
|
})
|
|
)}
|
|
</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()] ?? [];
|
|
}
|
|
|
|
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;
|
|
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;
|
|
return theme.accent;
|
|
}
|
|
|
|
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,
|
|
},
|
|
subtaskList: {
|
|
paddingLeft: 8,
|
|
paddingRight: 4,
|
|
paddingTop: 4,
|
|
},
|
|
}); |