functioning apk with reworked calendar

This commit is contained in:
2026-08-07 22:29:46 +02:00
parent ecb7fbefb1
commit ed31f24b23
22 changed files with 1108 additions and 502 deletions
+530 -182
View File
@@ -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',
},
});
+1 -1
View File
@@ -22,7 +22,7 @@ export default function TasksScreen() {
return (
<SafeAreaView style={[styles.container, { backgroundColor: theme.background }]}>
<Header title="TODO" showLogo={true} />
<Header title="TODO" showLogo={false} />
<View style={styles.categoryFilterWrapper}>
<CategoryFilter selected={selectedCategory} onSelect={setSelectedCategory} />
</View>
+154 -3
View File
@@ -1,5 +1,5 @@
import React, { useState } from 'react';
import { View, Text, StyleSheet, SafeAreaView, ScrollView, Switch, TouchableOpacity, Alert, Linking } from 'react-native';
import { View, Text, StyleSheet, SafeAreaView, ScrollView, Switch, TouchableOpacity, Alert, Linking, Modal, Pressable } from 'react-native';
import { Header } from '@/components/Header';
import { ListItem } from '@/components/ListItem';
import { OptionPickerModal } from '@/components/OptionPickerModal';
@@ -9,16 +9,17 @@ import { FriendsModal } from '@/components/FriendsModal';
import { LegalModal } from '@/components/LegalModal';
import { ServerUrlModal } from '@/components/ServerUrlModal';
import SyncStatus from '@/components/SyncStatus';
import { useSettings, SORT_OPTIONS, REMINDER_OPTIONS } from '@/theme';
import { useSettings, SORT_OPTIONS, REMINDER_OPTIONS, ACCENT_PRESETS, DEFAULT_ACCENT } from '@/theme';
import { useCategories } from '@/hooks/useDatabase';
import { getAuthUser, getAuthToken } from '@/services/auth';
import { checkForUpdates, getCurrentAppVersion } from '@/services/updates';
import { getLastSyncTime } from '@/database/sync';
import Category from '@/models/Category';
import Svg, { Path } from 'react-native-svg';
import ColorWheel from '@/components/ColorWheel';
export default function SettingsScreen() {
const { theme, notifications, setNotifications, defaultCategoryId, setDefaultCategoryId, sortBy, setSortBy, reminderPreference, setReminderPreference, apiUrl } = useSettings();
const { theme, notifications, setNotifications, defaultCategoryId, setDefaultCategoryId, sortBy, setSortBy, reminderPreference, setReminderPreference, apiUrl, accentColor, setAccentColor } = useSettings();
const categories = useCategories();
const [picker, setPicker] = useState<null | 'category' | 'sort' | 'reminder'>(null);
const [editingCategory, setEditingCategory] = useState<Category | null | 'new'>(null);
@@ -26,6 +27,8 @@ export default function SettingsScreen() {
const [friendsVisible, setFriendsVisible] = useState(false);
const [legalVisible, setLegalVisible] = useState<null | 'privacy' | 'terms'>(null);
const [serverUrlVisible, setServerUrlVisible] = useState(false);
const [customAccentVisible, setCustomAccentVisible] = useState(false);
const [draftAccent, setDraftAccent] = useState(accentColor);
const [syncSubtitle, setSyncSubtitle] = useState('Checking...');
const [updateSubtitle, setUpdateSubtitle] = useState('Tap to check');
@@ -76,6 +79,11 @@ export default function SettingsScreen() {
const defaultCategoryLabel = defaultCategory?.name ?? (categories.length > 0 ? categories[0].name : 'None');
const reminderLabel = REMINDER_OPTIONS.find((o) => o.value === reminderPreference)?.label ?? 'No reminder';
const openCustomAccent = () => {
setDraftAccent(accentColor);
setCustomAccentVisible(true);
};
const handleDefaultCategory = (value: string | string[]) => {
setDefaultCategoryId(Array.isArray(value) ? value[0] : value);
};
@@ -148,6 +156,35 @@ export default function SettingsScreen() {
onPress={() => setPicker('sort')}
showChevron
/>
<Text style={[styles.sectionTitle, { color: theme.textMuted }]}>Appearance</Text>
<Text style={[styles.sectionHint, { color: theme.textFaint }]}>Accent color</Text>
<View style={styles.accentPresets}>
{ACCENT_PRESETS.map((c) => (
<TouchableOpacity
key={c}
style={[
styles.accentSwatch,
{ backgroundColor: c },
accentColor.toUpperCase() === c && styles.accentSwatchSelected,
]}
onPress={() => setAccentColor(c)}
activeOpacity={0.8}
>
{accentColor.toUpperCase() === c && (
<Svg width={16} height={16} viewBox="0 0 24 24">
<Path d="M5 12l5 5 9-10" stroke={contrastOnSwatch(c)} strokeWidth={3} strokeLinecap="round" strokeLinejoin="round" fill="none" />
</Svg>
)}
</TouchableOpacity>
))}
</View>
<ListItem
title="Custom Color"
subtitle={accentColor}
leftElement={<View style={[styles.categoryDot, { backgroundColor: accentColor }]} />}
onPress={openCustomAccent}
showChevron
/>
<Text style={[styles.sectionTitle, { color: theme.textMuted }]}>Data</Text>
<SyncStatus />
<ListItem
@@ -232,6 +269,41 @@ export default function SettingsScreen() {
<ServerUrlModal visible={serverUrlVisible} onClose={() => setServerUrlVisible(false)} />
<Modal visible={customAccentVisible} transparent animationType="fade" onRequestClose={() => setCustomAccentVisible(false)}>
<Pressable
style={styles.accentModalOverlay}
onPress={() => setCustomAccentVisible(false)}
>
<Pressable style={[styles.accentModalSheet, { backgroundColor: theme.sheetBg }]} onPress={() => {}}>
<View style={styles.accentModalHeader}>
<Text style={[styles.accentModalTitle, { color: theme.text }]}>Custom Accent Color</Text>
<TouchableOpacity onPress={() => setCustomAccentVisible(false)} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}>
<Svg width={18} height={18} viewBox="0 0 24 24">
<Path d="M18 6L6 18M6 6l12 12" stroke={theme.textMuted} strokeWidth={2} strokeLinecap="round" />
</Svg>
</TouchableOpacity>
</View>
<ColorWheel color={draftAccent} onChange={setDraftAccent} />
<View style={styles.accentModalActions}>
<TouchableOpacity
style={[styles.accentModalButton, { borderColor: theme.borderStrong }]}
onPress={() => { setDraftAccent(DEFAULT_ACCENT); setAccentColor(DEFAULT_ACCENT); }}
activeOpacity={0.7}
>
<Text style={[styles.accentModalButtonText, { color: theme.textSecondary }]}>Reset</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.accentModalButton, styles.accentModalButtonPrimary, { backgroundColor: theme.accent }]}
onPress={() => { setAccentColor(draftAccent); setCustomAccentVisible(false); }}
activeOpacity={0.8}
>
<Text style={[styles.accentModalButtonText, { color: theme.accentText }]}>Apply</Text>
</TouchableOpacity>
</View>
</Pressable>
</Pressable>
</Modal>
<LegalModal
visible={legalVisible !== null}
type={legalVisible}
@@ -249,6 +321,16 @@ function formatSyncTime(timestamp: number): string {
return new Date(timestamp).toLocaleDateString();
}
function contrastOnSwatch(hex: string): string {
const h = hex.replace(/^#/, '');
const r = parseInt(h.slice(0, 2), 16) / 255;
const g = parseInt(h.slice(2, 4), 16) / 255;
const b = parseInt(h.slice(4, 6), 16) / 255;
const linear = (v: number) => (v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4));
const luminance = 0.2126 * linear(r) + 0.7152 * linear(g) + 0.0722 * linear(b);
return luminance > 0.5 ? '#111111' : '#FFFFFF';
}
const styles = StyleSheet.create({
container: {
flex: 1,
@@ -294,4 +376,73 @@ const styles = StyleSheet.create({
fontSize: 15,
fontWeight: '600',
},
sectionHint: {
fontSize: 12,
marginLeft: 4,
marginBottom: 8,
},
accentPresets: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 10,
paddingHorizontal: 4,
marginBottom: 4,
},
accentSwatch: {
width: 36,
height: 36,
borderRadius: 18,
alignItems: 'center',
justifyContent: 'center',
},
accentSwatchSelected: {
borderWidth: 2,
borderColor: '#FFFFFF',
shadowColor: '#000',
shadowOpacity: 0.3,
shadowRadius: 3,
elevation: 3,
},
accentModalOverlay: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.6)',
alignItems: 'center',
justifyContent: 'center',
padding: 24,
},
accentModalSheet: {
width: '100%',
maxWidth: 400,
borderRadius: 16,
padding: 20,
},
accentModalHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 16,
},
accentModalTitle: {
fontSize: 18,
fontWeight: '700',
},
accentModalActions: {
flexDirection: 'row',
gap: 12,
marginTop: 16,
},
accentModalButton: {
flex: 1,
paddingVertical: 12,
borderRadius: 12,
borderWidth: 1,
alignItems: 'center',
},
accentModalButtonPrimary: {
borderWidth: 0,
},
accentModalButtonText: {
fontSize: 15,
fontWeight: '600',
},
});
+5 -5
View File
@@ -15,7 +15,7 @@ import { AssigneeSelector } from '@/components/AssigneeSelector';
import { useForm, FormProvider, Controller } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useDatabase, useCategories } from '@/hooks/useDatabase';
import { useDatabase } from '@/hooks/useDatabase';
import { database, collections } from '@/database';
import { TaskFormData } from '@/types';
import { useSettings } from '@/theme';
@@ -25,7 +25,7 @@ import { useFriends } from '@/hooks/useFriends';
const taskSchema = z.object({
title: z.string().trim().min(1, 'Task name is required').max(100),
description: z.string().max(1000).optional(),
categoryId: z.string().min(1, 'Category is required'),
categoryId: z.string().optional(),
priority: z.enum(['none', 'low', 'medium', 'high', 'critical']),
dueDate: z.date().nullable().optional(),
dueTime: z.string().optional(),
@@ -42,13 +42,12 @@ const taskSchema = z.object({
export default function AddTaskScreen() {
const { isReady } = useDatabase();
const categories = useCategories();
const { defaultCategoryId } = useSettings();
const router = useRouter();
const { theme } = useSettings();
const { date: dateParam } = useLocalSearchParams<{ date?: string }>();
const { friends } = useFriends();
const initialCategory = defaultCategoryId || categories[0]?.id || '';
const initialCategory = defaultCategoryId || '';
const initialDate = useMemo(() => {
if (!dateParam) return null;
const parsed = new Date(Array.isArray(dateParam) ? dateParam[0] : dateParam);
@@ -108,6 +107,7 @@ export default function AddTaskScreen() {
const seriesId = data.repeat !== 'none'
? `series_${Math.random().toString(36).slice(2, 10)}${Date.now().toString(36)}`
: '';
const resolvedCategoryId = data.categoryId || '';
let createdTask: any = null;
@@ -115,7 +115,7 @@ export default function AddTaskScreen() {
const task = await collections.tasks.create((t) => {
t.title = data.title.trim();
t.description = data.description || '';
t.categoryId = data.categoryId;
t.categoryId = resolvedCategoryId;
t.priority = data.priority;
t.completed = false;
t.dueDate = dueDateTimestamp;
+2 -2
View File
@@ -29,7 +29,7 @@ import Svg, { Path, Circle } from 'react-native-svg';
const taskSchema = z.object({
title: z.string().trim().min(1, 'Task name is required').max(100),
description: z.string().max(1000).optional(),
categoryId: z.string().min(1, 'Category is required'),
categoryId: z.string().optional(),
priority: z.enum(['none', 'low', 'medium', 'high', 'critical']),
dueDate: z.date().nullable().optional(),
dueTime: z.string().optional(),
@@ -167,7 +167,7 @@ export default function TaskDetailScreen() {
await task.update((t) => {
t.title = data.title.trim();
t.description = data.description || '';
t.categoryId = data.categoryId;
t.categoryId = data.categoryId || task.categoryId || '';
t.priority = data.priority;
t.dueDate = dueDateTimestamp;
t.dueTime = data.dueTime || '';
+6 -70
View File
@@ -28,13 +28,10 @@
"react-hook-form": "^7.51.5",
"react-native": "0.86.2",
"react-native-gesture-handler": "2.32.0",
"react-native-paper": "^5.12.3",
"react-native-reanimated": "4.5.1",
"react-native-safe-area-context": "5.7.0",
"react-native-screens": "4.26.0",
"react-native-svg": "^15.15.4",
"react-native-web": "^0.21.2",
"react-native-worklets": "0.10.1",
"zod": "^3.23.8"
},
"devDependencies": {
@@ -577,6 +574,7 @@
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.29.7.tgz",
"integrity": "sha512-N7zArUXWzAMzm+/N0uPBeVB3Fam5lMxtUwMmDK5f/IBBS7a7p1qeUoxd/6CckXoxUdgsntq1Dh8xNW06maZbDQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/helper-plugin-utils": "^7.29.7"
},
@@ -1058,6 +1056,7 @@
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.29.7.tgz",
"integrity": "sha512-I+WYbGBAiCn7nA6xBrlgPH+MB7HWb4u8pv5S0Pv7OtwNvIFvCCb24YlttKEeUFVurfBCEaOTnuhlqsb7f0Z5Dg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/helper-plugin-utils": "^7.29.7"
},
@@ -1073,6 +1072,7 @@
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.29.7.tgz",
"integrity": "sha512-NCSEJ4sLFU2gqAub45HYh4fus2yQ36rr6ei6vpU7NdoJqCpxvEG8E6eJpscGyXP3VHD2Ny+fSXr04k1hoUrFqA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/helper-plugin-utils": "^7.29.7"
},
@@ -1191,28 +1191,6 @@
"node": ">=6.9.0"
}
},
"node_modules/@callstack/react-theme-provider": {
"version": "3.0.9",
"resolved": "https://registry.npmjs.org/@callstack/react-theme-provider/-/react-theme-provider-3.0.9.tgz",
"integrity": "sha512-tTQ0uDSCL0ypeMa8T/E9wAZRGKWj8kXP7+6RYgPTfOPs9N07C9xM8P02GJ3feETap4Ux5S69D9nteq9mEj86NA==",
"license": "MIT",
"dependencies": {
"deepmerge": "^3.2.0",
"hoist-non-react-statics": "^3.3.0"
},
"peerDependencies": {
"react": ">=16.3.0"
}
},
"node_modules/@callstack/react-theme-provider/node_modules/deepmerge": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-3.3.0.tgz",
"integrity": "sha512-GRQOafGHwMHpjPx9iCvTgpu9NojZ49q794EEL94JVEw6VaeA8XTUyBKvAkOOjBX9oJNiV6G3P+T+tihFjo2TqA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/@egjs/hammerjs": {
"version": "2.0.17",
"resolved": "https://registry.npmjs.org/@egjs/hammerjs/-/hammerjs-2.0.17.tgz",
@@ -10285,61 +10263,18 @@
"resolved": "https://registry.npmjs.org/react-native-is-edge-to-edge/-/react-native-is-edge-to-edge-1.3.1.tgz",
"integrity": "sha512-NIXU/iT5+ORyCc7p0z2nnlkouYKX425vuU1OEm6bMMtWWR9yvb+Xg5AZmImTKoF9abxCPqrKC3rOZsKzUYgYZA==",
"license": "MIT",
"peer": true,
"peerDependencies": {
"react": "*",
"react-native": "*"
}
},
"node_modules/react-native-paper": {
"version": "5.15.3",
"resolved": "https://registry.npmjs.org/react-native-paper/-/react-native-paper-5.15.3.tgz",
"integrity": "sha512-GEyNTmWElIZgnYw09AjjCNupRYzCmP79uAAyGSyCEUZz7KBz1wtJcC0wVUkozR1Rn3PK/td/9LlR6+F1hzmYvA==",
"license": "MIT",
"workspaces": [
"example",
"docs"
],
"dependencies": {
"@callstack/react-theme-provider": "^3.0.9",
"color": "^3.1.2",
"use-latest-callback": "^0.2.3"
},
"peerDependencies": {
"react": "*",
"react-native": "*",
"react-native-safe-area-context": "*"
}
},
"node_modules/react-native-paper/node_modules/color": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/color/-/color-3.2.1.tgz",
"integrity": "sha512-aBl7dZI9ENN6fUGC7mWpMTPNHmWUSNan9tuWN6ahh5ZLNk9baLJOnSMlrQkHcrfFgz2/RigjUVAjdx36VcemKA==",
"license": "MIT",
"dependencies": {
"color-convert": "^1.9.3",
"color-string": "^1.6.0"
}
},
"node_modules/react-native-paper/node_modules/color-convert": {
"version": "1.9.3",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
"integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==",
"license": "MIT",
"dependencies": {
"color-name": "1.1.3"
}
},
"node_modules/react-native-paper/node_modules/color-name": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz",
"integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==",
"license": "MIT"
},
"node_modules/react-native-reanimated": {
"version": "4.5.1",
"resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.5.1.tgz",
"integrity": "sha512-RnMvtDuR+68ig864gAvZCOdZehqhC5rFmMo0kn+ARfgVSTvFeF6IFLBVgMPUu0KwihaapEyW24WRi6nEyy1kSA==",
"license": "MIT",
"peer": true,
"dependencies": {
"react-native-is-edge-to-edge": "^1.3.1",
"semver": "^7.7.3"
@@ -10426,6 +10361,7 @@
"resolved": "https://registry.npmjs.org/react-native-worklets/-/react-native-worklets-0.10.1.tgz",
"integrity": "sha512-62mRM19bDpfpdI8HLkEErcdOsrAPDtE9lA/sw+5lLRpzBHNhxaoj9QyY2KjXqUmirelxkX4zuPGTC3VdA0feJA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/plugin-transform-arrow-functions": "^7.27.1",
"@babel/plugin-transform-class-properties": "^7.28.6",
@@ -132,7 +132,7 @@ export function CategoryEditorModal({ visible, category, categoryCount, onClose
disabled={!name.trim()}
activeOpacity={0.8}
>
<Text style={styles.saveButtonText}>Save</Text>
<Text style={[styles.saveButtonText, { color: theme.accentText }]}>Save</Text>
</TouchableOpacity>
</View>
</View>
@@ -231,6 +231,5 @@ const styles = StyleSheet.create({
saveButtonText: {
fontSize: 15,
fontWeight: '600',
color: '#FFFFFF',
},
});
@@ -28,10 +28,10 @@ export function CategorySelector({ value, onChange, error }: CategorySelectorPro
activeOpacity={0.8}
>
<View style={styles.selectorContent}>
<View style={styles.selectorRow}>
<View style={[styles.colorCircle, { backgroundColor: selectedCategory?.color || '#9E9E9E' }]} />
<Text style={[styles.selectorValue, { color: theme.text }]}>{selectedCategory?.name || 'Select category'}</Text>
</View>
<View style={styles.selectorRow}>
<View style={[styles.colorCircle, { backgroundColor: selectedCategory?.color || '#9E9E9E' }]} />
<Text style={[styles.selectorValue, { color: theme.text }]}>{selectedCategory?.name || 'None'}</Text>
</View>
</View>
<Svg width={20} height={20} viewBox="0 0 24 24">
<Path d="M9 18l6-6-6-6" stroke={theme.textMuted} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" fill="none" />
@@ -53,6 +53,29 @@ export function CategorySelector({ value, onChange, error }: CategorySelectorPro
</TouchableOpacity>
</View>
<ScrollView contentContainerStyle={styles.modalContent}>
<TouchableOpacity
style={[
styles.modalOption,
{ backgroundColor: theme.card, borderColor: theme.borderStrong },
!value && { borderColor: theme.accent, backgroundColor: theme.accentSoft, borderWidth: 2 },
]}
onPress={() => { onChange(''); setShowModal(false); }}
activeOpacity={0.8}
>
<View style={[styles.colorCircle, { backgroundColor: '#9E9E9E' }, !value && styles.colorCircleSelected]} />
<Text style={[
styles.categoryName,
{ color: theme.textSecondary },
!value && { color: theme.accent, fontWeight: '600' },
]}>
None
</Text>
{!value && (
<Svg width={20} height={20} viewBox="0 0 24 24">
<Path d="M5 12l5 5 9-10" stroke={theme.accent} strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
</Svg>
)}
</TouchableOpacity>
{categories.map((category) => (
<TouchableOpacity
key={category.id}
@@ -111,15 +111,15 @@ export function FriendsModal({ visible, onClose }: FriendsModalProps) {
<Text
style={[
styles.tabText,
{ color: selectedTab === tab ? '#FFFFFF' : theme.textMuted },
{ color: selectedTab === tab ? theme.accentText : theme.textMuted },
]}
>
{tab.charAt(0).toUpperCase() + tab.slice(1)}
{tab === 'friends' && friends.length > 0 && (
<Text style={[styles.badge, { color: '#FFFFFF' }]}>{friends.length}</Text>
<Text style={[styles.badge, { color: theme.accentText }]}>{friends.length}</Text>
)}
{tab === 'incoming' && incoming.length > 0 && (
<Text style={[styles.badge, { color: '#FFFFFF' }]}>{incoming.length}</Text>
<Text style={[styles.badge, { color: theme.accentText }]}>{incoming.length}</Text>
)}
</Text>
</TouchableOpacity>
@@ -179,7 +179,7 @@ export function FriendsModal({ visible, onClose }: FriendsModalProps) {
style={[styles.actionBtn, { backgroundColor: theme.accent }]}
onPress={() => handleAccept(item.requestId)}
>
<Text style={styles.actionBtnText}>Accept</Text>
<Text style={[styles.actionBtnText, { color: theme.accentText }]}>Accept</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.actionBtn, { backgroundColor: 'transparent', borderColor: theme.borderStrong, borderWidth: 1 }]}
@@ -265,7 +265,7 @@ export function FriendsModal({ visible, onClose }: FriendsModalProps) {
.catch(err => Alert.alert('Error', err instanceof Error ? err.message : 'Failed to send request'))
}
>
<Text style={styles.addBtnText}>Add</Text>
<Text style={[styles.addBtnText, { color: theme.accentText }]}>Add</Text>
</TouchableOpacity>
</View>
)}
@@ -399,7 +399,6 @@ const styles = StyleSheet.create({
addBtnText: {
fontSize: 13,
fontWeight: '600',
color: '#FFFFFF',
},
requestActions: {
flexDirection: 'row',
@@ -413,7 +412,6 @@ const styles = StyleSheet.create({
actionBtnText: {
fontSize: 13,
fontWeight: '600',
color: '#FFFFFF',
},
cancelBtn: {
paddingVertical: 6,
@@ -18,7 +18,7 @@ export function QuickAddBar({ dueDate = 0, placeholder }: QuickAddBarProps) {
const insets = useSafeAreaInsets();
const categories = useCategories();
const [title, setTitle] = useState('');
const [categoryId, setCategoryId] = useState(() => defaultCategoryId || categories[0]?.id || '');
const [categoryId, setCategoryId] = useState(() => defaultCategoryId || '');
const [categoryPickerVisible, setCategoryPickerVisible] = useState(false);
const inputRef = useRef<TextInput>(null);
const keyboardHeight = useRef(new Animated.Value(0)).current;
@@ -61,14 +61,14 @@ export function QuickAddBar({ dueDate = 0, placeholder }: QuickAddBarProps) {
const handleAdd = async () => {
const trimmed = title.trim();
if (!trimmed || !categoryId) return;
if (!trimmed) return;
const now = new Date();
await database.write(async () => {
await collections.tasks.create((t) => {
t.title = trimmed;
t.description = '';
t.categoryId = categoryId;
t.categoryId = categoryId || '';
t.priority = 'none';
t.completed = false;
t.dueDate = dueDate;
@@ -126,7 +126,7 @@ export function QuickAddBar({ dueDate = 0, placeholder }: QuickAddBarProps) {
<Svg width={20} height={20} viewBox="0 0 24 24">
<Path
d="M12 5v14M5 12h14"
stroke="#FFFFFF"
stroke={theme.accentText}
strokeWidth={2.5}
strokeLinecap="round"
strokeLinejoin="round"
@@ -138,7 +138,7 @@ export function QuickAddBar({ dueDate = 0, placeholder }: QuickAddBarProps) {
<OptionPickerModal
visible={categoryPickerVisible}
title="Select Category"
options={categories.map((c) => ({ value: c.id, label: c.name, color: c.color }))}
options={[{ value: '', label: 'None', color: '#9E9E9E' }, ...categories.map((c) => ({ value: c.id, label: c.name, color: c.color }))]}
selectedValue={categoryId}
onSelect={(value) => setCategoryId(Array.isArray(value) ? value[0] : value)}
onClose={() => setCategoryPickerVisible(false)}
@@ -282,7 +282,7 @@ export function RepeatSelector({ value, interval, days, onChange }: RepeatSelect
disabled={!profileName.trim()}
activeOpacity={0.8}
>
<Text style={[styles.modalButtonText, { color: '#FFFFFF', fontWeight: '600' }]}>Save</Text>
<Text style={[styles.modalButtonText, { color: theme.accentText, fontWeight: '600' }]}>Save</Text>
</TouchableOpacity>
</View>
</Pressable>
@@ -80,7 +80,7 @@ export function ServerUrlModal({ visible, onClose }: ServerUrlModalProps) {
disabled={!isValid}
activeOpacity={0.8}
>
<Text style={styles.saveButtonText}>Save</Text>
<Text style={[styles.saveButtonText, { color: theme.accentText }]}>Save</Text>
</TouchableOpacity>
</View>
</View>
@@ -160,6 +160,5 @@ const styles = StyleSheet.create({
saveButtonText: {
fontSize: 15,
fontWeight: '600',
color: '#FFFFFF',
},
});
+5 -6
View File
@@ -152,9 +152,9 @@ export function SyncModal({ visible, onClose }: SyncModalProps) {
activeOpacity={0.8}
>
{status === 'syncing' ? (
<ActivityIndicator color="#FFFFFF" />
<ActivityIndicator color={theme.accentText} />
) : (
<Text style={styles.primaryButtonText}>Sync Now</Text>
<Text style={[styles.primaryButtonText, { color: theme.accentText }]}>Sync Now</Text>
)}
</TouchableOpacity>
@@ -202,7 +202,7 @@ export function SyncModal({ visible, onClose }: SyncModalProps) {
<Text
style={[
styles.segmentText,
{ color: mode === m ? '#FFFFFF' : theme.textSecondary },
{ color: mode === m ? theme.accentText : theme.textSecondary },
]}
>
{m === 'login' ? 'Sign In' : 'Create Account'}
@@ -243,9 +243,9 @@ export function SyncModal({ visible, onClose }: SyncModalProps) {
activeOpacity={0.8}
>
{busy ? (
<ActivityIndicator color="#FFFFFF" />
<ActivityIndicator color={theme.accentText} />
) : (
<Text style={styles.primaryButtonText}>
<Text style={[styles.primaryButtonText, { color: theme.accentText }]}>
{mode === 'login' ? 'Sign In' : 'Create Account'}
</Text>
)}
@@ -349,7 +349,6 @@ const styles = StyleSheet.create({
minHeight: 50,
},
primaryButtonText: {
color: '#FFFFFF',
fontSize: 15,
fontWeight: '700',
},
@@ -121,7 +121,7 @@ export default function SyncStatus({ compact = false }: SyncStatusProps) {
disabled={status === 'syncing'}
activeOpacity={0.8}
>
<Text style={[styles.syncButtonText, { color: status === 'syncing' ? theme.accent : '#FFFFFF' }]}>
<Text style={[styles.syncButtonText, { color: status === 'syncing' ? theme.accent : theme.accentText }]}>
{status === 'syncing' ? 'Syncing...' : 'Sync Now'}
</Text>
</TouchableOpacity>
+6 -5
View File
@@ -36,8 +36,8 @@ interface TaskItemProps {
onDragUpdate?: (absoluteY: number) => void;
onDragEnd?: (absoluteY: number) => void;
onReorderStart?: () => void;
onReorderUpdate?: (translationY: number) => void;
onReorderEnd?: (translationY: number) => void;
onReorderUpdate?: (absoluteY: number) => void;
onReorderEnd?: (absoluteY: number, translationY: number) => void;
expanded?: boolean;
indented?: boolean;
depth?: number;
@@ -66,7 +66,7 @@ export const TaskItem = React.memo(function TaskItem({ task, onToggle, onDelete,
const reorderGesture = React.useMemo(
() =>
Gesture.Pan()
.activateAfterLongPress(0)
.activateAfterLongPress(400)
.minDistance(5)
.runOnJS(true)
.onStart((e) => {
@@ -77,10 +77,10 @@ export const TaskItem = React.memo(function TaskItem({ task, onToggle, onDelete,
.onUpdate((e) => {
dragTranslateX.setValue(e.translationX);
dragTranslateY.setValue(e.translationY);
onReorderUpdate?.(e.translationY);
onReorderUpdate?.(e.absoluteY);
})
.onEnd((e) => {
onReorderEnd?.(e.translationY);
onReorderEnd?.(e.absoluteY, e.translationY);
})
.onFinalize(() => {
setDragging(false);
@@ -95,6 +95,7 @@ export const TaskItem = React.memo(function TaskItem({ task, onToggle, onDelete,
Gesture.Pan()
.activateAfterLongPress(400)
.minDistance(2)
.maxDistance(12)
.runOnJS(true)
.onStart(() => {
setDragging(true);
+128 -173
View File
@@ -1,4 +1,4 @@
import React, { useState, useCallback, useMemo, useRef } from 'react';
import React, { useState, useCallback, useMemo, useRef, useEffect } from 'react';
import { View, Text, StyleSheet, Animated, RefreshControl, Alert, TouchableOpacity } from 'react-native';
import { useTasks } from '@/hooks/useTasks';
import { useTaskModals } from '@/hooks/useTaskModals';
@@ -6,7 +6,6 @@ import { TaskItem } from './TaskItem';
import { SubtaskItem } from './SubtaskItem';
import { TaskData, SubtaskData } from '@/types';
import Task from '@/models/Task';
import { useDatabase } from '@/hooks/useDatabase';
import { useSettings } from '@/theme';
import {
toggleTaskComplete,
@@ -16,9 +15,8 @@ import {
convertSubtaskToTask,
moveSubtaskToTask,
toggleSubtaskComplete,
reorderTasks,
fetchSubtaskTree,
} from '@/utils/taskActions';
import { Q } from '@nozbe/watermelondb';
import Svg, { Path, Rect } from 'react-native-svg';
interface TaskListProps {
@@ -41,7 +39,6 @@ const DropIndicator = ({ theme }: { theme: any }) => (
export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProps) {
const { theme, sortBy } = useSettings();
const { collections } = useDatabase();
const { tasks, loading } = useTasks(categoryId, false);
const { tasks: completedTasks } = useTasks(categoryId, true);
@@ -51,8 +48,8 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [hoverTaskId, setHoverTaskId] = useState<string | null>(null);
const [expandedTasks, setExpandedTasks] = useState<Set<string>>(new Set());
const [completedShown, setCompletedShown] = useState(false);
const [subtasksMap, setSubtasksMap] = useState<Map<string, SubtaskData[]>>(new Map());
const [reorderState, setReorderState] = useState<{ draggedId: string; draggedIndex: number; targetIndex: number | null; positions: Record<string, { top: number; bottom: number }> } | null>(null);
const [dropIndicator, setDropIndicator] = useState<{ targetId: string | null; position: 'above' | 'below' } | null>(null);
const itemRefs = useRef<Map<string, View>>(new Map());
const dragStateRef = useRef<{ taskId: string; positions: Record<string, { top: number; bottom: number }> } | null>(null);
@@ -127,70 +124,10 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
}, [onSelectionChange]);
const fetchSubtasks = useCallback(async (taskId: string) => {
const subs = await collections.subtasks.query(Q.where('task_id', taskId), Q.where('parent_subtask_id', null)).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: [],
}));
// Fetch nested subtasks for each subtask
const fetchNested = async (subtaskId: string): Promise<SubtaskData[]> => {
const nested = await collections.subtasks.query(Q.where('parent_subtask_id', subtaskId)).fetch();
return nested.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: [],
}));
};
// Recursively fetch all nested subtasks
const buildNested = async (subtasks: SubtaskData[]): Promise<SubtaskData[]> => {
for (const sub of subtasks) {
const children = await fetchNested(sub.id);
if (children.length > 0) {
sub.subtasks = await buildNested(children);
}
}
return subtasks;
};
const withNested = await buildNested(mapped);
const withNested = await fetchSubtaskTree(taskId);
setSubtasksMap((prev) => new Map(prev).set(taskId, withNested));
return withNested;
}, [collections.subtasks]);
}, []);
const refreshAll = useCallback(() => {
for (const taskId of expandedTasks) {
@@ -198,8 +135,9 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
}
}, [expandedTasks, fetchSubtasks]);
const handleToggle = useCallback(async (taskId: string) => {
const handleToggle = useCallback(async (taskId: string, showCompleted = true) => {
await toggleTaskComplete(taskId);
if (showCompleted) setCompletedShown(true);
refreshAll();
}, [refreshAll]);
@@ -248,6 +186,7 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
const handleBulkComplete = useCallback(async () => {
await Promise.all(Array.from(selectedIds).map(taskId => setTaskCompleted(taskId, true)));
setCompletedShown(true);
exitSelection();
refreshAll();
}, [selectedIds, exitSelection, refreshAll]);
@@ -323,89 +262,17 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
}
}, [findHoverTarget, refreshAll]);
const measureReorderItems = useCallback(async () => {
const positions: Record<string, { top: number; bottom: number }> = {};
const entries = Array.from(itemRefs.current.entries());
await Promise.all(entries.map(([id, ref]) => {
return new Promise<void>((resolve) => {
ref?.measureInWindow((_x, y, _w, h) => {
positions[id] = { top: y, bottom: y + h };
resolve();
});
});
}));
return positions;
}, []);
const findReorderTarget = useCallback((absoluteY: number, draggedId: string, positions: Record<string, { top: number; bottom: number }>) => {
for (const [id, p] of Object.entries(positions)) {
if (id === draggedId) continue;
if (absoluteY >= p.top && absoluteY <= p.bottom) {
return id;
}
}
return null;
}, []);
const calculateReorderDropPosition = useCallback((absoluteY: number, targetId: string, positions: Record<string, { top: number; bottom: number }>) => {
const target = positions[targetId];
if (!target) return 'below' as const;
const middle = (target.top + target.bottom) / 2;
return absoluteY < middle ? 'above' : 'below';
}, []);
const handleReorderStart = useCallback(async (taskId: string) => {
const positions = await measureReorderItems();
const draggedIndex = sortedTasks.findIndex(t => t.id === taskId);
if (draggedIndex === -1) return;
setReorderState({ draggedId: taskId, draggedIndex, targetIndex: null, positions });
}, [measureReorderItems, sortedTasks]);
const handleReorderUpdate = useCallback((absoluteY: number) => {
const state = reorderState;
if (!state) return;
const targetId = findReorderTarget(absoluteY, state.draggedId, state.positions);
let targetIndex = null;
if (targetId) {
targetIndex = sortedTasks.findIndex(t => t.id === targetId);
const position = calculateReorderDropPosition(absoluteY, targetId, state.positions);
setDropIndicator({ targetId, position });
} else {
// Check if below last item
const positions = state.positions;
const lastItem = Object.values(positions).reduce((max, p) => p.bottom > max.bottom ? p : max, { bottom: 0 });
if (absoluteY > lastItem.bottom) {
setDropIndicator({ targetId: null, position: 'below' });
targetIndex = sortedTasks.length; // Insert at end
} else {
setDropIndicator(null);
}
}
setReorderState(prev => prev ? { ...prev, targetIndex } : null);
setHoverTaskId(targetId);
}, [findReorderTarget, calculateReorderDropPosition, reorderState, sortedTasks]);
const handleReorderEnd = useCallback(async (translationY: number) => {
const state = reorderState;
setReorderState(null);
setHoverTaskId(null);
setDropIndicator(null);
if (!state) return;
if (state.targetIndex !== null && state.targetIndex !== state.draggedIndex) {
const newOrder = [...sortedTasks];
const [removed] = newOrder.splice(state.draggedIndex, 1);
newOrder.splice(state.targetIndex, 0, removed);
const newTaskIds = newOrder.map(t => t.id);
await reorderTasks(newTaskIds);
refreshAll();
}
}, [reorderState, sortedTasks, reorderTasks, refreshAll]);
const handleSubtaskDragStart = useCallback(async (subtaskId: string, parentTaskId: string) => {
subtaskDragRef.current = { subtaskId, parentTaskId, positions: await measureItems() };
}, [measureItems]);
const handleSubtaskDragUpdate = useCallback((absoluteY: number) => {
const state = subtaskDragRef.current;
if (!state) return;
const target = findHoverTarget(absoluteY, state.subtaskId, state.positions);
setHoverTaskId((prev) => (prev === target ? prev : target));
}, [findHoverTarget]);
const handleSubtaskDragEnd = useCallback(async (absoluteY: number) => {
const state = subtaskDragRef.current;
subtaskDragRef.current = null;
@@ -457,8 +324,9 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
onDragUpdate={handleDragUpdate}
onDragEnd={handleDragEnd}
onSubtaskDragStart={handleSubtaskDragStart}
onSubtaskDragUpdate={handleSubtaskDragUpdate}
onSubtaskDragEnd={handleSubtaskDragEnd}
onReorderStart={handleDragStart}
onReorderStart={() => handleDragStart(item.id)}
onReorderUpdate={handleDragUpdate}
onReorderEnd={handleDragEnd}
selectedIds={selectedIds}
@@ -490,6 +358,7 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
handleDragUpdate,
handleDragEnd,
handleSubtaskDragStart,
handleSubtaskDragUpdate,
handleSubtaskDragEnd,
]
);
@@ -508,13 +377,21 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
const footerContent = completedTasks.length === 0 ? null : (
<CompletedSection
tasks={completedTasks}
onToggle={(task) => handleToggle(task.id)}
shown={completedShown}
onShownChange={setCompletedShown}
onToggle={(task) => handleToggle(task.id, false)}
onDelete={openTaskDelete}
onMenuOpen={openTaskMenu}
onLongPress={(task) => enterSelection(task.id)}
selectionMode={selectionMode}
selectedIds={selectedIds}
onSelect={toggleSelect}
onFetchSubtasks={fetchSubtasks}
subtasksMap={subtasksMap}
onSubtaskToggle={handleSubtaskToggle}
onSubtaskDelete={openSubtaskDelete}
onSubtaskEdit={openSubtaskEdit}
onSubtaskMenuOpen={openSubtaskMenu}
/>
);
@@ -528,6 +405,7 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
);
}, [
completedTasks,
completedShown,
handleToggle,
openTaskDelete,
openTaskMenu,
@@ -535,6 +413,12 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
selectionMode,
selectedIds,
toggleSelect,
fetchSubtasks,
subtasksMap,
handleSubtaskToggle,
openSubtaskDelete,
openSubtaskEdit,
openSubtaskMenu,
dropIndicator,
theme,
]);
@@ -624,10 +508,11 @@ interface TaskRowProps {
onDragUpdate: (absoluteY: number) => void;
onDragEnd: (absoluteY: number) => void;
onSubtaskDragStart: (subtaskId: string, parentTaskId: string) => void;
onSubtaskDragUpdate: (absoluteY: number) => void;
onSubtaskDragEnd: (absoluteY: number) => void;
onReorderStart: (taskId: string) => void;
onReorderStart: () => void;
onReorderUpdate: (absoluteY: number) => void;
onReorderEnd: (translationY: number) => void;
onReorderEnd: (absoluteY: number, translationY: number) => void;
selectedIds: Set<string>;
}
@@ -653,6 +538,7 @@ const TaskRow = React.memo(function TaskRow({
onDragUpdate,
onDragEnd,
onSubtaskDragStart,
onSubtaskDragUpdate,
onSubtaskDragEnd,
onReorderStart,
onReorderUpdate,
@@ -683,7 +569,7 @@ const TaskRow = React.memo(function TaskRow({
onDragStart={() => onDragStart(task.id)}
onDragUpdate={onDragUpdate}
onDragEnd={onDragEnd}
onReorderStart={() => onReorderStart(task.id)}
onReorderStart={onReorderStart}
onReorderUpdate={onReorderUpdate}
onReorderEnd={onReorderEnd}
/>
@@ -701,7 +587,7 @@ const TaskRow = React.memo(function TaskRow({
selectionMode={selectionMode}
draggable
onDragStart={() => onSubtaskDragStart(sub.id, task.id)}
onDragUpdate={onDragUpdate}
onDragUpdate={onSubtaskDragUpdate}
onDragEnd={onSubtaskDragEnd}
/>
))}
@@ -713,6 +599,8 @@ const TaskRow = React.memo(function TaskRow({
interface CompletedSectionProps {
tasks: TaskData[];
shown: boolean;
onShownChange: (shown: boolean) => void;
onToggle: (task: TaskData) => void;
onDelete: (task: TaskData) => void;
onMenuOpen: (task: TaskData) => void;
@@ -720,41 +608,102 @@ interface CompletedSectionProps {
selectionMode: boolean;
selectedIds: Set<string>;
onSelect: (taskId: string) => void;
onFetchSubtasks: (taskId: string) => Promise<SubtaskData[]>;
subtasksMap: Map<string, SubtaskData[]>;
onSubtaskToggle: (subtaskId: string, taskId: string) => void;
onSubtaskDelete: (subtask: SubtaskData) => void;
onSubtaskEdit: (subtaskId: string) => void;
onSubtaskMenuOpen: (subtask: SubtaskData) => void;
}
const CompletedSection = React.memo(function CompletedSection({ tasks, onToggle, onDelete, onMenuOpen, onLongPress, selectionMode, selectedIds, onSelect }: CompletedSectionProps) {
const CompletedSection = React.memo(function CompletedSection({ tasks, shown, onShownChange, onToggle, onDelete, onMenuOpen, onLongPress, selectionMode, selectedIds, onSelect, onFetchSubtasks, subtasksMap, onSubtaskToggle, onSubtaskDelete, onSubtaskEdit, onSubtaskMenuOpen }: CompletedSectionProps) {
const { theme } = useSettings();
const [expanded, setExpanded] = useState(false);
const [openTasks, setOpenTasks] = useState<Set<string>>(new Set(tasks.map((t) => t.id)));
const fetchedRef = useRef<Set<string>>(new Set());
useEffect(() => {
setOpenTasks((prev) => {
const next = new Set(prev);
let changed = false;
for (const t of tasks) {
if (!next.has(t.id)) {
next.add(t.id);
changed = true;
}
}
return changed ? next : prev;
});
for (const t of tasks) {
if (!fetchedRef.current.has(t.id)) {
fetchedRef.current.add(t.id);
onFetchSubtasks(t.id);
}
}
}, [tasks, onFetchSubtasks]);
const toggleOpen = useCallback((taskId: string) => {
setOpenTasks((prev) => {
const next = new Set(prev);
if (next.has(taskId)) {
next.delete(taskId);
} else {
next.add(taskId);
}
return next;
});
}, []);
return (
<View style={styles.completedSection}>
<TouchableOpacity
style={styles.completedHeader}
onPress={() => setExpanded(!expanded)}
onPress={() => onShownChange(!shown)}
>
<Text style={[styles.completedTitle, { color: theme.textFaint }]}>
Completed ({tasks.length})
</Text>
<Text style={[styles.completedToggle, { color: theme.accent }]}>
{expanded ? 'Hide' : 'Show'}
{shown ? 'Hide' : 'Show'}
</Text>
</TouchableOpacity>
{expanded && (
{shown && (
<View style={styles.completedList}>
{tasks.map((task) => (
<TaskItem
key={task.id}
task={task}
onToggle={() => onToggle(task)}
onDelete={() => onDelete(task)}
onPress={() => {}}
onLongPress={() => onLongPress(task)}
onMenuOpen={() => onMenuOpen(task)}
selected={selectedIds.has(task.id)}
selectionMode={selectionMode}
completedSection
/>
))}
{tasks.map((task) => {
const isOpen = openTasks.has(task.id);
const subtasks = (subtasksMap.get(task.id) ?? [])
.slice()
.sort((a, b) => a.order - b.order);
return (
<View key={task.id}>
<TaskItem
task={task}
onToggle={() => onToggle(task)}
onDelete={() => onDelete(task)}
onPress={() => (selectionMode ? onSelect(task.id) : toggleOpen(task.id))}
onLongPress={() => onLongPress(task)}
onMenuOpen={() => onMenuOpen(task)}
selected={selectedIds.has(task.id)}
selectionMode={selectionMode}
completedSection
expanded={isOpen}
/>
{isOpen && subtasks.length > 0 && (
<View style={styles.completedSubtasks}>
{subtasks.map((sub) => (
<SubtaskItem
key={sub.id}
subtask={sub}
onToggle={() => onSubtaskToggle(sub.id, task.id)}
onDelete={() => onSubtaskDelete(sub)}
onPress={() => onSubtaskEdit(sub.id)}
onMenuOpen={() => onSubtaskMenuOpen(sub)}
/>
))}
</View>
)}
</View>
);
})}
</View>
)}
</View>
@@ -786,6 +735,12 @@ const styles = StyleSheet.create({
paddingRight: 4,
paddingTop: 8,
},
completedSubtasks: {
paddingLeft: 8,
paddingRight: 4,
paddingTop: 4,
marginBottom: 4,
},
dragContainer: {
},
dropIndicatorContainer: {
+2 -1
View File
@@ -1,7 +1,7 @@
import { appSchema, tableSchema } from '@nozbe/watermelondb';
export const schema = appSchema({
version: 15,
version: 16,
tables: [
tableSchema({
name: 'categories',
@@ -31,6 +31,7 @@ export const schema = appSchema({
{ name: 'repeat_days', type: 'string' },
{ name: 'color', type: 'string' },
{ name: 'series_id', type: 'string', isIndexed: true },
{ name: 'order', type: 'number', isOptional: true },
{ name: 'reminder', type: 'string' },
{ name: 'reminders', type: 'string' },
{ name: 'assignee_id', type: 'string', isOptional: true },
+1 -1
View File
@@ -304,7 +304,7 @@ export function useTaskModals() {
<OptionPickerModal
visible={picker?.type === 'category'}
title="Change Category"
options={categories.map((c) => ({ value: c.id, label: c.name, color: c.color }))}
options={[{ value: '', label: 'None', color: '#9E9E9E' }, ...categories.map((c) => ({ value: c.id, label: c.name, color: c.color }))]}
selectedValue={menuTask?.categoryId}
onSelect={handleSingleCategory}
onClose={() => setPicker(null)}
+107
View File
@@ -2,6 +2,61 @@ import { useDatabase } from './useDatabase';
import { Q } from '@nozbe/watermelondb';
import { useEffect, useState, useMemo } from 'react';
import Task from '../models/Task';
import { startOfMonth, endOfMonth } from 'date-fns';
export function useTasksInMonth(monthDate: Date) {
const { collections } = useDatabase();
const [tasks, setTasks] = useState<Task[]>([]);
const [loading, setLoading] = useState(true);
const range = useMemo(() => {
const s = startOfMonth(monthDate);
s.setHours(0, 0, 0, 0);
const e = endOfMonth(monthDate);
e.setHours(23, 59, 59, 999);
return { start: s.getTime(), end: e.getTime() };
}, [monthDate]);
useEffect(() => {
let mounted = true;
setLoading(true);
const subscription = collections.tasks
.query(
Q.where('due_date', Q.between(range.start, range.end)),
Q.sortBy('due_date', 'asc')
)
.observe()
.subscribe({
next: (result) => {
if (mounted) {
setTasks(result);
setLoading(false);
}
},
error: () => {
if (mounted) setLoading(false);
},
});
return () => {
mounted = false;
subscription.unsubscribe();
};
}, [collections, range.start, range.end]);
const byDay = useMemo(() => {
const map: Record<number, Task[]> = {};
for (const t of tasks) {
const key = new Date(t.dueDate).getDate();
const bucket = map[key] ?? [];
bucket.push(t);
map[key] = bucket;
}
return map;
}, [tasks]);
return { tasks, byDay, loading };
}
export function useTasks(categoryId?: string, showCompleted = false) {
const { collections } = useDatabase();
@@ -93,3 +148,55 @@ export function useTasksByDate(date: Date) {
return { tasks, loading };
}
export function useTasksInMonth(month: Date) {
const { collections } = useDatabase();
const [tasks, setTasks] = useState<Task[]>([]);
const [loading, setLoading] = useState(true);
const range = useMemo(() => {
const start = startOfMonth(month);
start.setHours(0, 0, 0, 0);
const end = endOfMonth(month);
end.setHours(23, 59, 59, 999);
return { start: start.getTime(), end: end.getTime() };
}, [month]);
useEffect(() => {
let mounted = true;
const subscription = collections.tasks
.query(
Q.where('due_date', Q.between(range.start, range.end)),
Q.sortBy('due_date', 'asc')
)
.observe()
.subscribe({
next: (result) => {
if (mounted) {
setTasks(result);
setLoading(false);
}
},
error: () => {
if (mounted) setLoading(false);
},
});
return () => {
mounted = false;
subscription.unsubscribe();
};
}, [collections, range]);
const byDay = useMemo(() => {
const map: Record<number, Task[]> = {};
for (const task of tasks) {
const day = new Date(task.dueDate).getDate();
if (!map[day]) map[day] = [];
map[day].push(task);
}
return map;
}, [tasks]);
return { tasks, byDay, loading };
}
+74 -24
View File
@@ -36,30 +36,72 @@ export interface ThemeColors {
accent: string;
accentSoft: string;
accentBorder: string;
accentText: string;
inputBg: string;
overlay: string;
sheetBg: string;
tabBarBg: string;
}
export const colors: ThemeColors = {
background: '#121212',
card: '#1E1E1E',
cardAlt: '#262626',
border: '#2A2A2A',
borderStrong: '#3A3A3A',
text: '#F5F5F5',
textSecondary: '#E0E0E0',
textFaint: '#BDBDBD',
textMuted: '#8E8E8E',
accent: '#EF5350',
accentSoft: '#2A1D1D',
accentBorder: '#4A2B2B',
inputBg: '#1A1A1A',
overlay: 'rgba(0,0,0,0.6)',
sheetBg: '#242424',
tabBarBg: '#1A1A1A',
};
export const DEFAULT_ACCENT = '#EF5350';
export const ACCENT_PRESETS: string[] = [
'#EF5350',
'#E91E63',
'#AB47BC',
'#7E57C2',
'#5C6BC0',
'#29B6F6',
'#26A69A',
'#66BB6A',
'#FFCA28',
'#FF7043',
'#8D6E63',
];
function parseHex(hex: string): [number, number, number] {
const h = hex.replace(/^#/, '');
return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)];
}
function toHexByte(n: number): string {
return Math.max(0, Math.min(255, Math.round(n))).toString(16).padStart(2, '0');
}
export function mixHex(color: string, target: string, ratio: number): string {
const [r1, g1, b1] = parseHex(color);
const [r2, g2, b2] = parseHex(target);
return `#${toHexByte(r1 + (r2 - r1) * ratio)}${toHexByte(g1 + (g2 - g1) * ratio)}${toHexByte(b1 + (b2 - b1) * ratio)}`.toUpperCase();
}
function accentTextColor(accent: string): string {
const [r, g, b] = parseHex(accent).map((v) => v / 255);
const linear = (v: number) => (v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4));
const luminance = 0.2126 * linear(r) + 0.7152 * linear(g) + 0.0722 * linear(b);
return luminance > 0.5 ? '#111111' : '#FFFFFF';
}
export function colors(accent: string = DEFAULT_ACCENT): ThemeColors {
return {
background: '#121212',
card: '#1E1E1E',
cardAlt: '#262626',
border: '#2A2A2A',
borderStrong: '#3A3A3A',
text: '#F5F5F5',
textSecondary: '#E0E0E0',
textFaint: '#BDBDBD',
textMuted: '#8E8E8E',
accent,
accentSoft: mixHex(accent, '#121212', 0.12),
accentBorder: mixHex(accent, '#121212', 0.25),
accentText: accentTextColor(accent),
inputBg: '#1A1A1A',
overlay: 'rgba(0,0,0,0.6)',
sheetBg: '#242424',
tabBarBg: '#1A1A1A',
};
}
interface SettingsContextType {
notifications: boolean;
@@ -72,6 +114,8 @@ interface SettingsContextType {
setReminderPreference: (value: ReminderPreference) => void;
apiUrl: string;
setApiUrl: (value: string) => void;
accentColor: string;
setAccentColor: (value: string) => void;
theme: ThemeColors;
}
@@ -80,6 +124,7 @@ const STORAGE_KEYS = {
defaultCategoryId: 'settings:defaultCategoryId',
sortBy: 'settings:sortBy',
reminderPreference: 'settings:reminderPreference',
accentColor: 'settings:accentColor',
};
const SettingsContext = createContext<SettingsContextType | null>(null);
@@ -119,10 +164,11 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
'15m',
);
const [apiUrl, setApiUrl] = useStoredSetting<string>(API_URL_KEY, DEFAULT_API_BASE_URL);
const [accentColor, setAccentColor] = useStoredSetting<string>(STORAGE_KEYS.accentColor, DEFAULT_ACCENT);
const theme = colors;
const theme = useMemo(() => colors(accentColor), [accentColor]);
const value = useMemo<SettingsContextType>(
const value = useMemo<SettingsContextType>(
() => ({
notifications,
setNotifications,
@@ -132,10 +178,12 @@ const value = useMemo<SettingsContextType>(
setSortBy,
reminderPreference,
setReminderPreference,
apiUrl,
setApiUrl,
theme,
}),
apiUrl,
setApiUrl,
accentColor,
setAccentColor,
theme,
}),
[
notifications,
setNotifications,
@@ -147,6 +195,8 @@ apiUrl,
setReminderPreference,
apiUrl,
setApiUrl,
accentColor,
setAccentColor,
theme,
]
);
+4 -6
View File
@@ -34,12 +34,10 @@ export async function deleteCategory(categoryId: string): Promise<void> {
const tasks = await collections.tasks.query(Q.where('category_id', categoryId)).fetch();
for (const task of tasks) {
if (fallback) {
await task.update((t) => {
t.categoryId = fallback.id;
t.updatedAt = new Date();
});
}
await task.update((t) => {
t.categoryId = fallback?.id ?? '';
t.updatedAt = new Date();
});
}
const category = await collections.categories.find(categoryId);
+42 -1
View File
@@ -1,8 +1,49 @@
import { database, collections } from '@/database';
import { Q } from '@nozbe/watermelondb';
import { Priority, Repeat, Reminder } from '@/types';
import { Priority, Repeat, Reminder, SubtaskData } from '@/types';
import { scheduleTaskReminder, cancelTaskReminder } from '@/services/notifications';
function mapSubtaskRow(s: any, taskId?: string): SubtaskData {
return {
id: s.id,
taskId: s.taskId ?? taskId ?? '',
parentSubtaskId: s.parentSubtaskId || null,
title: s.title,
description: s.description || '',
priority: (s.priority || 'none') as Priority,
completed: s.completed,
dueDate: s.dueDate || 0,
dueTime: s.dueTime || '',
endTime: s.endTime || '',
allDay: s.allDay ?? false,
repeat: (s.repeat || 'none') as Repeat,
repeatInterval: s.repeatInterval ?? 1,
repeatDays: s.repeatDays || '',
seriesId: s.seriesId || '',
reminder: (s.reminder || 'none') as Reminder,
assigneeId: s.assigneeId ?? null,
order: s.order,
subtasks: [],
};
}
export async function fetchSubtaskTree(taskId: string): Promise<SubtaskData[]> {
const topLevel = await collections.subtasks.query(Q.where('task_id', taskId), Q.where('parent_subtask_id', null)).fetch();
const items: SubtaskData[] = topLevel.map((s: any) => mapSubtaskRow(s, taskId));
const decorate = async (subtasks: SubtaskData[]): Promise<SubtaskData[]> => {
for (const sub of subtasks) {
const children = await collections.subtasks.query(Q.where('parent_subtask_id', sub.id)).fetch();
if (children.length > 0) {
sub.subtasks = await decorate(children.map((c: any) => mapSubtaskRow(c, taskId)));
}
}
return subtasks;
};
return decorate(items);
}
function addDays(date: Date, days: number): Date {
const next = new Date(date);
next.setDate(next.getDate() + days);