feat(app): subtask categories, live refresh on save, nested subtask fixes
This commit is contained in:
@@ -1,19 +1,18 @@
|
||||
import React, { useMemo, useRef, useState, useCallback, useEffect } from 'react';
|
||||
import React, { useMemo, useRef, useState, useCallback } from 'react';
|
||||
import { View, Text, StyleSheet, SafeAreaView, ScrollView, TouchableOpacity, Animated, Dimensions, BackHandler, 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, useDatabase } from '@/hooks/useDatabase';
|
||||
import { useCategories } from '@/hooks/useDatabase';
|
||||
import { toggleTaskComplete } from '@/utils/taskActions';
|
||||
import { QuickAddBar } from '@/components/QuickAddBar';
|
||||
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 { desaturate } from '@/theme';
|
||||
import type { ThemeColors } from '@/theme';
|
||||
@@ -28,7 +27,6 @@ const MONTH_NAMES = [
|
||||
export default function CalendarScreen() {
|
||||
const router = useRouter();
|
||||
const { theme } = useSettings();
|
||||
const { collections } = useDatabase();
|
||||
const categories = useCategories();
|
||||
const { modals, openTaskEdit } = useTaskModals();
|
||||
|
||||
@@ -36,27 +34,31 @@ export default function CalendarScreen() {
|
||||
const [selectedDate, setSelectedDate] = useState(() => new Date());
|
||||
const [monthPickerVisible, setMonthPickerVisible] = useState(false);
|
||||
const [yearPickerVisible, setYearPickerVisible] = useState(false);
|
||||
const [subtasksMap, setSubtasksMap] = useState<Record<string, SubtaskData[]>>({});
|
||||
const { map: subtasksByTask, refresh: refreshSubtasks } = useSubtasks();
|
||||
|
||||
const visibleMonthRef = useRef(visibleMonth);
|
||||
visibleMonthRef.current = visibleMonth;
|
||||
const selectedDateRef = useRef(selectedDate);
|
||||
selectedDateRef.current = selectedDate;
|
||||
|
||||
const { tasks: selectedDayTasks, refresh: refreshDayTasks } = useTasksByDate(selectedDate);
|
||||
const monthTasks = useTasksInMonth(visibleMonth);
|
||||
const refreshMonthTasks = monthTasks.refresh;
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
const sub = BackHandler.addEventListener('hardwareBackPress', () => true);
|
||||
refreshDayTasks();
|
||||
refreshMonthTasks();
|
||||
refreshSubtasks();
|
||||
return () => sub.remove();
|
||||
}, [])
|
||||
}, [refreshDayTasks, refreshMonthTasks, refreshSubtasks])
|
||||
);
|
||||
|
||||
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
|
||||
@@ -67,67 +69,22 @@ export default function CalendarScreen() {
|
||||
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]
|
||||
);
|
||||
|
||||
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, visibleMonthRef.current)) {
|
||||
setVisibleMonth(day);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const transitionTo = useCallback(
|
||||
(dir: 1 | -1) => {
|
||||
(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.setValue(0);
|
||||
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(() => {
|
||||
@@ -138,6 +95,18 @@ export default function CalendarScreen() {
|
||||
[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()
|
||||
@@ -153,10 +122,10 @@ export default function CalendarScreen() {
|
||||
const dx = e.translationX;
|
||||
if (dx <= -w / 4) {
|
||||
translateX.stopAnimation();
|
||||
transitionTo(1);
|
||||
transitionTo(1, false);
|
||||
} else if (dx >= w / 4) {
|
||||
translateX.stopAnimation();
|
||||
transitionTo(-1);
|
||||
transitionTo(-1, false);
|
||||
} else {
|
||||
Animated.spring(translateX, { toValue: 0, useNativeDriver: false }).start();
|
||||
}
|
||||
@@ -184,7 +153,10 @@ export default function CalendarScreen() {
|
||||
|
||||
const handleToggleComplete = useCallback(async (taskId: string) => {
|
||||
await toggleTaskComplete(taskId);
|
||||
}, []);
|
||||
refreshDayTasks();
|
||||
refreshMonthTasks();
|
||||
refreshSubtasks();
|
||||
}, [refreshDayTasks, refreshMonthTasks, refreshSubtasks]);
|
||||
|
||||
return (
|
||||
<SafeAreaView style={[styles.container, { backgroundColor: theme.background }]}>
|
||||
@@ -217,11 +189,12 @@ export default function CalendarScreen() {
|
||||
<Path d="M6 9l6 6 6-6" stroke={theme.textSecondary} strokeWidth={2.5} 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={() => 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 }]}
|
||||
@@ -316,15 +289,10 @@ export default function CalendarScreen() {
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{(subtasksMap[task.id] ?? []).length > 0 && (
|
||||
{(subtasksByTask.get(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}
|
||||
>
|
||||
{(subtasksByTask.get(task.id) ?? []).map((sub) => (
|
||||
<View key={sub.id} style={styles.bulletRow}>
|
||||
<Svg width={5} height={5} viewBox="0 0 6 6" style={styles.bulletDot as any}>
|
||||
<Circle cx={3} cy={3} r={3} fill={theme.textMuted} />
|
||||
</Svg>
|
||||
@@ -334,7 +302,7 @@ export default function CalendarScreen() {
|
||||
>
|
||||
{sub.title}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
@@ -343,7 +311,10 @@ export default function CalendarScreen() {
|
||||
)}
|
||||
</ScrollView>
|
||||
|
||||
<QuickAddBar dueDate={selectedDate.getTime()} placeholder={`Add event for ${format(selectedDate, 'MMM d')}`} />
|
||||
<QuickAddBar
|
||||
dueDate={selectedDate.getTime()}
|
||||
placeholder={`Add event for ${format(selectedDate, 'MMM d')}`}
|
||||
/>
|
||||
</KeyboardAvoidingView>
|
||||
|
||||
{modals(() => {})}
|
||||
@@ -461,7 +432,8 @@ const styles = StyleSheet.create({
|
||||
justifyContent: 'center',
|
||||
},
|
||||
monthSelectorGroup: {
|
||||
alignItems: 'center',
|
||||
alignItems: 'flex-start',
|
||||
flex: 1,
|
||||
},
|
||||
monthButton: {
|
||||
flexDirection: 'row',
|
||||
@@ -476,10 +448,11 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
yearButton: {
|
||||
marginTop: -2,
|
||||
paddingHorizontal: 6,
|
||||
},
|
||||
yearLabel: {
|
||||
fontSize: 13,
|
||||
fontWeight: '500',
|
||||
fontSize: 17,
|
||||
fontWeight: '700',
|
||||
},
|
||||
weekdayRow: {
|
||||
flexDirection: 'row',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { View, Text, StyleSheet, SafeAreaView, BackHandler, KeyboardAvoidingView } from 'react-native';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { View, Text, StyleSheet, SafeAreaView, BackHandler, KeyboardAvoidingView, TouchableOpacity } from 'react-native';
|
||||
import { useFocusEffect } from 'expo-router';
|
||||
import { Header } from '@/components/Header';
|
||||
import { CategoryFilter } from '@/components/CategoryFilter';
|
||||
@@ -7,11 +7,14 @@ import { TaskList } from '@/components/TaskList';
|
||||
import { QuickAddBar } from '@/components/QuickAddBar';
|
||||
import { useDatabase } from '@/hooks/useDatabase';
|
||||
import { useSettings } from '@/theme';
|
||||
import Svg, { Path, Circle } from 'react-native-svg';
|
||||
|
||||
export default function TasksScreen() {
|
||||
const { isReady } = useDatabase();
|
||||
const { theme } = useSettings();
|
||||
const [selectedCategory, setSelectedCategory] = React.useState<string>('all');
|
||||
const { theme, showCompleted, setShowCompleted } = useSettings();
|
||||
const [selectedCategories, setSelectedCategories] = useState<string[]>([]);
|
||||
|
||||
const categoryIds = useMemo(() => selectedCategories, [selectedCategories]);
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
@@ -32,13 +35,41 @@ export default function TasksScreen() {
|
||||
<SafeAreaView style={[styles.container, { backgroundColor: theme.background }]}>
|
||||
<Header title="ToDo" showLogo={false} />
|
||||
<View style={styles.categoryFilterWrapper}>
|
||||
<CategoryFilter selected={selectedCategory} onSelect={setSelectedCategory} />
|
||||
<CategoryFilter selected={selectedCategories} onSelect={setSelectedCategories} />
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.completedToggle,
|
||||
{ backgroundColor: theme.card, borderColor: showCompleted ? theme.accent : theme.borderStrong },
|
||||
]}
|
||||
onPress={() => setShowCompleted(!showCompleted)}
|
||||
activeOpacity={0.8}
|
||||
accessibilityRole="switch"
|
||||
accessibilityLabel="Show completed tasks"
|
||||
accessibilityState={{ checked: showCompleted }}
|
||||
>
|
||||
<Svg width={14} height={14} viewBox="0 0 24 24">
|
||||
<Circle
|
||||
cx={12}
|
||||
cy={12}
|
||||
r={9}
|
||||
stroke={showCompleted ? theme.accent : theme.textMuted}
|
||||
strokeWidth={2}
|
||||
fill="none"
|
||||
/>
|
||||
{showCompleted && (
|
||||
<Path d="M7 12.5l3.5 3.5 6.5-7" stroke={theme.accent} strokeWidth={2.2} strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||
)}
|
||||
</Svg>
|
||||
<Text style={[styles.completedToggleText, { color: showCompleted ? theme.text : theme.textMuted }]}>
|
||||
Completed
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<KeyboardAvoidingView
|
||||
style={styles.kbAvoid}
|
||||
behavior="padding"
|
||||
>
|
||||
<TaskList categoryId={selectedCategory} />
|
||||
<TaskList categoryIds={categoryIds} showCompleted={showCompleted} />
|
||||
<QuickAddBar />
|
||||
</KeyboardAvoidingView>
|
||||
</SafeAreaView>
|
||||
@@ -51,13 +82,25 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
categoryFilterWrapper: {
|
||||
justifyContent: 'center',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingRight: 12,
|
||||
},
|
||||
completedToggle: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 5,
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 8,
|
||||
borderRadius: 18,
|
||||
borderWidth: 1.5,
|
||||
alignSelf: 'center',
|
||||
},
|
||||
completedToggleText: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
},
|
||||
kbAvoid: {
|
||||
flex: 1,
|
||||
},
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
});
|
||||
@@ -22,7 +22,7 @@ import { getLastVisitedTab, tabHref } from '@/utils/tabHistory';
|
||||
|
||||
export default function SettingsScreen() {
|
||||
const router = useRouter();
|
||||
const { theme, notifications, setNotifications, defaultCategoryId, setDefaultCategoryId, sortBy, setSortBy, reminderPreference, setReminderPreference, apiUrl, accentColor, setAccentColor, todoAheadDays, setTodoAheadDays } = useSettings();
|
||||
const { theme, notifications, setNotifications, defaultCategoryId, setDefaultCategoryId, sortBy, setSortBy, reminderPreference, setReminderPreference, apiUrl, accentColor, setAccentColor, todoAheadDays, setTodoAheadDays, showCompleted, setShowCompleted } = useSettings();
|
||||
const categories = useCategories();
|
||||
const [picker, setPicker] = useState<null | 'category' | 'sort' | 'reminder' | 'todoAhead'>(null);
|
||||
const [editingCategory, setEditingCategory] = useState<Category | null | 'new'>(null);
|
||||
@@ -176,6 +176,18 @@ export default function SettingsScreen() {
|
||||
onPress={() => setPicker('sort')}
|
||||
showChevron
|
||||
/>
|
||||
<ListItem
|
||||
title="Show Completed Tasks"
|
||||
subtitle="Hide or show finished tasks in the Todo list"
|
||||
rightElement={
|
||||
<Switch
|
||||
value={showCompleted}
|
||||
onValueChange={setShowCompleted}
|
||||
thumbColor="#FFFFFF"
|
||||
trackColor={{ false: theme.borderStrong, true: theme.accent }}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<ListItem
|
||||
title="Show Calendar Tasks"
|
||||
subtitle={todoAheadDays === 0 ? 'Only today' : `Up to ${todoAheadDays} days ahead`}
|
||||
|
||||
@@ -33,6 +33,7 @@ function RootNavigator() {
|
||||
<Stack.Screen name="(tabs)" />
|
||||
<Stack.Screen name="add-task" />
|
||||
<Stack.Screen name="task-detail" />
|
||||
<Stack.Screen name="day-view" />
|
||||
</Stack>
|
||||
</GestureHandlerRootView>
|
||||
);
|
||||
|
||||
@@ -17,7 +17,7 @@ import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { useDatabase } from '@/hooks/useDatabase';
|
||||
import { database, collections } from '@/database';
|
||||
import { TaskFormData } from '@/types';
|
||||
import { TaskFormData, tagsToString } from '@/types';
|
||||
import { useSettings } from '@/theme';
|
||||
import { scheduleTaskReminder } from '@/services/notifications';
|
||||
import { useFriends } from '@/hooks/useFriends';
|
||||
@@ -26,6 +26,7 @@ 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().optional(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
priority: z.enum(['none', 'low', 'medium', 'high', 'critical']),
|
||||
dueDate: z.date().nullable().optional(),
|
||||
dueTime: z.string().optional(),
|
||||
@@ -60,6 +61,7 @@ export default function AddTaskScreen() {
|
||||
title: '',
|
||||
description: '',
|
||||
categoryId: initialCategory,
|
||||
tags: initialCategory ? [initialCategory] : [],
|
||||
priority: 'none',
|
||||
dueDate: initialDate,
|
||||
dueTime: '',
|
||||
@@ -83,7 +85,7 @@ export default function AddTaskScreen() {
|
||||
formState: { errors },
|
||||
} = methods;
|
||||
|
||||
const categoryId = watch('categoryId');
|
||||
const tags = watch('tags') ?? [];
|
||||
const priority = watch('priority');
|
||||
const repeat = watch('repeat');
|
||||
const repeatInterval = watch('repeatInterval') ?? 1;
|
||||
@@ -94,10 +96,10 @@ export default function AddTaskScreen() {
|
||||
const assigneeId = watch('assigneeId');
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!categoryId && initialCategory) {
|
||||
setValue('categoryId', initialCategory);
|
||||
if (tags.length === 0 && initialCategory) {
|
||||
setValue('tags', [initialCategory]);
|
||||
}
|
||||
}, [initialCategory, categoryId, setValue]);
|
||||
}, [initialCategory, tags, setValue]);
|
||||
|
||||
const onSubmit = async (data: TaskFormData) => {
|
||||
if (!isReady) return;
|
||||
@@ -107,7 +109,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 || '';
|
||||
const resolvedCategoryId = (data.tags && data.tags[0]) || '';
|
||||
|
||||
let createdTask: any = null;
|
||||
|
||||
@@ -116,6 +118,7 @@ export default function AddTaskScreen() {
|
||||
t.title = data.title.trim();
|
||||
t.description = data.description || '';
|
||||
t.categoryId = resolvedCategoryId;
|
||||
t.tags = tagsToString(data.tags || []);
|
||||
t.priority = data.priority;
|
||||
t.completed = false;
|
||||
t.dueDate = dueDateTimestamp;
|
||||
@@ -184,8 +187,8 @@ export default function AddTaskScreen() {
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
<CategorySelector
|
||||
value={categoryId}
|
||||
onChange={(value) => setValue('categoryId', value)}
|
||||
value={tags}
|
||||
onChange={(value) => setValue('tags', value)}
|
||||
error={errors.categoryId?.message}
|
||||
/>
|
||||
<Controller
|
||||
|
||||
@@ -18,12 +18,14 @@ import { collections } from '@/database';
|
||||
import { SubtaskFormData, Reminder, parseReminders, toRemindersString } from '@/types';
|
||||
import { useSettings } from '@/theme';
|
||||
import { updateSubtask, deleteSubtask } from '@/utils/taskActions';
|
||||
import { CategorySelector } from '@/components/CategorySelector';
|
||||
import { useFriends } from '@/hooks/useFriends';
|
||||
import Svg, { Path } from 'react-native-svg';
|
||||
|
||||
const subtaskSchema = z.object({
|
||||
title: z.string().trim().min(1, 'Subtask name is required').max(100),
|
||||
description: z.string().max(1000).optional(),
|
||||
categoryId: z.string().optional(),
|
||||
priority: z.enum(['none', 'low', 'medium', 'high', 'critical']),
|
||||
dueDate: z.date().nullable().optional(),
|
||||
dueTime: z.string().optional(),
|
||||
@@ -51,6 +53,7 @@ export default function SubtaskDetailScreen() {
|
||||
defaultValues: {
|
||||
title: '',
|
||||
description: '',
|
||||
categoryId: '',
|
||||
priority: 'none',
|
||||
dueDate: null,
|
||||
dueTime: '',
|
||||
@@ -68,6 +71,7 @@ export default function SubtaskDetailScreen() {
|
||||
const { handleSubmit, watch, setValue, control, reset, formState: { errors } } = methods;
|
||||
|
||||
const priority = watch('priority');
|
||||
const categoryId = watch('categoryId');
|
||||
const repeat = watch('repeat');
|
||||
const repeatInterval = watch('repeatInterval') ?? 1;
|
||||
const repeatDays = watch('repeatDays') ?? [];
|
||||
@@ -80,13 +84,13 @@ export default function SubtaskDetailScreen() {
|
||||
if (!id || !isReady) return;
|
||||
let mounted = true;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const subtask = await collections.subtasks.find(id);
|
||||
const subscription = collections.subtasks.findAndObserve(id).subscribe({
|
||||
next: (subtask: any) => {
|
||||
if (!mounted) return;
|
||||
reset({
|
||||
title: subtask.title,
|
||||
description: subtask.description,
|
||||
categoryId: subtask.categoryId || '',
|
||||
priority: subtask.priority,
|
||||
dueDate: subtask.dueDate ? new Date(subtask.dueDate) : null,
|
||||
dueTime: subtask.dueTime,
|
||||
@@ -94,18 +98,25 @@ export default function SubtaskDetailScreen() {
|
||||
allDay: subtask.allDay ?? false,
|
||||
repeat: subtask.repeat,
|
||||
repeatInterval: subtask.repeatInterval || 1,
|
||||
repeatDays: (subtask.repeatDays || '').split(',').map(Number).filter((d) => !Number.isNaN(d)),
|
||||
repeatDays: ((subtask.repeatDays || '') as string).split(',').map(Number).filter((d) => !Number.isNaN(d)),
|
||||
reminder: (subtask.reminder || 'none') as Reminder,
|
||||
reminders: subtask.reminders || '',
|
||||
assigneeId: subtask.assigneeId ?? null,
|
||||
});
|
||||
setLoaded(true);
|
||||
} catch {
|
||||
},
|
||||
error: () => {
|
||||
if (mounted) setNotFound(true);
|
||||
}
|
||||
})();
|
||||
},
|
||||
complete: () => {
|
||||
if (mounted) setNotFound(true);
|
||||
},
|
||||
});
|
||||
|
||||
return () => { mounted = false; };
|
||||
return () => {
|
||||
mounted = false;
|
||||
subscription.unsubscribe();
|
||||
};
|
||||
}, [id, isReady, reset]);
|
||||
|
||||
const onSubmit = async (data: SubtaskFormData) => {
|
||||
@@ -114,6 +125,7 @@ export default function SubtaskDetailScreen() {
|
||||
await updateSubtask(id, {
|
||||
title: data.title,
|
||||
description: data.description || '',
|
||||
categoryId: data.categoryId || '',
|
||||
priority: data.priority,
|
||||
dueDate: data.dueDate ? data.dueDate.getTime() : 0,
|
||||
dueTime: data.dueTime || '',
|
||||
@@ -187,6 +199,10 @@ export default function SubtaskDetailScreen() {
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<CategorySelector
|
||||
value={categoryId ? [categoryId] : []}
|
||||
onChange={(value) => setValue('categoryId', value[0] ?? '')}
|
||||
/>
|
||||
<PrioritySelector
|
||||
value={priority}
|
||||
onChange={(value) => setValue('priority', value)}
|
||||
|
||||
@@ -19,10 +19,11 @@ import { z } from 'zod';
|
||||
import { useDatabase } from '@/hooks/useDatabase';
|
||||
import { database, collections } from '@/database';
|
||||
import { Q } from '@nozbe/watermelondb';
|
||||
import { TaskFormData } from '@/types';
|
||||
import { TaskFormData, parseTaskTags, tagsToString } from '@/types';
|
||||
import { useSettings } from '@/theme';
|
||||
import { deleteTaskOccurrences } from '@/utils/taskActions';
|
||||
import { scheduleTaskReminder } from '@/services/notifications';
|
||||
import { recordTombstonesInBatch } from '@/database/tombstones';
|
||||
import { useFriends } from '@/hooks/useFriends';
|
||||
import Svg, { Path, Circle } from 'react-native-svg';
|
||||
|
||||
@@ -30,6 +31,7 @@ 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().optional(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
priority: z.enum(['none', 'low', 'medium', 'high', 'critical']),
|
||||
dueDate: z.date().nullable().optional(),
|
||||
dueTime: z.string().optional(),
|
||||
@@ -86,6 +88,7 @@ export default function TaskDetailScreen() {
|
||||
title: '',
|
||||
description: '',
|
||||
categoryId: '',
|
||||
tags: [],
|
||||
priority: 'none',
|
||||
dueDate: null,
|
||||
dueTime: '',
|
||||
@@ -102,7 +105,7 @@ export default function TaskDetailScreen() {
|
||||
|
||||
const { handleSubmit, watch, setValue, control, reset, formState: { errors } } = methods;
|
||||
|
||||
const categoryId = watch('categoryId');
|
||||
const tags = watch('tags') ?? [];
|
||||
const priority = watch('priority');
|
||||
const repeat = watch('repeat');
|
||||
const repeatInterval = watch('repeatInterval') ?? 1;
|
||||
@@ -127,6 +130,7 @@ export default function TaskDetailScreen() {
|
||||
title: task.title,
|
||||
description: task.description,
|
||||
categoryId: task.categoryId,
|
||||
tags: parseTaskTags(task.tags, task.categoryId),
|
||||
priority: task.priority,
|
||||
dueDate: task.dueDate ? new Date(task.dueDate) : null,
|
||||
dueTime: task.dueTime,
|
||||
@@ -160,14 +164,12 @@ export default function TaskDetailScreen() {
|
||||
const task = await collections.tasks.find(id);
|
||||
savedTask = task;
|
||||
const existingSubtasks = await collections.subtasks.query(Q.where('task_id', id)).fetch();
|
||||
for (const subtask of existingSubtasks) {
|
||||
await subtask.destroyPermanently();
|
||||
}
|
||||
|
||||
await task.update((t) => {
|
||||
t.title = data.title.trim();
|
||||
t.description = data.description || '';
|
||||
t.categoryId = data.categoryId || task.categoryId || '';
|
||||
t.categoryId = (data.tags && data.tags[0]) || task.categoryId || '';
|
||||
t.tags = tagsToString(data.tags || []);
|
||||
t.priority = data.priority;
|
||||
t.dueDate = dueDateTimestamp;
|
||||
t.dueTime = data.dueTime || '';
|
||||
@@ -184,32 +186,64 @@ export default function TaskDetailScreen() {
|
||||
t.updatedAt = now;
|
||||
});
|
||||
|
||||
if (data.subtasks && data.subtasks.length > 0) {
|
||||
for (let i = 0; i < data.subtasks.length; i++) {
|
||||
const subtask = data.subtasks[i];
|
||||
if (subtask.title.trim()) {
|
||||
await collections.subtasks.create((s) => {
|
||||
s.taskId = task.id;
|
||||
s.title = subtask.title.trim();
|
||||
s.description = '';
|
||||
s.priority = 'none';
|
||||
s.completed = false;
|
||||
s.dueDate = 0;
|
||||
s.dueTime = '';
|
||||
s.endTime = '';
|
||||
s.allDay = false;
|
||||
s.repeat = 'none';
|
||||
s.repeatInterval = 1;
|
||||
s.repeatDays = '';
|
||||
s.seriesId = '';
|
||||
s.reminder = 'none';
|
||||
s.assigneeId = null;
|
||||
s.order = i;
|
||||
s.createdAt = now;
|
||||
const keptIds = new Set<string>();
|
||||
let order = 0;
|
||||
for (const formItem of data.subtasks ?? []) {
|
||||
const trimmed = formItem.title.trim();
|
||||
if (!trimmed) continue;
|
||||
if (formItem._key) {
|
||||
const existing = existingSubtasks.find((s) => s.id === formItem._key && !s.parentSubtaskId);
|
||||
if (existing) {
|
||||
keptIds.add(existing.id);
|
||||
await existing.update((s) => {
|
||||
s.title = trimmed;
|
||||
s.order = order;
|
||||
s.updatedAt = now;
|
||||
});
|
||||
order++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
await collections.subtasks.create((s) => {
|
||||
s.taskId = task.id;
|
||||
s.categoryId = task.categoryId || '';
|
||||
s.title = trimmed;
|
||||
s.description = '';
|
||||
s.priority = 'none';
|
||||
s.completed = false;
|
||||
s.dueDate = 0;
|
||||
s.dueTime = '';
|
||||
s.endTime = '';
|
||||
s.allDay = false;
|
||||
s.repeat = 'none';
|
||||
s.repeatInterval = 1;
|
||||
s.repeatDays = '';
|
||||
s.seriesId = '';
|
||||
s.reminder = 'none';
|
||||
s.reminders = '';
|
||||
s.assigneeId = null;
|
||||
s.order = order++;
|
||||
s.createdAt = now;
|
||||
s.updatedAt = now;
|
||||
});
|
||||
}
|
||||
|
||||
const removedIds: string[] = [];
|
||||
for (const existing of existingSubtasks) {
|
||||
if (existing.parentSubtaskId) continue;
|
||||
if (keptIds.has(existing.id)) continue;
|
||||
removedIds.push(existing.id);
|
||||
const children = await collections.subtasks.query(Q.where('parent_subtask_id', existing.id)).fetch();
|
||||
for (const child of children) {
|
||||
await child.update((c) => {
|
||||
c.parentSubtaskId = null;
|
||||
c.updatedAt = now;
|
||||
});
|
||||
}
|
||||
await existing.destroyPermanently();
|
||||
}
|
||||
if (removedIds.length > 0) {
|
||||
await recordTombstonesInBatch('subtasks', removedIds);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -261,8 +295,8 @@ export default function TaskDetailScreen() {
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
<CategorySelector
|
||||
value={categoryId}
|
||||
onChange={(value) => setValue('categoryId', value)}
|
||||
value={tags}
|
||||
onChange={(value) => setValue('tags', value)}
|
||||
error={errors.categoryId?.message}
|
||||
/>
|
||||
<Controller
|
||||
|
||||
Reference in New Issue
Block a user