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:
parent
72f4a50e2b
commit
5fc1365ced
20 changed files with 1582 additions and 251 deletions
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
166
app/task/new.tsx
166
app/task/new.tsx
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
234
src/components/FilterMenu.tsx
Normal file
234
src/components/FilterMenu.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
86
src/components/SortMenu.tsx
Normal file
86
src/components/SortMenu.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
51
src/components/task/TagChip.tsx
Normal file
51
src/components/task/TagChip.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 */}
|
||||
|
|
|
|||
16
src/db/migrations/0001_sticky_arachne.sql
Normal file
16
src/db/migrations/0001_sticky_arachne.sql
Normal 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;
|
||||
302
src/db/migrations/meta/0001_snapshot.json
Normal file
302
src/db/migrations/meta/0001_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
|
|
@ -8,6 +8,13 @@
|
|||
"when": 1771632828781,
|
||||
"tag": "0000_bitter_phalanx",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "6",
|
||||
"when": 1771637151512,
|
||||
"tag": "0001_sticky_arachne",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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
56
src/db/repository/tags.ts
Normal 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)));
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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] }),
|
||||
]
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
18
src/lib/recurrence.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
64
src/stores/useTaskStore.ts
Normal file
64
src/stores/useTaskStore.ts
Normal 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),
|
||||
}
|
||||
)
|
||||
);
|
||||
Loading…
Reference in a new issue