feat: add Phase 2 — tags, sort, filters, recurrence

- Tags: create/edit/delete tags with color picker modal, assign to tasks
- Sort: sort tasks by position, priority, due date, title, created date
- Filters: filter by status, priority, due date, tag
- Recurrence: daily/weekly/monthly/yearly with auto-creation on completion
- Fix removeTagFromTask bug (was deleting all tags instead of specific one)
- Tag editor redesigned as modal for better keyboard UX

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
le king fu 2026-02-20 20:54:06 -05:00
parent 72f4a50e2b
commit 5fc1365ced
20 changed files with 1582 additions and 251 deletions

View file

@ -1,16 +1,21 @@
import { useEffect, useState, useCallback } from 'react';
import { View, Text, FlatList, Pressable, useColorScheme, Alert } from 'react-native';
import { useRouter } from 'expo-router';
import { Plus } from 'lucide-react-native';
import { Plus, ArrowUpDown, Filter } from 'lucide-react-native';
import { useTranslation } from 'react-i18next';
import * as Haptics from 'expo-haptics';
import { colors } from '@/src/theme/colors';
import { useSettingsStore } from '@/src/stores/useSettingsStore';
import { useTaskStore } from '@/src/stores/useTaskStore';
import { getTasksByList, toggleComplete, deleteTask } from '@/src/db/repository/tasks';
import { getInboxId } from '@/src/db/repository/lists';
import { getTagsForTask } from '@/src/db/repository/tags';
import TaskItem from '@/src/components/task/TaskItem';
import SortMenu from '@/src/components/SortMenu';
import FilterMenu from '@/src/components/FilterMenu';
type Tag = { id: string; name: string; color: string };
type Task = {
id: string;
title: string;
@ -18,20 +23,35 @@ type Task = {
priority: number;
dueDate: Date | null;
position: number;
recurrence: string | null;
tags?: Tag[];
};
export default function InboxScreen() {
const { t } = useTranslation();
const router = useRouter();
const [tasks, setTasks] = useState<Task[]>([]);
const [showSort, setShowSort] = useState(false);
const [showFilter, setShowFilter] = useState(false);
const systemScheme = useColorScheme();
const theme = useSettingsStore((s) => s.theme);
const isDark = (theme === 'system' ? systemScheme : theme) === 'dark';
const { sortBy, sortOrder, filterPriority, filterTag, filterCompleted, filterDueDate, hasActiveFilters } = useTaskStore();
const loadTasks = useCallback(async () => {
const result = await getTasksByList(getInboxId());
setTasks(result as Task[]);
}, []);
const result = await getTasksByList(getInboxId(), {
sortBy, sortOrder, filterPriority, filterTag, filterCompleted, filterDueDate,
});
// Load tags for each task
const withTags = await Promise.all(
result.map(async (task) => ({
...task,
tags: await getTagsForTask(task.id),
}))
);
setTasks(withTags as Task[]);
}, [sortBy, sortOrder, filterPriority, filterTag, filterCompleted, filterDueDate]);
useEffect(() => {
loadTasks();
@ -63,17 +83,30 @@ export default function InboxScreen() {
]);
};
const filtersActive = hasActiveFilters();
return (
<View className={`flex-1 ${isDark ? 'bg-[#1A1A1A]' : 'bg-creme'}`}>
{/* Toolbar */}
<View className={`flex-row items-center justify-end border-b px-4 py-2 ${isDark ? 'border-[#3A3A3A]' : 'border-[#E5E7EB]'}`}>
<Pressable onPress={() => setShowSort(true)} className="mr-3 p-1">
<ArrowUpDown size={20} color={sortBy !== 'position' ? colors.bleu.DEFAULT : isDark ? '#A0A0A0' : '#6B6B6B'} />
</Pressable>
<Pressable onPress={() => setShowFilter(true)} className="relative p-1">
<Filter size={20} color={filtersActive ? colors.bleu.DEFAULT : isDark ? '#A0A0A0' : '#6B6B6B'} />
{filtersActive && (
<View className="absolute -right-0.5 -top-0.5 h-2.5 w-2.5 rounded-full bg-terracotta" />
)}
</Pressable>
</View>
{tasks.length === 0 ? (
<View className="flex-1 items-center justify-center px-8">
<Text
className={`text-center text-base ${
isDark ? 'text-[#A0A0A0]' : 'text-[#6B6B6B]'
}`}
className={`text-center text-base ${isDark ? 'text-[#A0A0A0]' : 'text-[#6B6B6B]'}`}
style={{ fontFamily: 'Inter_400Regular' }}
>
{t('empty.inbox')}
{filtersActive ? t('empty.list') : t('empty.inbox')}
</Text>
</View>
) : (
@ -100,6 +133,9 @@ export default function InboxScreen() {
>
<Plus size={28} color="#FFFFFF" />
</Pressable>
<SortMenu visible={showSort} onClose={() => setShowSort(false)} isDark={isDark} />
<FilterMenu visible={showFilter} onClose={() => setShowFilter(false)} isDark={isDark} />
</View>
);
}

View file

@ -1,20 +1,39 @@
import { View, Text, Pressable, useColorScheme } from 'react-native';
import { useState, useEffect, useCallback } from 'react';
import { View, Text, Pressable, useColorScheme, TextInput, ScrollView, Alert, Modal, KeyboardAvoidingView, Platform } from 'react-native';
import { useTranslation } from 'react-i18next';
import { Sun, Moon, Smartphone } from 'lucide-react-native';
import { Sun, Moon, Smartphone, Plus, Trash2, Pencil } from 'lucide-react-native';
import Constants from 'expo-constants';
import { colors } from '@/src/theme/colors';
import { useSettingsStore } from '@/src/stores/useSettingsStore';
import { getAllTags, createTag, updateTag, deleteTag } from '@/src/db/repository/tags';
import i18n from '@/src/i18n';
type ThemeMode = 'light' | 'dark' | 'system';
const TAG_COLORS = ['#4A90A4', '#C17767', '#8BA889', '#D4A574', '#7B68EE', '#E57373', '#4DB6AC'];
export default function SettingsScreen() {
const { t } = useTranslation();
const systemScheme = useColorScheme();
const { theme, locale, setTheme, setLocale } = useSettingsStore();
const isDark = (theme === 'system' ? systemScheme : theme) === 'dark';
const [tagsList, setTagsList] = useState<{ id: string; name: string; color: string }[]>([]);
const [showTagModal, setShowTagModal] = useState(false);
const [editingTagId, setEditingTagId] = useState<string | null>(null);
const [tagName, setTagName] = useState('');
const [tagColor, setTagColor] = useState(TAG_COLORS[0]);
const loadTags = useCallback(async () => {
const result = await getAllTags();
setTagsList(result);
}, []);
useEffect(() => {
loadTags();
}, [loadTags]);
const themeOptions: { value: ThemeMode; label: string; icon: typeof Sun }[] = [
{ value: 'light', label: t('settings.light'), icon: Sun },
{ value: 'dark', label: t('settings.dark'), icon: Moon },
@ -26,23 +45,56 @@ export default function SettingsScreen() {
i18n.changeLanguage(newLocale);
};
const openNewTag = () => {
setEditingTagId(null);
setTagName('');
setTagColor(TAG_COLORS[0]);
setShowTagModal(true);
};
const openEditTag = (tag: { id: string; name: string; color: string }) => {
setEditingTagId(tag.id);
setTagName(tag.name);
setTagColor(tag.color);
setShowTagModal(true);
};
const handleSaveTag = async () => {
if (!tagName.trim()) return;
if (editingTagId) {
await updateTag(editingTagId, tagName.trim(), tagColor);
} else {
await createTag(tagName.trim(), tagColor);
}
setShowTagModal(false);
loadTags();
};
const handleDeleteTag = (id: string) => {
Alert.alert(t('tag.deleteConfirm'), '', [
{ text: t('common.cancel'), style: 'cancel' },
{
text: t('common.delete'),
style: 'destructive',
onPress: async () => {
await deleteTag(id);
loadTags();
},
},
]);
};
return (
<View className={`flex-1 ${isDark ? 'bg-[#1A1A1A]' : 'bg-creme'}`}>
<ScrollView className={`flex-1 ${isDark ? 'bg-[#1A1A1A]' : 'bg-creme'}`}>
{/* Theme Section */}
<View className="px-4 pt-6">
<Text
className={`mb-3 text-xs uppercase tracking-wide ${
isDark ? 'text-[#A0A0A0]' : 'text-[#6B6B6B]'
}`}
className={`mb-3 text-xs uppercase tracking-wide ${isDark ? 'text-[#A0A0A0]' : 'text-[#6B6B6B]'}`}
style={{ fontFamily: 'Inter_600SemiBold' }}
>
{t('settings.theme')}
</Text>
<View
className={`overflow-hidden rounded-xl ${
isDark ? 'bg-[#2A2A2A]' : 'bg-white'
}`}
>
<View className={`overflow-hidden rounded-xl ${isDark ? 'bg-[#2A2A2A]' : 'bg-white'}`}>
{themeOptions.map((option) => {
const Icon = option.icon;
const isActive = theme === option.value;
@ -50,22 +102,11 @@ export default function SettingsScreen() {
<Pressable
key={option.value}
onPress={() => setTheme(option.value)}
className={`flex-row items-center border-b px-4 py-3.5 ${
isDark ? 'border-[#3A3A3A]' : 'border-[#E5E7EB]'
} ${isActive ? (isDark ? 'bg-[#3A3A3A]' : 'bg-creme-dark') : ''}`}
className={`flex-row items-center border-b px-4 py-3.5 ${isDark ? 'border-[#3A3A3A]' : 'border-[#E5E7EB]'} ${isActive ? (isDark ? 'bg-[#3A3A3A]' : 'bg-creme-dark') : ''}`}
>
<Icon
size={20}
color={isActive ? colors.bleu.DEFAULT : isDark ? '#A0A0A0' : '#6B6B6B'}
/>
<Icon size={20} color={isActive ? colors.bleu.DEFAULT : isDark ? '#A0A0A0' : '#6B6B6B'} />
<Text
className={`ml-3 text-base ${
isActive
? 'text-bleu'
: isDark
? 'text-[#F5F5F5]'
: 'text-[#1A1A1A]'
}`}
className={`ml-3 text-base ${isActive ? 'text-bleu' : isDark ? 'text-[#F5F5F5]' : 'text-[#1A1A1A]'}`}
style={{ fontFamily: isActive ? 'Inter_600SemiBold' : 'Inter_400Regular' }}
>
{option.label}
@ -79,36 +120,22 @@ export default function SettingsScreen() {
{/* Language Section */}
<View className="px-4 pt-6">
<Text
className={`mb-3 text-xs uppercase tracking-wide ${
isDark ? 'text-[#A0A0A0]' : 'text-[#6B6B6B]'
}`}
className={`mb-3 text-xs uppercase tracking-wide ${isDark ? 'text-[#A0A0A0]' : 'text-[#6B6B6B]'}`}
style={{ fontFamily: 'Inter_600SemiBold' }}
>
{t('settings.language')}
</Text>
<View
className={`overflow-hidden rounded-xl ${
isDark ? 'bg-[#2A2A2A]' : 'bg-white'
}`}
>
<View className={`overflow-hidden rounded-xl ${isDark ? 'bg-[#2A2A2A]' : 'bg-white'}`}>
{(['fr', 'en'] as const).map((lang) => {
const isActive = locale === lang;
return (
<Pressable
key={lang}
onPress={() => handleLocaleChange(lang)}
className={`border-b px-4 py-3.5 ${
isDark ? 'border-[#3A3A3A]' : 'border-[#E5E7EB]'
} ${isActive ? (isDark ? 'bg-[#3A3A3A]' : 'bg-creme-dark') : ''}`}
className={`border-b px-4 py-3.5 ${isDark ? 'border-[#3A3A3A]' : 'border-[#E5E7EB]'} ${isActive ? (isDark ? 'bg-[#3A3A3A]' : 'bg-creme-dark') : ''}`}
>
<Text
className={`text-base ${
isActive
? 'text-bleu'
: isDark
? 'text-[#F5F5F5]'
: 'text-[#1A1A1A]'
}`}
className={`text-base ${isActive ? 'text-bleu' : isDark ? 'text-[#F5F5F5]' : 'text-[#1A1A1A]'}`}
style={{ fontFamily: isActive ? 'Inter_600SemiBold' : 'Inter_400Regular' }}
>
{lang === 'fr' ? 'Français' : 'English'}
@ -119,21 +146,149 @@ export default function SettingsScreen() {
</View>
</View>
{/* About Section */}
{/* Tags Section */}
<View className="px-4 pt-6">
<View className="mb-3 flex-row items-center justify-between">
<Text
className={`text-xs uppercase tracking-wide ${isDark ? 'text-[#A0A0A0]' : 'text-[#6B6B6B]'}`}
style={{ fontFamily: 'Inter_600SemiBold' }}
>
{t('tag.tags')}
</Text>
<Pressable onPress={openNewTag}>
<Plus size={18} color={colors.bleu.DEFAULT} />
</Pressable>
</View>
<View className={`overflow-hidden rounded-xl ${isDark ? 'bg-[#2A2A2A]' : 'bg-white'}`}>
{tagsList.length === 0 ? (
<View className="px-4 py-3.5">
<Text
className={`text-sm ${isDark ? 'text-[#A0A0A0]' : 'text-[#6B6B6B]'}`}
style={{ fontFamily: 'Inter_400Regular' }}
>
{t('tag.noTags')}
</Text>
</View>
) : (
tagsList.map((tag) => (
<Pressable
key={tag.id}
onPress={() => openEditTag(tag)}
className={`flex-row items-center justify-between border-b px-4 py-3.5 ${isDark ? 'border-[#3A3A3A]' : 'border-[#E5E7EB]'}`}
>
<View className="flex-row items-center flex-1">
<View className="mr-3 h-4 w-4 rounded-full" style={{ backgroundColor: tag.color }} />
<Text
className={`text-base ${isDark ? 'text-[#F5F5F5]' : 'text-[#1A1A1A]'}`}
style={{ fontFamily: 'Inter_400Regular' }}
>
{tag.name}
</Text>
</View>
<View className="flex-row items-center">
<Pencil size={14} color={isDark ? '#A0A0A0' : '#9CA3AF'} />
<Pressable onPress={() => handleDeleteTag(tag.id)} className="ml-3 p-1">
<Trash2 size={16} color={colors.terracotta.DEFAULT} />
</Pressable>
</View>
</Pressable>
))
)}
</View>
</View>
{/* Tag Create/Edit Modal */}
<Modal visible={showTagModal} transparent animationType="fade">
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
className="flex-1"
>
<Pressable onPress={() => setShowTagModal(false)} className="flex-1 justify-center bg-black/40 px-6">
<Pressable
onPress={(e) => e.stopPropagation()}
className={`rounded-2xl px-5 pb-5 pt-4 ${isDark ? 'bg-[#2A2A2A]' : 'bg-white'}`}
>
{/* Modal title */}
<Text
className={`mb-4 text-lg ${isDark ? 'text-[#F5F5F5]' : 'text-[#1A1A1A]'}`}
style={{ fontFamily: 'Inter_600SemiBold' }}
>
{editingTagId ? t('tag.editTag') : t('tag.newTag')}
</Text>
{/* Name input */}
<TextInput
autoFocus
value={tagName}
onChangeText={setTagName}
onSubmitEditing={handleSaveTag}
placeholder={t('tag.namePlaceholder')}
placeholderTextColor={isDark ? '#A0A0A0' : '#6B6B6B'}
className={`rounded-lg border px-3 py-2.5 text-base ${isDark ? 'border-[#3A3A3A] text-[#F5F5F5]' : 'border-[#E5E7EB] text-[#1A1A1A]'}`}
style={{ fontFamily: 'Inter_400Regular' }}
/>
{/* Color picker */}
<View className="mt-4 flex-row justify-between">
{TAG_COLORS.map((c) => (
<Pressable
key={c}
onPress={() => setTagColor(c)}
className="h-8 w-8 items-center justify-center rounded-full"
style={{ backgroundColor: c, borderWidth: tagColor === c ? 3 : 0, borderColor: isDark ? '#F5F5F5' : '#1A1A1A' }}
>
{tagColor === c && (
<Text className="text-xs text-white" style={{ fontFamily: 'Inter_700Bold' }}></Text>
)}
</Pressable>
))}
</View>
{/* Preview */}
{tagName.trim().length > 0 && (
<View className="mt-4 flex-row items-center">
<View
className="flex-row items-center rounded-full px-2.5 py-1"
style={{ backgroundColor: tagColor + '20' }}
>
<View className="mr-1.5 h-2 w-2 rounded-full" style={{ backgroundColor: tagColor }} />
<Text className="text-sm" style={{ color: tagColor, fontFamily: 'Inter_500Medium' }}>
{tagName.trim()}
</Text>
</View>
</View>
)}
{/* Buttons */}
<View className="mt-5 flex-row justify-end">
<Pressable onPress={() => setShowTagModal(false)} className="mr-3 px-4 py-2">
<Text
className={`text-sm ${isDark ? 'text-[#A0A0A0]' : 'text-[#6B6B6B]'}`}
style={{ fontFamily: 'Inter_500Medium' }}
>
{t('common.cancel')}
</Text>
</Pressable>
<Pressable onPress={handleSaveTag} className="rounded-lg bg-bleu px-4 py-2">
<Text className="text-sm text-white" style={{ fontFamily: 'Inter_600SemiBold' }}>
{t('common.save')}
</Text>
</Pressable>
</View>
</Pressable>
</Pressable>
</KeyboardAvoidingView>
</Modal>
{/* About Section */}
<View className="px-4 pb-8 pt-6">
<Text
className={`mb-3 text-xs uppercase tracking-wide ${
isDark ? 'text-[#A0A0A0]' : 'text-[#6B6B6B]'
}`}
className={`mb-3 text-xs uppercase tracking-wide ${isDark ? 'text-[#A0A0A0]' : 'text-[#6B6B6B]'}`}
style={{ fontFamily: 'Inter_600SemiBold' }}
>
{t('settings.about')}
</Text>
<View
className={`overflow-hidden rounded-xl px-4 py-3.5 ${
isDark ? 'bg-[#2A2A2A]' : 'bg-white'
}`}
>
<View className={`overflow-hidden rounded-xl px-4 py-3.5 ${isDark ? 'bg-[#2A2A2A]' : 'bg-white'}`}>
<Text className={`text-sm ${isDark ? 'text-[#A0A0A0]' : 'text-[#6B6B6B]'}`}>
Simpl-Liste {t('settings.version')} {Constants.expoConfig?.version ?? '1.0.0'}
</Text>
@ -142,6 +297,6 @@ export default function SettingsScreen() {
</Text>
</View>
</View>
</View>
</ScrollView>
);
}

View file

@ -1,16 +1,21 @@
import { useEffect, useState, useCallback } from 'react';
import { View, Text, FlatList, Pressable, useColorScheme, Alert } from 'react-native';
import { useRouter, useLocalSearchParams } from 'expo-router';
import { ArrowLeft, Plus } from 'lucide-react-native';
import { ArrowLeft, Plus, ArrowUpDown, Filter } from 'lucide-react-native';
import { useTranslation } from 'react-i18next';
import * as Haptics from 'expo-haptics';
import { colors } from '@/src/theme/colors';
import { useSettingsStore } from '@/src/stores/useSettingsStore';
import { useTaskStore } from '@/src/stores/useTaskStore';
import { getTasksByList, toggleComplete, deleteTask } from '@/src/db/repository/tasks';
import { getAllLists } from '@/src/db/repository/lists';
import { getTagsForTask } from '@/src/db/repository/tags';
import TaskItem from '@/src/components/task/TaskItem';
import SortMenu from '@/src/components/SortMenu';
import FilterMenu from '@/src/components/FilterMenu';
type Tag = { id: string; name: string; color: string };
type Task = {
id: string;
title: string;
@ -18,6 +23,8 @@ type Task = {
priority: number;
dueDate: Date | null;
position: number;
recurrence: string | null;
tags?: Tag[];
};
export default function ListDetailScreen() {
@ -26,21 +33,33 @@ export default function ListDetailScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
const [tasks, setTasks] = useState<Task[]>([]);
const [listName, setListName] = useState('');
const [showSort, setShowSort] = useState(false);
const [showFilter, setShowFilter] = useState(false);
const systemScheme = useColorScheme();
const theme = useSettingsStore((s) => s.theme);
const isDark = (theme === 'system' ? systemScheme : theme) === 'dark';
const { sortBy, sortOrder, filterPriority, filterTag, filterCompleted, filterDueDate, hasActiveFilters } = useTaskStore();
const loadData = useCallback(async () => {
if (!id) return;
const result = await getTasksByList(id);
setTasks(result as Task[]);
const result = await getTasksByList(id, {
sortBy, sortOrder, filterPriority, filterTag, filterCompleted, filterDueDate,
});
const withTags = await Promise.all(
result.map(async (task) => ({
...task,
tags: await getTagsForTask(task.id),
}))
);
setTasks(withTags as Task[]);
const lists = await getAllLists();
const list = lists.find((l) => l.id === id);
if (list) {
setListName(list.isInbox ? t('list.inbox') : list.name);
}
}, [id, t]);
}, [id, t, sortBy, sortOrder, filterPriority, filterTag, filterCompleted, filterDueDate]);
useEffect(() => {
loadData();
@ -72,23 +91,38 @@ export default function ListDetailScreen() {
]);
};
const filtersActive = hasActiveFilters();
return (
<View className={`flex-1 ${isDark ? 'bg-[#1A1A1A]' : 'bg-creme'}`}>
{/* Header */}
<View
className={`flex-row items-center border-b px-4 pb-3 pt-14 ${
className={`flex-row items-center justify-between border-b px-4 pb-3 pt-14 ${
isDark ? 'border-[#3A3A3A]' : 'border-[#E5E7EB]'
}`}
>
<Pressable onPress={() => router.back()} className="mr-3 p-1">
<ArrowLeft size={24} color={isDark ? '#F5F5F5' : '#1A1A1A'} />
</Pressable>
<Text
className={`text-lg ${isDark ? 'text-[#F5F5F5]' : 'text-[#1A1A1A]'}`}
style={{ fontFamily: 'Inter_600SemiBold' }}
>
{listName}
</Text>
<View className="flex-row items-center">
<Pressable onPress={() => router.back()} className="mr-3 p-1">
<ArrowLeft size={24} color={isDark ? '#F5F5F5' : '#1A1A1A'} />
</Pressable>
<Text
className={`text-lg ${isDark ? 'text-[#F5F5F5]' : 'text-[#1A1A1A]'}`}
style={{ fontFamily: 'Inter_600SemiBold' }}
>
{listName}
</Text>
</View>
<View className="flex-row items-center">
<Pressable onPress={() => setShowSort(true)} className="mr-3 p-1">
<ArrowUpDown size={20} color={sortBy !== 'position' ? colors.bleu.DEFAULT : isDark ? '#A0A0A0' : '#6B6B6B'} />
</Pressable>
<Pressable onPress={() => setShowFilter(true)} className="relative p-1">
<Filter size={20} color={filtersActive ? colors.bleu.DEFAULT : isDark ? '#A0A0A0' : '#6B6B6B'} />
{filtersActive && (
<View className="absolute -right-0.5 -top-0.5 h-2.5 w-2.5 rounded-full bg-terracotta" />
)}
</Pressable>
</View>
</View>
{tasks.length === 0 ? (
@ -117,7 +151,6 @@ export default function ListDetailScreen() {
/>
)}
{/* FAB */}
<Pressable
onPress={() => router.push(`/task/new?listId=${id}` as any)}
className="absolute bottom-6 right-6 h-14 w-14 items-center justify-center rounded-full bg-bleu"
@ -125,6 +158,9 @@ export default function ListDetailScreen() {
>
<Plus size={28} color="#FFFFFF" />
</Pressable>
<SortMenu visible={showSort} onClose={() => setShowSort(false)} isDark={isDark} />
<FilterMenu visible={showFilter} onClose={() => setShowFilter(false)} isDark={isDark} />
</View>
);
}

View file

@ -10,7 +10,7 @@ import {
Platform,
} from 'react-native';
import { useRouter, useLocalSearchParams } from 'expo-router';
import { ArrowLeft, Plus, Trash2, Calendar, X } from 'lucide-react-native';
import { ArrowLeft, Plus, Trash2, Calendar, X, Repeat } from 'lucide-react-native';
import { useTranslation } from 'react-i18next';
import * as Haptics from 'expo-haptics';
import DateTimePicker, { DateTimePickerEvent } from '@react-native-community/datetimepicker';
@ -18,6 +18,7 @@ import DateTimePicker, { DateTimePickerEvent } from '@react-native-community/dat
import { colors } from '@/src/theme/colors';
import { useSettingsStore } from '@/src/stores/useSettingsStore';
import { getPriorityOptions } from '@/src/lib/priority';
import { RECURRENCE_OPTIONS } from '@/src/lib/recurrence';
import {
getTaskById,
updateTask,
@ -26,7 +27,8 @@ import {
createTask,
toggleComplete,
} from '@/src/db/repository/tasks';
import { getAllTags, getTagsForTask, setTagsForTask } from '@/src/db/repository/tags';
import TagChip from '@/src/components/task/TagChip';
type TaskData = {
id: string;
@ -36,13 +38,10 @@ type TaskData = {
priority: number;
dueDate: Date | null;
listId: string;
recurrence: string | null;
};
type SubtaskData = {
id: string;
title: string;
completed: boolean;
};
type SubtaskData = { id: string; title: string; completed: boolean };
export default function TaskDetailScreen() {
const { t } = useTranslation();
@ -58,13 +57,17 @@ export default function TaskDetailScreen() {
const [priority, setPriority] = useState(0);
const [dueDate, setDueDate] = useState<Date | null>(null);
const [showDatePicker, setShowDatePicker] = useState(false);
const [recurrence, setRecurrence] = useState<string | null>(null);
const [subtasks, setSubtasks] = useState<SubtaskData[]>([]);
const [newSubtask, setNewSubtask] = useState('');
const [availableTags, setAvailableTags] = useState<{ id: string; name: string; color: string }[]>([]);
const [selectedTagIds, setSelectedTagIds] = useState<string[]>([]);
useEffect(() => {
if (!id) return;
loadTask();
loadSubtasks();
getAllTags().then(setAvailableTags);
}, [id]);
const loadTask = async () => {
@ -75,6 +78,10 @@ export default function TaskDetailScreen() {
setNotes(result.notes ?? '');
setPriority(result.priority);
setDueDate(result.dueDate ? new Date(result.dueDate) : null);
setRecurrence(result.recurrence ?? null);
const taskTags = await getTagsForTask(id!);
setSelectedTagIds(taskTags.map((t) => t.id));
};
const loadSubtasks = async () => {
@ -89,7 +96,9 @@ export default function TaskDetailScreen() {
notes: notes.trim() || undefined,
priority,
dueDate,
recurrence,
});
await setTagsForTask(task.id, selectedTagIds);
router.back();
};
@ -110,11 +119,7 @@ export default function TaskDetailScreen() {
const handleAddSubtask = async () => {
if (!newSubtask.trim() || !task) return;
await createTask({
title: newSubtask.trim(),
listId: task.listId,
parentId: task.id,
});
await createTask({ title: newSubtask.trim(), listId: task.listId, parentId: task.id });
setNewSubtask('');
loadSubtasks();
};
@ -130,6 +135,12 @@ export default function TaskDetailScreen() {
if (date) setDueDate(date);
};
const toggleTag = (tagId: string) => {
setSelectedTagIds((prev) =>
prev.includes(tagId) ? prev.filter((i) => i !== tagId) : [...prev, tagId]
);
};
if (!task) {
return (
<View className={`flex-1 items-center justify-center ${isDark ? 'bg-[#1A1A1A]' : 'bg-creme'}`}>
@ -142,9 +153,7 @@ export default function TaskDetailScreen() {
<View className={`flex-1 ${isDark ? 'bg-[#1A1A1A]' : 'bg-creme'}`}>
{/* Header */}
<View
className={`flex-row items-center justify-between border-b px-4 pb-3 pt-14 ${
isDark ? 'border-[#3A3A3A]' : 'border-[#E5E7EB]'
}`}
className={`flex-row items-center justify-between border-b px-4 pb-3 pt-14 ${isDark ? 'border-[#3A3A3A]' : 'border-[#E5E7EB]'}`}
>
<Pressable onPress={() => router.back()} className="p-1">
<ArrowLeft size={24} color={isDark ? '#F5F5F5' : '#1A1A1A'} />
@ -154,9 +163,7 @@ export default function TaskDetailScreen() {
<Trash2 size={20} color={colors.terracotta.DEFAULT} />
</Pressable>
<Pressable onPress={handleSave} className="rounded-lg bg-bleu px-4 py-1.5">
<Text className="text-sm text-white" style={{ fontFamily: 'Inter_600SemiBold' }}>
{t('common.save')}
</Text>
<Text className="text-sm text-white" style={{ fontFamily: 'Inter_600SemiBold' }}>{t('common.save')}</Text>
</Pressable>
</View>
</View>
@ -184,12 +191,7 @@ export default function TaskDetailScreen() {
/>
{/* Priority */}
<Text
className={`mb-2 mt-6 text-xs uppercase tracking-wide ${
isDark ? 'text-[#A0A0A0]' : 'text-[#6B6B6B]'
}`}
style={{ fontFamily: 'Inter_600SemiBold' }}
>
<Text className={`mb-2 mt-6 text-xs uppercase tracking-wide ${isDark ? 'text-[#A0A0A0]' : 'text-[#6B6B6B]'}`} style={{ fontFamily: 'Inter_600SemiBold' }}>
{t('task.priority')}
</Text>
<View className="flex-row">
@ -197,32 +199,12 @@ export default function TaskDetailScreen() {
<Pressable
key={opt.value}
onPress={() => setPriority(opt.value)}
className={`mr-2 rounded-full border px-3 py-1.5 ${
priority === opt.value
? 'border-transparent'
: isDark
? 'border-[#3A3A3A]'
: 'border-[#E5E7EB]'
}`}
className={`mr-2 rounded-full border px-3 py-1.5 ${priority === opt.value ? 'border-transparent' : isDark ? 'border-[#3A3A3A]' : 'border-[#E5E7EB]'}`}
style={priority === opt.value ? { backgroundColor: opt.color + '20' } : undefined}
>
<View className="flex-row items-center">
<View
className="mr-1.5 h-2.5 w-2.5 rounded-full"
style={{ backgroundColor: opt.color }}
/>
<Text
className="text-sm"
style={{
fontFamily: priority === opt.value ? 'Inter_600SemiBold' : 'Inter_400Regular',
color:
priority === opt.value
? opt.color
: isDark
? '#A0A0A0'
: '#6B6B6B',
}}
>
<View className="mr-1.5 h-2.5 w-2.5 rounded-full" style={{ backgroundColor: opt.color }} />
<Text className="text-sm" style={{ fontFamily: priority === opt.value ? 'Inter_600SemiBold' : 'Inter_400Regular', color: priority === opt.value ? opt.color : isDark ? '#A0A0A0' : '#6B6B6B' }}>
{t(opt.labelKey)}
</Text>
</View>
@ -231,33 +213,15 @@ export default function TaskDetailScreen() {
</View>
{/* Due Date */}
<Text
className={`mb-2 mt-6 text-xs uppercase tracking-wide ${
isDark ? 'text-[#A0A0A0]' : 'text-[#6B6B6B]'
}`}
style={{ fontFamily: 'Inter_600SemiBold' }}
>
<Text className={`mb-2 mt-6 text-xs uppercase tracking-wide ${isDark ? 'text-[#A0A0A0]' : 'text-[#6B6B6B]'}`} style={{ fontFamily: 'Inter_600SemiBold' }}>
{t('task.dueDate')}
</Text>
<Pressable
onPress={() => setShowDatePicker(true)}
className={`flex-row items-center rounded-lg border px-3 py-2.5 ${
isDark ? 'border-[#3A3A3A]' : 'border-[#E5E7EB]'
}`}
className={`flex-row items-center rounded-lg border px-3 py-2.5 ${isDark ? 'border-[#3A3A3A]' : 'border-[#E5E7EB]'}`}
>
<Calendar size={18} color={isDark ? '#A0A0A0' : '#6B6B6B'} />
<Text
className={`ml-2 text-base ${
dueDate
? isDark
? 'text-[#F5F5F5]'
: 'text-[#1A1A1A]'
: isDark
? 'text-[#A0A0A0]'
: 'text-[#6B6B6B]'
}`}
style={{ fontFamily: 'Inter_400Regular' }}
>
<Text className={`ml-2 text-base ${dueDate ? (isDark ? 'text-[#F5F5F5]' : 'text-[#1A1A1A]') : isDark ? 'text-[#A0A0A0]' : 'text-[#6B6B6B]'}`} style={{ fontFamily: 'Inter_400Regular' }}>
{dueDate ? dueDate.toLocaleDateString() : t('task.dueDate')}
</Text>
{dueDate && (
@ -267,30 +231,66 @@ export default function TaskDetailScreen() {
)}
</Pressable>
{showDatePicker && (
<DateTimePicker
value={dueDate ?? new Date()}
mode="date"
display="default"
onChange={handleDateChange}
/>
<DateTimePicker value={dueDate ?? new Date()} mode="date" display="default" onChange={handleDateChange} />
)}
{/* Recurrence */}
<Text className={`mb-2 mt-6 text-xs uppercase tracking-wide ${isDark ? 'text-[#A0A0A0]' : 'text-[#6B6B6B]'}`} style={{ fontFamily: 'Inter_600SemiBold' }}>
{t('recurrence.label')}
</Text>
<View className="flex-row flex-wrap">
<Pressable
onPress={() => setRecurrence(null)}
className={`mb-1 mr-2 rounded-full border px-3 py-1.5 ${recurrence === null ? 'border-bleu bg-bleu/10' : isDark ? 'border-[#3A3A3A]' : 'border-[#E5E7EB]'}`}
>
<Text className="text-sm" style={{ fontFamily: recurrence === null ? 'Inter_600SemiBold' : 'Inter_400Regular', color: recurrence === null ? colors.bleu.DEFAULT : isDark ? '#A0A0A0' : '#6B6B6B' }}>
{t('recurrence.none')}
</Text>
</Pressable>
{RECURRENCE_OPTIONS.map((opt) => (
<Pressable
key={opt}
onPress={() => setRecurrence(recurrence === opt ? null : opt)}
className={`mb-1 mr-2 flex-row items-center rounded-full border px-3 py-1.5 ${recurrence === opt ? 'border-bleu bg-bleu/10' : isDark ? 'border-[#3A3A3A]' : 'border-[#E5E7EB]'}`}
>
<Repeat size={12} color={recurrence === opt ? colors.bleu.DEFAULT : isDark ? '#A0A0A0' : '#6B6B6B'} />
<Text className="ml-1 text-sm" style={{ fontFamily: recurrence === opt ? 'Inter_600SemiBold' : 'Inter_400Regular', color: recurrence === opt ? colors.bleu.DEFAULT : isDark ? '#A0A0A0' : '#6B6B6B' }}>
{t(`recurrence.${opt}`)}
</Text>
</Pressable>
))}
</View>
{/* Tags */}
{availableTags.length > 0 && (
<>
<Text className={`mb-2 mt-6 text-xs uppercase tracking-wide ${isDark ? 'text-[#A0A0A0]' : 'text-[#6B6B6B]'}`} style={{ fontFamily: 'Inter_600SemiBold' }}>
{t('tag.tags')}
</Text>
<View className="flex-row flex-wrap">
{availableTags.map((tag) => (
<TagChip
key={tag.id}
name={tag.name}
color={tag.color}
isDark={isDark}
selected={selectedTagIds.includes(tag.id)}
onPress={() => toggleTag(tag.id)}
/>
))}
</View>
</>
)}
{/* Subtasks */}
<Text
className={`mb-2 mt-6 text-xs uppercase tracking-wide ${
isDark ? 'text-[#A0A0A0]' : 'text-[#6B6B6B]'
}`}
style={{ fontFamily: 'Inter_600SemiBold' }}
>
<Text className={`mb-2 mt-6 text-xs uppercase tracking-wide ${isDark ? 'text-[#A0A0A0]' : 'text-[#6B6B6B]'}`} style={{ fontFamily: 'Inter_600SemiBold' }}>
{t('task.subtasks')}
</Text>
{subtasks.map((sub) => (
<Pressable
key={sub.id}
onPress={() => handleToggleSubtask(sub.id)}
className={`flex-row items-center border-b py-2.5 ${
isDark ? 'border-[#3A3A3A]' : 'border-[#E5E7EB]'
}`}
className={`flex-row items-center border-b py-2.5 ${isDark ? 'border-[#3A3A3A]' : 'border-[#E5E7EB]'}`}
>
<View
className="mr-3 h-5 w-5 items-center justify-center rounded-full border-2"
@ -299,20 +299,10 @@ export default function TaskDetailScreen() {
backgroundColor: sub.completed ? colors.bleu.DEFAULT : 'transparent',
}}
>
{sub.completed && (
<Text className="text-xs text-white" style={{ fontFamily: 'Inter_700Bold' }}>
</Text>
)}
{sub.completed && <Text className="text-xs text-white" style={{ fontFamily: 'Inter_700Bold' }}></Text>}
</View>
<Text
className={`text-base ${
sub.completed
? 'line-through ' + (isDark ? 'text-[#A0A0A0]' : 'text-[#9CA3AF]')
: isDark
? 'text-[#F5F5F5]'
: 'text-[#1A1A1A]'
}`}
className={`text-base ${sub.completed ? 'line-through ' + (isDark ? 'text-[#A0A0A0]' : 'text-[#9CA3AF]') : isDark ? 'text-[#F5F5F5]' : 'text-[#1A1A1A]'}`}
style={{ fontFamily: 'Inter_400Regular' }}
>
{sub.title}

View file

@ -9,14 +9,18 @@ import {
Platform,
} from 'react-native';
import { useRouter, useLocalSearchParams } from 'expo-router';
import { X, Calendar } from 'lucide-react-native';
import { X, Calendar, Repeat } from 'lucide-react-native';
import { useTranslation } from 'react-i18next';
import DateTimePicker, { DateTimePickerEvent } from '@react-native-community/datetimepicker';
import { colors } from '@/src/theme/colors';
import { useSettingsStore } from '@/src/stores/useSettingsStore';
import { createTask } from '@/src/db/repository/tasks';
import { getInboxId, getAllLists } from '@/src/db/repository/lists';
import { getAllTags, setTagsForTask } from '@/src/db/repository/tags';
import { getPriorityOptions } from '@/src/lib/priority';
import { RECURRENCE_OPTIONS } from '@/src/lib/recurrence';
import TagChip from '@/src/components/task/TagChip';
export default function NewTaskScreen() {
const { t } = useTranslation();
@ -33,20 +37,28 @@ export default function NewTaskScreen() {
const [showDatePicker, setShowDatePicker] = useState(false);
const [selectedListId, setSelectedListId] = useState(params.listId ?? getInboxId());
const [lists, setLists] = useState<{ id: string; name: string; isInbox: boolean }[]>([]);
const [recurrence, setRecurrence] = useState<string | null>(null);
const [availableTags, setAvailableTags] = useState<{ id: string; name: string; color: string }[]>([]);
const [selectedTagIds, setSelectedTagIds] = useState<string[]>([]);
useEffect(() => {
getAllLists().then(setLists);
getAllTags().then(setAvailableTags);
}, []);
const handleSave = async () => {
if (!title.trim()) return;
await createTask({
const taskId = await createTask({
title: title.trim(),
notes: notes.trim() || undefined,
priority,
dueDate: dueDate ?? undefined,
listId: selectedListId,
recurrence: recurrence ?? undefined,
});
if (selectedTagIds.length > 0) {
await setTagsForTask(taskId, selectedTagIds);
}
router.back();
};
@ -55,6 +67,12 @@ export default function NewTaskScreen() {
if (date) setDueDate(date);
};
const toggleTag = (tagId: string) => {
setSelectedTagIds((prev) =>
prev.includes(tagId) ? prev.filter((id) => id !== tagId) : [...prev, tagId]
);
};
return (
<View className={`flex-1 ${isDark ? 'bg-[#1A1A1A]' : 'bg-creme'}`}>
{/* Header */}
@ -104,9 +122,7 @@ export default function NewTaskScreen() {
{/* Priority */}
<Text
className={`mb-2 mt-6 text-xs uppercase tracking-wide ${
isDark ? 'text-[#A0A0A0]' : 'text-[#6B6B6B]'
}`}
className={`mb-2 mt-6 text-xs uppercase tracking-wide ${isDark ? 'text-[#A0A0A0]' : 'text-[#6B6B6B]'}`}
style={{ fontFamily: 'Inter_600SemiBold' }}
>
{t('task.priority')}
@ -117,30 +133,17 @@ export default function NewTaskScreen() {
key={opt.value}
onPress={() => setPriority(opt.value)}
className={`mr-2 rounded-full border px-3 py-1.5 ${
priority === opt.value
? 'border-transparent'
: isDark
? 'border-[#3A3A3A]'
: 'border-[#E5E7EB]'
priority === opt.value ? 'border-transparent' : isDark ? 'border-[#3A3A3A]' : 'border-[#E5E7EB]'
}`}
style={priority === opt.value ? { backgroundColor: opt.color + '20' } : undefined}
>
<View className="flex-row items-center">
<View
className="mr-1.5 h-2.5 w-2.5 rounded-full"
style={{ backgroundColor: opt.color }}
/>
<View className="mr-1.5 h-2.5 w-2.5 rounded-full" style={{ backgroundColor: opt.color }} />
<Text
className={`text-sm ${
priority === opt.value
? ''
: isDark
? 'text-[#A0A0A0]'
: 'text-[#6B6B6B]'
}`}
className="text-sm"
style={{
fontFamily: priority === opt.value ? 'Inter_600SemiBold' : 'Inter_400Regular',
color: priority === opt.value ? opt.color : undefined,
color: priority === opt.value ? opt.color : isDark ? '#A0A0A0' : '#6B6B6B',
}}
>
{t(opt.labelKey)}
@ -152,30 +155,18 @@ export default function NewTaskScreen() {
{/* Due Date */}
<Text
className={`mb-2 mt-6 text-xs uppercase tracking-wide ${
isDark ? 'text-[#A0A0A0]' : 'text-[#6B6B6B]'
}`}
className={`mb-2 mt-6 text-xs uppercase tracking-wide ${isDark ? 'text-[#A0A0A0]' : 'text-[#6B6B6B]'}`}
style={{ fontFamily: 'Inter_600SemiBold' }}
>
{t('task.dueDate')}
</Text>
<Pressable
onPress={() => setShowDatePicker(true)}
className={`flex-row items-center rounded-lg border px-3 py-2.5 ${
isDark ? 'border-[#3A3A3A]' : 'border-[#E5E7EB]'
}`}
className={`flex-row items-center rounded-lg border px-3 py-2.5 ${isDark ? 'border-[#3A3A3A]' : 'border-[#E5E7EB]'}`}
>
<Calendar size={18} color={isDark ? '#A0A0A0' : '#6B6B6B'} />
<Text
className={`ml-2 text-base ${
dueDate
? isDark
? 'text-[#F5F5F5]'
: 'text-[#1A1A1A]'
: isDark
? 'text-[#A0A0A0]'
: 'text-[#6B6B6B]'
}`}
className={`ml-2 text-base ${dueDate ? (isDark ? 'text-[#F5F5F5]' : 'text-[#1A1A1A]') : isDark ? 'text-[#A0A0A0]' : 'text-[#6B6B6B]'}`}
style={{ fontFamily: 'Inter_400Regular' }}
>
{dueDate ? dueDate.toLocaleDateString() : t('task.dueDate')}
@ -187,21 +178,84 @@ export default function NewTaskScreen() {
)}
</Pressable>
{showDatePicker && (
<DateTimePicker
value={dueDate ?? new Date()}
mode="date"
display="default"
onChange={handleDateChange}
/>
<DateTimePicker value={dueDate ?? new Date()} mode="date" display="default" onChange={handleDateChange} />
)}
{/* Recurrence */}
<Text
className={`mb-2 mt-6 text-xs uppercase tracking-wide ${isDark ? 'text-[#A0A0A0]' : 'text-[#6B6B6B]'}`}
style={{ fontFamily: 'Inter_600SemiBold' }}
>
{t('recurrence.label')}
</Text>
<View className="flex-row flex-wrap">
<Pressable
onPress={() => setRecurrence(null)}
className={`mb-1 mr-2 rounded-full border px-3 py-1.5 ${
recurrence === null ? 'border-bleu bg-bleu/10' : isDark ? 'border-[#3A3A3A]' : 'border-[#E5E7EB]'
}`}
>
<Text
className="text-sm"
style={{
fontFamily: recurrence === null ? 'Inter_600SemiBold' : 'Inter_400Regular',
color: recurrence === null ? colors.bleu.DEFAULT : isDark ? '#A0A0A0' : '#6B6B6B',
}}
>
{t('recurrence.none')}
</Text>
</Pressable>
{RECURRENCE_OPTIONS.map((opt) => (
<Pressable
key={opt}
onPress={() => setRecurrence(recurrence === opt ? null : opt)}
className={`mb-1 mr-2 flex-row items-center rounded-full border px-3 py-1.5 ${
recurrence === opt ? 'border-bleu bg-bleu/10' : isDark ? 'border-[#3A3A3A]' : 'border-[#E5E7EB]'
}`}
>
<Repeat size={12} color={recurrence === opt ? colors.bleu.DEFAULT : isDark ? '#A0A0A0' : '#6B6B6B'} />
<Text
className="ml-1 text-sm"
style={{
fontFamily: recurrence === opt ? 'Inter_600SemiBold' : 'Inter_400Regular',
color: recurrence === opt ? colors.bleu.DEFAULT : isDark ? '#A0A0A0' : '#6B6B6B',
}}
>
{t(`recurrence.${opt}`)}
</Text>
</Pressable>
))}
</View>
{/* Tags */}
{availableTags.length > 0 && (
<>
<Text
className={`mb-2 mt-6 text-xs uppercase tracking-wide ${isDark ? 'text-[#A0A0A0]' : 'text-[#6B6B6B]'}`}
style={{ fontFamily: 'Inter_600SemiBold' }}
>
{t('tag.tags')}
</Text>
<View className="flex-row flex-wrap">
{availableTags.map((tag) => (
<TagChip
key={tag.id}
name={tag.name}
color={tag.color}
isDark={isDark}
selected={selectedTagIds.includes(tag.id)}
onPress={() => toggleTag(tag.id)}
/>
))}
</View>
</>
)}
{/* List selector */}
{lists.length > 1 && (
<>
<Text
className={`mb-2 mt-6 text-xs uppercase tracking-wide ${
isDark ? 'text-[#A0A0A0]' : 'text-[#6B6B6B]'
}`}
className={`mb-2 mt-6 text-xs uppercase tracking-wide ${isDark ? 'text-[#A0A0A0]' : 'text-[#6B6B6B]'}`}
style={{ fontFamily: 'Inter_600SemiBold' }}
>
{t('nav.lists')}
@ -212,24 +266,14 @@ export default function NewTaskScreen() {
key={list.id}
onPress={() => setSelectedListId(list.id)}
className={`mb-2 mr-2 rounded-full border px-3 py-1.5 ${
selectedListId === list.id
? 'border-bleu bg-bleu/10'
: isDark
? 'border-[#3A3A3A]'
: 'border-[#E5E7EB]'
selectedListId === list.id ? 'border-bleu bg-bleu/10' : isDark ? 'border-[#3A3A3A]' : 'border-[#E5E7EB]'
}`}
>
<Text
className={`text-sm ${
selectedListId === list.id
? 'text-bleu'
: isDark
? 'text-[#A0A0A0]'
: 'text-[#6B6B6B]'
}`}
className="text-sm"
style={{
fontFamily:
selectedListId === list.id ? 'Inter_600SemiBold' : 'Inter_400Regular',
fontFamily: selectedListId === list.id ? 'Inter_600SemiBold' : 'Inter_400Regular',
color: selectedListId === list.id ? colors.bleu.DEFAULT : isDark ? '#A0A0A0' : '#6B6B6B',
}}
>
{list.isInbox ? t('list.inbox') : list.name}
@ -239,6 +283,8 @@ export default function NewTaskScreen() {
</View>
</>
)}
<View className="h-24" />
</ScrollView>
</View>
);

View file

@ -0,0 +1,234 @@
import { useState, useEffect } from 'react';
import { View, Text, Pressable, Modal, ScrollView } from 'react-native';
import { Filter, X } from 'lucide-react-native';
import { useTranslation } from 'react-i18next';
import { colors } from '@/src/theme/colors';
import { useTaskStore, type FilterCompleted, type FilterDueDate } from '@/src/stores/useTaskStore';
import { getPriorityOptions } from '@/src/lib/priority';
import { getAllTags } from '@/src/db/repository/tags';
import TagChip from '@/src/components/task/TagChip';
interface FilterMenuProps {
visible: boolean;
onClose: () => void;
isDark: boolean;
}
const completedOptions: { value: FilterCompleted; labelKey: string }[] = [
{ value: 'all', labelKey: 'filter.all' },
{ value: 'active', labelKey: 'filter.active' },
{ value: 'completed', labelKey: 'filter.completed' },
];
const dueDateOptions: { value: FilterDueDate; labelKey: string }[] = [
{ value: 'all', labelKey: 'filter.all' },
{ value: 'today', labelKey: 'filter.today' },
{ value: 'week', labelKey: 'filter.thisWeek' },
{ value: 'overdue', labelKey: 'filter.overdue' },
{ value: 'noDate', labelKey: 'filter.noDate' },
];
export default function FilterMenu({ visible, onClose, isDark }: FilterMenuProps) {
const { t } = useTranslation();
const {
filterPriority, filterTag, filterCompleted, filterDueDate,
setFilterPriority, setFilterTag, setFilterCompleted, setFilterDueDate, clearFilters,
} = useTaskStore();
const [availableTags, setAvailableTags] = useState<{ id: string; name: string; color: string }[]>([]);
const priorityOpts = getPriorityOptions(isDark);
useEffect(() => {
if (visible) {
getAllTags().then(setAvailableTags);
}
}, [visible]);
return (
<Modal visible={visible} transparent animationType="fade">
<Pressable onPress={onClose} className="flex-1 justify-end bg-black/40">
<Pressable
onPress={(e) => e.stopPropagation()}
className={`max-h-[70%] rounded-t-2xl px-4 pb-8 pt-4 ${isDark ? 'bg-[#2A2A2A]' : 'bg-white'}`}
>
<ScrollView showsVerticalScrollIndicator={false}>
{/* Header */}
<View className="mb-4 flex-row items-center justify-between">
<Text
className={`text-lg ${isDark ? 'text-[#F5F5F5]' : 'text-[#1A1A1A]'}`}
style={{ fontFamily: 'Inter_600SemiBold' }}
>
{t('filter.filter')}
</Text>
<View className="flex-row items-center">
<Pressable onPress={clearFilters} className="mr-3">
<Text className="text-sm text-bleu" style={{ fontFamily: 'Inter_500Medium' }}>
Reset
</Text>
</Pressable>
<Pressable onPress={onClose} className="p-1">
<X size={20} color={isDark ? '#A0A0A0' : '#6B6B6B'} />
</Pressable>
</View>
</View>
{/* Status filter */}
<Text
className={`mb-2 text-xs uppercase tracking-wide ${isDark ? 'text-[#A0A0A0]' : 'text-[#6B6B6B]'}`}
style={{ fontFamily: 'Inter_600SemiBold' }}
>
{t('task.completed')}
</Text>
<View className="mb-4 flex-row">
{completedOptions.map((opt) => (
<Pressable
key={opt.value}
onPress={() => setFilterCompleted(opt.value)}
className={`mr-2 rounded-full border px-3 py-1.5 ${
filterCompleted === opt.value
? 'border-bleu bg-bleu/10'
: isDark
? 'border-[#3A3A3A]'
: 'border-[#E5E7EB]'
}`}
>
<Text
className="text-sm"
style={{
fontFamily: filterCompleted === opt.value ? 'Inter_600SemiBold' : 'Inter_400Regular',
color: filterCompleted === opt.value ? colors.bleu.DEFAULT : isDark ? '#F5F5F5' : '#1A1A1A',
}}
>
{t(opt.labelKey)}
</Text>
</Pressable>
))}
</View>
{/* Priority filter */}
<Text
className={`mb-2 text-xs uppercase tracking-wide ${isDark ? 'text-[#A0A0A0]' : 'text-[#6B6B6B]'}`}
style={{ fontFamily: 'Inter_600SemiBold' }}
>
{t('task.priority')}
</Text>
<View className="mb-4 flex-row">
<Pressable
onPress={() => setFilterPriority(null)}
className={`mr-2 rounded-full border px-3 py-1.5 ${
filterPriority === null
? 'border-bleu bg-bleu/10'
: isDark
? 'border-[#3A3A3A]'
: 'border-[#E5E7EB]'
}`}
>
<Text
className="text-sm"
style={{
fontFamily: filterPriority === null ? 'Inter_600SemiBold' : 'Inter_400Regular',
color: filterPriority === null ? colors.bleu.DEFAULT : isDark ? '#F5F5F5' : '#1A1A1A',
}}
>
{t('filter.all')}
</Text>
</Pressable>
{priorityOpts.slice(1).map((opt) => (
<Pressable
key={opt.value}
onPress={() => setFilterPriority(filterPriority === opt.value ? null : opt.value)}
className={`mr-2 rounded-full border px-3 py-1.5 ${
filterPriority === opt.value
? 'border-transparent'
: isDark
? 'border-[#3A3A3A]'
: 'border-[#E5E7EB]'
}`}
style={filterPriority === opt.value ? { backgroundColor: opt.color + '20' } : undefined}
>
<View className="flex-row items-center">
<View className="mr-1.5 h-2.5 w-2.5 rounded-full" style={{ backgroundColor: opt.color }} />
<Text
className="text-sm"
style={{
fontFamily: filterPriority === opt.value ? 'Inter_600SemiBold' : 'Inter_400Regular',
color: filterPriority === opt.value ? opt.color : isDark ? '#F5F5F5' : '#1A1A1A',
}}
>
{t(opt.labelKey)}
</Text>
</View>
</Pressable>
))}
</View>
{/* Due date filter */}
<Text
className={`mb-2 text-xs uppercase tracking-wide ${isDark ? 'text-[#A0A0A0]' : 'text-[#6B6B6B]'}`}
style={{ fontFamily: 'Inter_600SemiBold' }}
>
{t('task.dueDate')}
</Text>
<View className="mb-4 flex-row flex-wrap">
{dueDateOptions.map((opt) => (
<Pressable
key={opt.value}
onPress={() => setFilterDueDate(opt.value)}
className={`mb-1 mr-2 rounded-full border px-3 py-1.5 ${
filterDueDate === opt.value
? 'border-bleu bg-bleu/10'
: isDark
? 'border-[#3A3A3A]'
: 'border-[#E5E7EB]'
}`}
>
<Text
className="text-sm"
style={{
fontFamily: filterDueDate === opt.value ? 'Inter_600SemiBold' : 'Inter_400Regular',
color: filterDueDate === opt.value ? colors.bleu.DEFAULT : isDark ? '#F5F5F5' : '#1A1A1A',
}}
>
{t(opt.labelKey)}
</Text>
</Pressable>
))}
</View>
{/* Tag filter */}
{availableTags.length > 0 && (
<>
<Text
className={`mb-2 text-xs uppercase tracking-wide ${isDark ? 'text-[#A0A0A0]' : 'text-[#6B6B6B]'}`}
style={{ fontFamily: 'Inter_600SemiBold' }}
>
{t('tag.tags')}
</Text>
<View className="flex-row flex-wrap">
<TagChip
name={t('filter.all')}
color={colors.bleu.DEFAULT}
isDark={isDark}
selected={filterTag === null}
onPress={() => setFilterTag(null)}
/>
{availableTags.map((tag) => (
<TagChip
key={tag.id}
name={tag.name}
color={tag.color}
isDark={isDark}
selected={filterTag === tag.id}
onPress={() => setFilterTag(filterTag === tag.id ? null : tag.id)}
/>
))}
</View>
</>
)}
</ScrollView>
</Pressable>
</Pressable>
</Modal>
);
}

View file

@ -0,0 +1,86 @@
import { View, Text, Pressable, Modal } from 'react-native';
import { ArrowUpDown, ArrowUp, ArrowDown, X } from 'lucide-react-native';
import { useTranslation } from 'react-i18next';
import { colors } from '@/src/theme/colors';
import { useTaskStore, type SortBy } from '@/src/stores/useTaskStore';
interface SortMenuProps {
visible: boolean;
onClose: () => void;
isDark: boolean;
}
const sortOptions: { value: SortBy; labelKey: string }[] = [
{ value: 'position', labelKey: 'sort.position' },
{ value: 'priority', labelKey: 'sort.priority' },
{ value: 'dueDate', labelKey: 'sort.dueDate' },
{ value: 'title', labelKey: 'sort.title' },
{ value: 'createdAt', labelKey: 'sort.createdAt' },
];
export default function SortMenu({ visible, onClose, isDark }: SortMenuProps) {
const { t } = useTranslation();
const { sortBy, sortOrder, setSortBy, setSortOrder } = useTaskStore();
return (
<Modal visible={visible} transparent animationType="fade">
<Pressable onPress={onClose} className="flex-1 justify-end bg-black/40">
<Pressable
onPress={(e) => e.stopPropagation()}
className={`rounded-t-2xl px-4 pb-8 pt-4 ${isDark ? 'bg-[#2A2A2A]' : 'bg-white'}`}
>
{/* Header */}
<View className="mb-4 flex-row items-center justify-between">
<Text
className={`text-lg ${isDark ? 'text-[#F5F5F5]' : 'text-[#1A1A1A]'}`}
style={{ fontFamily: 'Inter_600SemiBold' }}
>
{t('sort.sortBy')}
</Text>
<Pressable onPress={onClose} className="p-1">
<X size={20} color={isDark ? '#A0A0A0' : '#6B6B6B'} />
</Pressable>
</View>
{/* Sort options */}
{sortOptions.map((opt) => {
const isActive = sortBy === opt.value;
return (
<Pressable
key={opt.value}
onPress={() => {
if (isActive) {
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc');
} else {
setSortBy(opt.value);
// Priority defaults to desc (high first), others to asc
setSortOrder(opt.value === 'priority' ? 'desc' : 'asc');
}
}}
className={`flex-row items-center justify-between rounded-lg px-3 py-3 ${
isActive ? (isDark ? 'bg-[#3A3A3A]' : 'bg-creme-dark') : ''
}`}
>
<Text
className="text-base"
style={{
fontFamily: isActive ? 'Inter_600SemiBold' : 'Inter_400Regular',
color: isActive ? colors.bleu.DEFAULT : isDark ? '#F5F5F5' : '#1A1A1A',
}}
>
{t(opt.labelKey)}
</Text>
{isActive && (
sortOrder === 'asc'
? <ArrowUp size={18} color={colors.bleu.DEFAULT} />
: <ArrowDown size={18} color={colors.bleu.DEFAULT} />
)}
</Pressable>
);
})}
</Pressable>
</Pressable>
</Modal>
);
}

View file

@ -0,0 +1,51 @@
import { View, Text, Pressable } from 'react-native';
import { X } from 'lucide-react-native';
interface TagChipProps {
name: string;
color: string;
isDark: boolean;
onRemove?: () => void;
onPress?: () => void;
small?: boolean;
selected?: boolean;
}
export default function TagChip({ name, color, isDark, onRemove, onPress, small, selected }: TagChipProps) {
const Wrapper = onPress ? Pressable : View;
return (
<Wrapper
onPress={onPress}
className={`mr-1.5 mb-1.5 flex-row items-center rounded-full border ${
small ? 'px-2 py-0.5' : 'px-2.5 py-1'
} ${
selected
? 'border-transparent'
: isDark
? 'border-[#3A3A3A]'
: 'border-[#E5E7EB]'
}`}
style={selected ? { backgroundColor: color + '25' } : undefined}
>
<View
className={`rounded-full ${small ? 'mr-1 h-2 w-2' : 'mr-1.5 h-2.5 w-2.5'}`}
style={{ backgroundColor: color }}
/>
<Text
className={`${small ? 'text-xs' : 'text-sm'}`}
style={{
fontFamily: selected ? 'Inter_600SemiBold' : 'Inter_400Regular',
color: selected ? color : isDark ? '#F5F5F5' : '#1A1A1A',
}}
>
{name}
</Text>
{onRemove && (
<Pressable onPress={onRemove} className="ml-1 p-0.5">
<X size={small ? 10 : 12} color={isDark ? '#A0A0A0' : '#6B6B6B'} />
</Pressable>
)}
</Wrapper>
);
}

View file

@ -1,5 +1,5 @@
import { View, Text, Pressable } from 'react-native';
import { Check, Trash2 } from 'lucide-react-native';
import { Check, Trash2, Repeat } from 'lucide-react-native';
import { format } from 'date-fns';
import { fr, enUS } from 'date-fns/locale';
import { useTranslation } from 'react-i18next';
@ -14,6 +14,8 @@ interface TaskItemProps {
completed: boolean;
priority: number;
dueDate: Date | null;
recurrence?: string | null;
tags?: { id: string; name: string; color: string }[];
};
isDark: boolean;
onToggle: () => void;
@ -35,7 +37,7 @@ export default function TaskItem({ task, isDark, onToggle, onPress, onDelete }:
{/* Checkbox */}
<Pressable
onPress={onToggle}
className={`mr-3 h-6 w-6 items-center justify-center rounded-full border-2`}
className="mr-3 h-6 w-6 items-center justify-center rounded-full border-2"
style={{
borderColor: task.completed ? colors.bleu.DEFAULT : getPriorityColor(task.priority, isDark),
backgroundColor: task.completed ? colors.bleu.DEFAULT : 'transparent',
@ -59,14 +61,35 @@ export default function TaskItem({ task, isDark, onToggle, onPress, onDelete }:
>
{task.title}
</Text>
{task.dueDate && (
<Text
className={`mt-0.5 text-xs ${isDark ? 'text-[#A0A0A0]' : 'text-[#6B6B6B]'}`}
style={{ fontFamily: 'Inter_400Regular' }}
>
{format(new Date(task.dueDate), 'd MMM yyyy', { locale: dateLocale })}
</Text>
)}
{/* Meta row: date, tags, recurrence */}
<View className="mt-0.5 flex-row flex-wrap items-center">
{task.dueDate && (
<Text
className={`mr-2 text-xs ${isDark ? 'text-[#A0A0A0]' : 'text-[#6B6B6B]'}`}
style={{ fontFamily: 'Inter_400Regular' }}
>
{format(new Date(task.dueDate), 'd MMM', { locale: dateLocale })}
</Text>
)}
{task.recurrence && (
<View className="mr-2">
<Repeat size={11} color={isDark ? '#A0A0A0' : '#6B6B6B'} />
</View>
)}
{task.tags && task.tags.length > 0 && task.tags.map((tag) => (
<View
key={tag.id}
className="mr-1 mb-0.5 flex-row items-center rounded-full px-1.5 py-0.5"
style={{ backgroundColor: tag.color + '20' }}
>
<View className="mr-1 h-1.5 w-1.5 rounded-full" style={{ backgroundColor: tag.color }} />
<Text className="text-[10px]" style={{ color: tag.color, fontFamily: 'Inter_500Medium' }}>
{tag.name}
</Text>
</View>
))}
</View>
</View>
{/* Priority dot */}

View file

@ -0,0 +1,16 @@
CREATE TABLE `tags` (
`id` text PRIMARY KEY NOT NULL,
`name` text NOT NULL,
`color` text DEFAULT '#4A90A4' NOT NULL,
`created_at` integer NOT NULL
);
--> statement-breakpoint
CREATE TABLE `task_tags` (
`task_id` text NOT NULL,
`tag_id` text NOT NULL,
PRIMARY KEY(`task_id`, `tag_id`),
FOREIGN KEY (`task_id`) REFERENCES `tasks`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`tag_id`) REFERENCES `tags`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
ALTER TABLE `tasks` ADD `recurrence` text;

View file

@ -0,0 +1,302 @@
{
"version": "6",
"dialect": "sqlite",
"id": "0d7c6471-bc3c-4111-8166-9729923db06b",
"prevId": "67121461-af03-441b-8c25-f88e630334d7",
"tables": {
"lists": {
"name": "lists",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"color": {
"name": "color",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"icon": {
"name": "icon",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"position": {
"name": "position",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"is_inbox": {
"name": "is_inbox",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"tags": {
"name": "tags",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"color": {
"name": "color",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'#4A90A4'"
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"task_tags": {
"name": "task_tags",
"columns": {
"task_id": {
"name": "task_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"tag_id": {
"name": "tag_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"task_tags_task_id_tasks_id_fk": {
"name": "task_tags_task_id_tasks_id_fk",
"tableFrom": "task_tags",
"tableTo": "tasks",
"columnsFrom": [
"task_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"task_tags_tag_id_tags_id_fk": {
"name": "task_tags_tag_id_tags_id_fk",
"tableFrom": "task_tags",
"tableTo": "tags",
"columnsFrom": [
"tag_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"task_tags_task_id_tag_id_pk": {
"columns": [
"task_id",
"tag_id"
],
"name": "task_tags_task_id_tag_id_pk"
}
},
"uniqueConstraints": {},
"checkConstraints": {}
},
"tasks": {
"name": "tasks",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"notes": {
"name": "notes",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"completed": {
"name": "completed",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"completed_at": {
"name": "completed_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"priority": {
"name": "priority",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"due_date": {
"name": "due_date",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"list_id": {
"name": "list_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"parent_id": {
"name": "parent_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"position": {
"name": "position",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"recurrence": {
"name": "recurrence",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"tasks_list_id_lists_id_fk": {
"name": "tasks_list_id_lists_id_fk",
"tableFrom": "tasks",
"tableTo": "lists",
"columnsFrom": [
"list_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View file

@ -8,6 +8,13 @@
"when": 1771632828781,
"tag": "0000_bitter_phalanx",
"breakpoints": true
},
{
"idx": 1,
"version": "6",
"when": 1771637151512,
"tag": "0001_sticky_arachne",
"breakpoints": true
}
]
}

View file

@ -2,11 +2,13 @@
import journal from './meta/_journal.json';
import m0000 from './0000_bitter_phalanx.sql';
import m0001 from './0001_sticky_arachne.sql';
export default {
journal,
migrations: {
m0000
m0000,
m0001
}
}

56
src/db/repository/tags.ts Normal file
View file

@ -0,0 +1,56 @@
import { eq, and } from 'drizzle-orm';
import { db } from '../client';
import { tags, taskTags } from '../schema';
import { randomUUID } from '@/src/lib/uuid';
export async function getAllTags() {
return db.select().from(tags).orderBy(tags.name);
}
export async function createTag(name: string, color: string) {
const id = randomUUID();
await db.insert(tags).values({
id,
name,
color,
createdAt: new Date(),
});
return id;
}
export async function updateTag(id: string, name: string, color: string) {
await db.update(tags).set({ name, color }).where(eq(tags.id, id));
}
export async function deleteTag(id: string) {
await db.delete(taskTags).where(eq(taskTags.tagId, id));
await db.delete(tags).where(eq(tags.id, id));
}
export async function getTagsForTask(taskId: string) {
const rows = await db
.select({ tag: tags })
.from(taskTags)
.innerJoin(tags, eq(taskTags.tagId, tags.id))
.where(eq(taskTags.taskId, taskId));
return rows.map((r) => r.tag);
}
export async function setTagsForTask(taskId: string, tagIds: string[]) {
await db.delete(taskTags).where(eq(taskTags.taskId, taskId));
if (tagIds.length > 0) {
await db.insert(taskTags).values(
tagIds.map((tagId) => ({ taskId, tagId }))
);
}
}
export async function addTagToTask(taskId: string, tagId: string) {
await db.insert(taskTags).values({ taskId, tagId }).onConflictDoNothing();
}
export async function removeTagFromTask(taskId: string, tagId: string) {
await db
.delete(taskTags)
.where(and(eq(taskTags.taskId, taskId), eq(taskTags.tagId, tagId)));
}

View file

@ -1,14 +1,97 @@
import { eq, and, isNull, desc, asc } from 'drizzle-orm';
import { eq, and, isNull, desc, asc, gte, lte, lt, sql, inArray } from 'drizzle-orm';
import { db } from '../client';
import { tasks } from '../schema';
import { tasks, taskTags } from '../schema';
import { randomUUID } from '@/src/lib/uuid';
import { getNextOccurrence, type RecurrenceType } from '@/src/lib/recurrence';
import { startOfDay, endOfDay, endOfWeek, startOfWeek } from 'date-fns';
import type { SortBy, SortOrder, FilterCompleted, FilterDueDate } from '@/src/stores/useTaskStore';
export interface TaskFilters {
sortBy?: SortBy;
sortOrder?: SortOrder;
filterPriority?: number | null;
filterTag?: string | null;
filterCompleted?: FilterCompleted;
filterDueDate?: FilterDueDate;
}
export async function getTasksByList(listId: string, filters?: TaskFilters) {
const conditions = [eq(tasks.listId, listId), isNull(tasks.parentId)];
// Filter: completed status
if (filters?.filterCompleted === 'active') {
conditions.push(eq(tasks.completed, false));
} else if (filters?.filterCompleted === 'completed') {
conditions.push(eq(tasks.completed, true));
}
// Filter: priority
if (filters?.filterPriority != null) {
conditions.push(eq(tasks.priority, filters.filterPriority));
}
// Filter: due date
if (filters?.filterDueDate && filters.filterDueDate !== 'all') {
const now = new Date();
const todayStart = startOfDay(now);
const todayEnd = endOfDay(now);
switch (filters.filterDueDate) {
case 'today':
conditions.push(gte(tasks.dueDate, todayStart));
conditions.push(lte(tasks.dueDate, todayEnd));
break;
case 'week':
conditions.push(gte(tasks.dueDate, startOfWeek(now, { weekStartsOn: 1 })));
conditions.push(lte(tasks.dueDate, endOfWeek(now, { weekStartsOn: 1 })));
break;
case 'overdue':
conditions.push(lt(tasks.dueDate, todayStart));
conditions.push(eq(tasks.completed, false));
break;
case 'noDate':
conditions.push(isNull(tasks.dueDate));
break;
}
}
// If filtering by tag, get task IDs that have that tag first
let tagTaskIds: string[] | null = null;
if (filters?.filterTag) {
const taggedRows = await db
.select({ taskId: taskTags.taskId })
.from(taskTags)
.where(eq(taskTags.tagId, filters.filterTag));
tagTaskIds = taggedRows.map((r) => r.taskId);
if (tagTaskIds.length === 0) return [];
conditions.push(inArray(tasks.id, tagTaskIds));
}
// Sort
const orderClauses = getOrderClauses(filters?.sortBy ?? 'position', filters?.sortOrder ?? 'asc');
export async function getTasksByList(listId: string) {
return db
.select()
.from(tasks)
.where(and(eq(tasks.listId, listId), isNull(tasks.parentId)))
.orderBy(asc(tasks.position), desc(tasks.createdAt));
.where(and(...conditions))
.orderBy(...orderClauses);
}
function getOrderClauses(sortBy: SortBy, sortOrder: SortOrder) {
const dir = sortOrder === 'asc' ? asc : desc;
switch (sortBy) {
case 'priority':
return [dir(tasks.priority), asc(tasks.position)];
case 'dueDate':
return [dir(tasks.dueDate), asc(tasks.position)];
case 'title':
return [dir(tasks.title)];
case 'createdAt':
return [dir(tasks.createdAt)];
case 'position':
default:
return [asc(tasks.position), desc(tasks.createdAt)];
}
}
export async function getSubtasks(parentId: string) {
@ -31,6 +114,7 @@ export async function createTask(data: {
priority?: number;
dueDate?: Date;
parentId?: string;
recurrence?: string;
}) {
const now = new Date();
const id = randomUUID();
@ -50,6 +134,7 @@ export async function createTask(data: {
parentId: data.parentId ?? null,
completed: false,
position: maxPosition + 1,
recurrence: data.recurrence ?? null,
createdAt: now,
updatedAt: now,
});
@ -65,6 +150,7 @@ export async function updateTask(
dueDate?: Date | null;
listId?: string;
completed?: boolean;
recurrence?: string | null;
}
) {
const updates: Record<string, unknown> = { ...data, updatedAt: new Date() };
@ -79,14 +165,34 @@ export async function updateTask(
export async function toggleComplete(id: string) {
const task = await getTaskById(id);
if (!task) return;
await updateTask(id, { completed: !task.completed });
const nowCompleting = !task.completed;
await updateTask(id, { completed: nowCompleting });
// If completing a recurring task, create the next occurrence
if (nowCompleting && task.recurrence && task.dueDate) {
const nextDate = getNextOccurrence(
new Date(task.dueDate),
task.recurrence as RecurrenceType
);
await createTask({
title: task.title,
listId: task.listId,
notes: task.notes ?? undefined,
priority: task.priority,
dueDate: nextDate,
recurrence: task.recurrence,
});
}
}
export async function deleteTask(id: string) {
// Delete subtasks first
const subtasks = await getSubtasks(id);
for (const sub of subtasks) {
await db.delete(taskTags).where(eq(taskTags.taskId, sub.id));
await db.delete(tasks).where(eq(tasks.id, sub.id));
}
await db.delete(taskTags).where(eq(taskTags.taskId, id));
await db.delete(tasks).where(eq(tasks.id, id));
}

View file

@ -1,4 +1,4 @@
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';
import { sqliteTable, text, integer, primaryKey } from 'drizzle-orm/sqlite-core';
export const lists = sqliteTable('lists', {
id: text('id').primaryKey(),
@ -22,6 +22,25 @@ export const tasks = sqliteTable('tasks', {
listId: text('list_id').notNull().references(() => lists.id),
parentId: text('parent_id'),
position: integer('position').notNull().default(0),
recurrence: text('recurrence'), // 'daily' | 'weekly' | 'monthly' | 'yearly' | null
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
});
export const tags = sqliteTable('tags', {
id: text('id').primaryKey(),
name: text('name').notNull(),
color: text('color').notNull().default('#4A90A4'),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
});
export const taskTags = sqliteTable(
'task_tags',
{
taskId: text('task_id').notNull().references(() => tasks.id, { onDelete: 'cascade' }),
tagId: text('tag_id').notNull().references(() => tags.id, { onDelete: 'cascade' }),
},
(table) => [
primaryKey({ columns: [table.taskId, table.tagId] }),
]
);

View file

@ -6,7 +6,8 @@
"edit": "Edit",
"add": "Add",
"done": "Done",
"confirm": "Confirm"
"confirm": "Confirm",
"none": "None"
},
"task": {
"title": "Title",
@ -40,6 +41,47 @@
"taskCount_one": "{{count}} task",
"taskCount_other": "{{count}} tasks"
},
"tag": {
"tags": "Tags",
"newTag": "New tag",
"namePlaceholder": "Tag name...",
"deleteConfirm": "Are you sure you want to delete this tag?",
"manage": "Manage tags",
"addTag": "Add a tag",
"noTags": "No tags",
"editTag": "Edit tag"
},
"sort": {
"sortBy": "Sort by",
"position": "Position",
"priority": "Priority",
"dueDate": "Due date",
"title": "Title",
"createdAt": "Date created",
"ascending": "Ascending",
"descending": "Descending",
"groupBy": "Group by",
"noGroup": "None"
},
"filter": {
"filter": "Filter",
"all": "All",
"active": "Active",
"completed": "Completed",
"today": "Today",
"thisWeek": "This week",
"overdue": "Overdue",
"noDate": "No date",
"activeFilters": "Active filters"
},
"recurrence": {
"label": "Recurrence",
"none": "None",
"daily": "Daily",
"weekly": "Weekly",
"monthly": "Monthly",
"yearly": "Yearly"
},
"settings": {
"title": "Settings",
"theme": "Theme",

View file

@ -6,7 +6,8 @@
"edit": "Modifier",
"add": "Ajouter",
"done": "Terminé",
"confirm": "Confirmer"
"confirm": "Confirmer",
"none": "Aucun"
},
"task": {
"title": "Titre",
@ -40,6 +41,47 @@
"taskCount_one": "{{count}} tâche",
"taskCount_other": "{{count}} tâches"
},
"tag": {
"tags": "Étiquettes",
"newTag": "Nouvelle étiquette",
"namePlaceholder": "Nom de l'étiquette...",
"deleteConfirm": "Voulez-vous vraiment supprimer cette étiquette ?",
"manage": "Gérer les étiquettes",
"addTag": "Ajouter une étiquette",
"noTags": "Aucune étiquette",
"editTag": "Modifier l'étiquette"
},
"sort": {
"sortBy": "Trier par",
"position": "Position",
"priority": "Priorité",
"dueDate": "Date d'échéance",
"title": "Titre",
"createdAt": "Date de création",
"ascending": "Croissant",
"descending": "Décroissant",
"groupBy": "Grouper par",
"noGroup": "Aucun"
},
"filter": {
"filter": "Filtrer",
"all": "Toutes",
"active": "Actives",
"completed": "Terminées",
"today": "Aujourd'hui",
"thisWeek": "Cette semaine",
"overdue": "En retard",
"noDate": "Sans date",
"activeFilters": "Filtres actifs"
},
"recurrence": {
"label": "Récurrence",
"none": "Aucune",
"daily": "Quotidien",
"weekly": "Hebdomadaire",
"monthly": "Mensuel",
"yearly": "Annuel"
},
"settings": {
"title": "Paramètres",
"theme": "Thème",

18
src/lib/recurrence.ts Normal file
View file

@ -0,0 +1,18 @@
import { addDays, addWeeks, addMonths, addYears } from 'date-fns';
export type RecurrenceType = 'daily' | 'weekly' | 'monthly' | 'yearly';
export const RECURRENCE_OPTIONS: RecurrenceType[] = ['daily', 'weekly', 'monthly', 'yearly'];
export function getNextOccurrence(dueDate: Date, recurrence: RecurrenceType): Date {
switch (recurrence) {
case 'daily':
return addDays(dueDate, 1);
case 'weekly':
return addWeeks(dueDate, 1);
case 'monthly':
return addMonths(dueDate, 1);
case 'yearly':
return addYears(dueDate, 1);
}
}

View file

@ -0,0 +1,64 @@
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import AsyncStorage from '@react-native-async-storage/async-storage';
export type SortBy = 'position' | 'priority' | 'dueDate' | 'title' | 'createdAt';
export type SortOrder = 'asc' | 'desc';
export type FilterCompleted = 'all' | 'active' | 'completed';
export type FilterDueDate = 'all' | 'today' | 'week' | 'overdue' | 'noDate';
interface TaskStoreState {
sortBy: SortBy;
sortOrder: SortOrder;
filterPriority: number | null;
filterTag: string | null;
filterCompleted: FilterCompleted;
filterDueDate: FilterDueDate;
setSortBy: (sortBy: SortBy) => void;
setSortOrder: (sortOrder: SortOrder) => void;
setFilterPriority: (priority: number | null) => void;
setFilterTag: (tagId: string | null) => void;
setFilterCompleted: (filter: FilterCompleted) => void;
setFilterDueDate: (filter: FilterDueDate) => void;
clearFilters: () => void;
hasActiveFilters: () => boolean;
}
export const useTaskStore = create<TaskStoreState>()(
persist(
(set, get) => ({
sortBy: 'position',
sortOrder: 'asc',
filterPriority: null,
filterTag: null,
filterCompleted: 'all',
filterDueDate: 'all',
setSortBy: (sortBy) => set({ sortBy }),
setSortOrder: (sortOrder) => set({ sortOrder }),
setFilterPriority: (filterPriority) => set({ filterPriority }),
setFilterTag: (filterTag) => set({ filterTag }),
setFilterCompleted: (filterCompleted) => set({ filterCompleted }),
setFilterDueDate: (filterDueDate) => set({ filterDueDate }),
clearFilters: () =>
set({
filterPriority: null,
filterTag: null,
filterCompleted: 'all',
filterDueDate: 'all',
}),
hasActiveFilters: () => {
const s = get();
return (
s.filterPriority !== null ||
s.filterTag !== null ||
s.filterCompleted !== 'all' ||
s.filterDueDate !== 'all'
);
},
}),
{
name: 'simpl-liste-tasks',
storage: createJSONStorage(() => AsyncStorage),
}
)
);