From ed31f24b230f991649632b7de184b5fc32362b86 Mon Sep 17 00:00:00 2001 From: tech08mag Date: Fri, 7 Aug 2026 22:29:46 +0200 Subject: [PATCH] functioning apk with reworked calendar --- carry-your-live/app/(tabs)/calendar.tsx | 712 +++++++++++++----- carry-your-live/app/(tabs)/index.tsx | 2 +- carry-your-live/app/(tabs)/settings.tsx | 157 +++- carry-your-live/app/add-task.tsx | 10 +- carry-your-live/app/task-detail.tsx | 4 +- carry-your-live/package-lock.json | 76 +- .../src/components/CategoryEditorModal.tsx | 3 +- .../src/components/CategorySelector.tsx | 31 +- .../src/components/FriendsModal.tsx | 12 +- .../src/components/QuickAddBar.tsx | 10 +- .../src/components/RepeatSelector.tsx | 2 +- .../src/components/ServerUrlModal.tsx | 3 +- carry-your-live/src/components/SyncModal.tsx | 11 +- carry-your-live/src/components/SyncStatus.tsx | 2 +- carry-your-live/src/components/TaskItem.tsx | 11 +- carry-your-live/src/components/TaskList.tsx | 301 ++++---- carry-your-live/src/database/schema.ts | 3 +- carry-your-live/src/hooks/useTaskModals.tsx | 2 +- carry-your-live/src/hooks/useTasks.tsx | 107 +++ carry-your-live/src/theme.tsx | 98 ++- carry-your-live/src/utils/categoryActions.ts | 10 +- carry-your-live/src/utils/taskActions.ts | 43 +- 22 files changed, 1108 insertions(+), 502 deletions(-) diff --git a/carry-your-live/app/(tabs)/calendar.tsx b/carry-your-live/app/(tabs)/calendar.tsx index 8a11014..4bc56bc 100644 --- a/carry-your-live/app/(tabs)/calendar.tsx +++ b/carry-your-live/app/(tabs)/calendar.tsx @@ -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(null); + const [monthPickerVisible, setMonthPickerVisible] = useState(false); + const [yearPickerVisible, setYearPickerVisible] = useState(false); + const [subtasksMap, setSubtasksMap] = useState>({}); - 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(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 }) => ( - handleToggleComplete(item.id)} - onDelete={() => openTaskDelete(item)} - onPress={() => router.push({ pathname: '/task-detail', params: { id: item.id } })} - onMenuOpen={() => openTaskMenu(item)} - /> - ), - [handleToggleComplete, openTaskDelete, openTaskMenu, router] - ); - return ( -
+ + +
- - {days.map((day) => ( - + { + gridWidthRef.current = e.nativeEvent.layout.width; + }} + style={[styles.calendarArea, { transform: [{ translateX }] }]} + > + + transitionTo(-1)} + style={[styles.monthNav, { borderColor: theme.borderStrong, backgroundColor: theme.card }]} + activeOpacity={0.7} + > + + + + + + + setMonthPickerVisible(true)} activeOpacity={0.7} style={styles.monthButton}> + {format(visibleMonth, 'MMMM')} + + + + + setYearPickerVisible(true)} activeOpacity={0.7} style={styles.yearButton}> + {format(visibleMonth, 'yyyy')} + + + + transitionTo(1)} + style={[styles.monthNav, { borderColor: theme.borderStrong, backgroundColor: theme.card }]} + activeOpacity={0.7} + > + + + + + + + + {GERMAN_WEEKDAYS.map((day, i) => ( + + {day} + + ))} + + + {weeks.map((week, wi) => ( + + {week.map((day) => ( + + ))} + + ))} + + + + {format(selectedDate, 'EEEE, MMMM d')} + {selectedDayTasks.length > 0 && ( + + {selectedDayTasks.length} event{selectedDayTasks.length === 1 ? '' : 's'} + + )} + + + {selectedDayTasks.length === 0 ? ( + + No events on this day + Tap a date or use the bar below + + ) : ( + selectedDayTasks.map((task) => ( + + + handleToggleComplete(task.id)} + activeOpacity={0.7} + > + + {task.completed ? ( + <> + + + + ) : ( + + )} + + + + router.push({ pathname: '/task-detail', params: { id: task.id } })} + activeOpacity={0.7} + > + + {task.title} + + + + openTaskMenu(task)} activeOpacity={0.7}> + + + + + + + + + {(subtasksMap[task.id] ?? []).length > 0 && ( + + {(subtasksMap[task.id] ?? []).map((sub) => ( + router.push({ pathname: '/subtask-detail', params: { id: sub.id } })} + activeOpacity={0.7} + > + + + + + {sub.title} + + + ))} + + )} + + )) + )} + + + + + {modals(() => {})} + + setMonthPickerVisible(false)} /> - ))} - - - - - - - - - {format(visibleMonth, 'MMM yyyy').toUpperCase()} - - - - - - - - item.id} - renderItem={renderTask} - ItemSeparatorComponent={MemoSeparator} - ListEmptyComponent={ - loading ? ( - - Loading... - - ) : ( - - No tasks scheduled. - Use the bar below to add one - - ) - } - contentContainerStyle={styles.listContent} - /> - - - - {modals(() => {})} + setYearPickerVisible(false)} + /> + + ); } -interface DayButtonProps { - day: Date; - selected: boolean; - current: boolean; - onPress: (day: Date) => void; - theme: ThemeColors; +function byDayOf(byDay: Record, 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 ( onPress(day)} activeOpacity={0.7} > - - {format(day, 'EEE').charAt(0)} - - + {format(day, 'd')} + {label ? ( + + + {label} + + + ) : !inMonth ? ( + + ) : null} ); }); -const MemoSeparator = React.memo(function Separator() { - return ; -}); - 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', + }, +}); \ No newline at end of file diff --git a/carry-your-live/app/(tabs)/index.tsx b/carry-your-live/app/(tabs)/index.tsx index bcf7377..d852f5a 100644 --- a/carry-your-live/app/(tabs)/index.tsx +++ b/carry-your-live/app/(tabs)/index.tsx @@ -22,7 +22,7 @@ export default function TasksScreen() { return ( -
+
diff --git a/carry-your-live/app/(tabs)/settings.tsx b/carry-your-live/app/(tabs)/settings.tsx index 7a2cd15..591b25d 100644 --- a/carry-your-live/app/(tabs)/settings.tsx +++ b/carry-your-live/app/(tabs)/settings.tsx @@ -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); const [editingCategory, setEditingCategory] = useState(null); @@ -26,6 +27,8 @@ export default function SettingsScreen() { const [friendsVisible, setFriendsVisible] = useState(false); const [legalVisible, setLegalVisible] = useState(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 /> + Appearance + Accent color + + {ACCENT_PRESETS.map((c) => ( + setAccentColor(c)} + activeOpacity={0.8} + > + {accentColor.toUpperCase() === c && ( + + + + )} + + ))} + + } + onPress={openCustomAccent} + showChevron + /> Data setServerUrlVisible(false)} /> + setCustomAccentVisible(false)}> + setCustomAccentVisible(false)} + > + {}}> + + Custom Accent Color + setCustomAccentVisible(false)} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}> + + + + + + + + { setDraftAccent(DEFAULT_ACCENT); setAccentColor(DEFAULT_ACCENT); }} + activeOpacity={0.7} + > + Reset + + { setAccentColor(draftAccent); setCustomAccentVisible(false); }} + activeOpacity={0.8} + > + Apply + + + + + + (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', + }, }); diff --git a/carry-your-live/app/add-task.tsx b/carry-your-live/app/add-task.tsx index 8df1e7b..13904b9 100644 --- a/carry-your-live/app/add-task.tsx +++ b/carry-your-live/app/add-task.tsx @@ -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; diff --git a/carry-your-live/app/task-detail.tsx b/carry-your-live/app/task-detail.tsx index 6219fc6..4370c3f 100644 --- a/carry-your-live/app/task-detail.tsx +++ b/carry-your-live/app/task-detail.tsx @@ -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 || ''; diff --git a/carry-your-live/package-lock.json b/carry-your-live/package-lock.json index e57b372..4b83cb3 100644 --- a/carry-your-live/package-lock.json +++ b/carry-your-live/package-lock.json @@ -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", diff --git a/carry-your-live/src/components/CategoryEditorModal.tsx b/carry-your-live/src/components/CategoryEditorModal.tsx index 214b83c..45d3dce 100644 --- a/carry-your-live/src/components/CategoryEditorModal.tsx +++ b/carry-your-live/src/components/CategoryEditorModal.tsx @@ -132,7 +132,7 @@ export function CategoryEditorModal({ visible, category, categoryCount, onClose disabled={!name.trim()} activeOpacity={0.8} > - Save + Save @@ -231,6 +231,5 @@ const styles = StyleSheet.create({ saveButtonText: { fontSize: 15, fontWeight: '600', - color: '#FFFFFF', }, }); diff --git a/carry-your-live/src/components/CategorySelector.tsx b/carry-your-live/src/components/CategorySelector.tsx index 2b9ae74..5ce84e7 100644 --- a/carry-your-live/src/components/CategorySelector.tsx +++ b/carry-your-live/src/components/CategorySelector.tsx @@ -28,10 +28,10 @@ export function CategorySelector({ value, onChange, error }: CategorySelectorPro activeOpacity={0.8} > - - - {selectedCategory?.name || 'Select category'} - + + + {selectedCategory?.name || 'None'} + @@ -53,6 +53,29 @@ export function CategorySelector({ value, onChange, error }: CategorySelectorPro + { onChange(''); setShowModal(false); }} + activeOpacity={0.8} + > + + + None + + {!value && ( + + + + )} + {categories.map((category) => ( {tab.charAt(0).toUpperCase() + tab.slice(1)} {tab === 'friends' && friends.length > 0 && ( - {friends.length} + {friends.length} )} {tab === 'incoming' && incoming.length > 0 && ( - {incoming.length} + {incoming.length} )} @@ -179,7 +179,7 @@ export function FriendsModal({ visible, onClose }: FriendsModalProps) { style={[styles.actionBtn, { backgroundColor: theme.accent }]} onPress={() => handleAccept(item.requestId)} > - Accept + Accept Alert.alert('Error', err instanceof Error ? err.message : 'Failed to send request')) } > - Add + Add )} @@ -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, diff --git a/carry-your-live/src/components/QuickAddBar.tsx b/carry-your-live/src/components/QuickAddBar.tsx index 3f91d4d..8ffa507 100644 --- a/carry-your-live/src/components/QuickAddBar.tsx +++ b/carry-your-live/src/components/QuickAddBar.tsx @@ -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(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) { ({ 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)} diff --git a/carry-your-live/src/components/RepeatSelector.tsx b/carry-your-live/src/components/RepeatSelector.tsx index 1018cc5..49bdfb6 100644 --- a/carry-your-live/src/components/RepeatSelector.tsx +++ b/carry-your-live/src/components/RepeatSelector.tsx @@ -282,7 +282,7 @@ export function RepeatSelector({ value, interval, days, onChange }: RepeatSelect disabled={!profileName.trim()} activeOpacity={0.8} > - Save + Save diff --git a/carry-your-live/src/components/ServerUrlModal.tsx b/carry-your-live/src/components/ServerUrlModal.tsx index 143650b..b8847dd 100644 --- a/carry-your-live/src/components/ServerUrlModal.tsx +++ b/carry-your-live/src/components/ServerUrlModal.tsx @@ -80,7 +80,7 @@ export function ServerUrlModal({ visible, onClose }: ServerUrlModalProps) { disabled={!isValid} activeOpacity={0.8} > - Save + Save @@ -160,6 +160,5 @@ const styles = StyleSheet.create({ saveButtonText: { fontSize: 15, fontWeight: '600', - color: '#FFFFFF', }, }); diff --git a/carry-your-live/src/components/SyncModal.tsx b/carry-your-live/src/components/SyncModal.tsx index f650538..15da8a6 100644 --- a/carry-your-live/src/components/SyncModal.tsx +++ b/carry-your-live/src/components/SyncModal.tsx @@ -152,9 +152,9 @@ export function SyncModal({ visible, onClose }: SyncModalProps) { activeOpacity={0.8} > {status === 'syncing' ? ( - + ) : ( - Sync Now + Sync Now )} @@ -202,7 +202,7 @@ export function SyncModal({ visible, onClose }: SyncModalProps) { {m === 'login' ? 'Sign In' : 'Create Account'} @@ -243,9 +243,9 @@ export function SyncModal({ visible, onClose }: SyncModalProps) { activeOpacity={0.8} > {busy ? ( - + ) : ( - + {mode === 'login' ? 'Sign In' : 'Create Account'} )} @@ -349,7 +349,6 @@ const styles = StyleSheet.create({ minHeight: 50, }, primaryButtonText: { - color: '#FFFFFF', fontSize: 15, fontWeight: '700', }, diff --git a/carry-your-live/src/components/SyncStatus.tsx b/carry-your-live/src/components/SyncStatus.tsx index 0033418..fe0664e 100644 --- a/carry-your-live/src/components/SyncStatus.tsx +++ b/carry-your-live/src/components/SyncStatus.tsx @@ -121,7 +121,7 @@ export default function SyncStatus({ compact = false }: SyncStatusProps) { disabled={status === 'syncing'} activeOpacity={0.8} > - + {status === 'syncing' ? 'Syncing...' : 'Sync Now'} diff --git a/carry-your-live/src/components/TaskItem.tsx b/carry-your-live/src/components/TaskItem.tsx index 0e19391..a0ca61b 100644 --- a/carry-your-live/src/components/TaskItem.tsx +++ b/carry-your-live/src/components/TaskItem.tsx @@ -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); diff --git a/carry-your-live/src/components/TaskList.tsx b/carry-your-live/src/components/TaskList.tsx index 01032aa..b36a5c0 100644 --- a/carry-your-live/src/components/TaskList.tsx +++ b/carry-your-live/src/components/TaskList.tsx @@ -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>(new Set()); const [hoverTaskId, setHoverTaskId] = useState(null); const [expandedTasks, setExpandedTasks] = useState>(new Set()); + const [completedShown, setCompletedShown] = useState(false); const [subtasksMap, setSubtasksMap] = useState>(new Map()); - const [reorderState, setReorderState] = useState<{ draggedId: string; draggedIndex: number; targetIndex: number | null; positions: Record } | null>(null); const [dropIndicator, setDropIndicator] = useState<{ targetId: string | null; position: 'above' | 'below' } | null>(null); const itemRefs = useRef>(new Map()); const dragStateRef = useRef<{ taskId: string; positions: Record } | 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 => { - 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 => { - 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 = {}; - const entries = Array.from(itemRefs.current.entries()); - await Promise.all(entries.map(([id, ref]) => { - return new Promise((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) => { - 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) => { - 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 : ( 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; } @@ -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; onSelect: (taskId: string) => void; + onFetchSubtasks: (taskId: string) => Promise; + subtasksMap: Map; + 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>(new Set(tasks.map((t) => t.id))); + const fetchedRef = useRef>(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 ( setExpanded(!expanded)} + onPress={() => onShownChange(!shown)} > Completed ({tasks.length}) - {expanded ? 'Hide' : 'Show'} + {shown ? 'Hide' : 'Show'} - {expanded && ( + {shown && ( - {tasks.map((task) => ( - 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 ( + + 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 && ( + + {subtasks.map((sub) => ( + onSubtaskToggle(sub.id, task.id)} + onDelete={() => onSubtaskDelete(sub)} + onPress={() => onSubtaskEdit(sub.id)} + onMenuOpen={() => onSubtaskMenuOpen(sub)} + /> + ))} + + )} + + ); + })} )} @@ -786,6 +735,12 @@ const styles = StyleSheet.create({ paddingRight: 4, paddingTop: 8, }, + completedSubtasks: { + paddingLeft: 8, + paddingRight: 4, + paddingTop: 4, + marginBottom: 4, + }, dragContainer: { }, dropIndicatorContainer: { diff --git a/carry-your-live/src/database/schema.ts b/carry-your-live/src/database/schema.ts index 002c245..704dca3 100644 --- a/carry-your-live/src/database/schema.ts +++ b/carry-your-live/src/database/schema.ts @@ -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 }, diff --git a/carry-your-live/src/hooks/useTaskModals.tsx b/carry-your-live/src/hooks/useTaskModals.tsx index 59a5570..7d5f71b 100644 --- a/carry-your-live/src/hooks/useTaskModals.tsx +++ b/carry-your-live/src/hooks/useTaskModals.tsx @@ -304,7 +304,7 @@ export function useTaskModals() { ({ 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)} diff --git a/carry-your-live/src/hooks/useTasks.tsx b/carry-your-live/src/hooks/useTasks.tsx index d5a241f..7f4065f 100644 --- a/carry-your-live/src/hooks/useTasks.tsx +++ b/carry-your-live/src/hooks/useTasks.tsx @@ -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([]); + 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 = {}; + 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([]); + 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 = {}; + 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 }; +} diff --git a/carry-your-live/src/theme.tsx b/carry-your-live/src/theme.tsx index e67430a..4bd2798 100644 --- a/carry-your-live/src/theme.tsx +++ b/carry-your-live/src/theme.tsx @@ -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(null); @@ -119,10 +164,11 @@ export function SettingsProvider({ children }: { children: ReactNode }) { '15m', ); const [apiUrl, setApiUrl] = useStoredSetting(API_URL_KEY, DEFAULT_API_BASE_URL); + const [accentColor, setAccentColor] = useStoredSetting(STORAGE_KEYS.accentColor, DEFAULT_ACCENT); - const theme = colors; + const theme = useMemo(() => colors(accentColor), [accentColor]); -const value = useMemo( + const value = useMemo( () => ({ notifications, setNotifications, @@ -132,10 +178,12 @@ const value = useMemo( setSortBy, reminderPreference, setReminderPreference, -apiUrl, - setApiUrl, - theme, - }), + apiUrl, + setApiUrl, + accentColor, + setAccentColor, + theme, + }), [ notifications, setNotifications, @@ -147,6 +195,8 @@ apiUrl, setReminderPreference, apiUrl, setApiUrl, + accentColor, + setAccentColor, theme, ] ); diff --git a/carry-your-live/src/utils/categoryActions.ts b/carry-your-live/src/utils/categoryActions.ts index e596244..4a4d4cd 100644 --- a/carry-your-live/src/utils/categoryActions.ts +++ b/carry-your-live/src/utils/categoryActions.ts @@ -34,12 +34,10 @@ export async function deleteCategory(categoryId: string): Promise { 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); diff --git a/carry-your-live/src/utils/taskActions.ts b/carry-your-live/src/utils/taskActions.ts index 8b7ca23..c351b96 100644 --- a/carry-your-live/src/utils/taskActions.ts +++ b/carry-your-live/src/utils/taskActions.ts @@ -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 { + 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 => { + 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);