feat: add client-side task search in inbox and list detail
Search icon in toolbar opens a text input that filters tasks by title and notes in real-time. Drag-to-reorder is disabled while searching. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
4d62658ae7
commit
3558171bb9
4 changed files with 99 additions and 26 deletions
|
|
@ -1,7 +1,7 @@
|
|||
import { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import { View, Text, Pressable, useColorScheme, Alert } from 'react-native';
|
||||
import { View, Text, Pressable, TextInput, useColorScheme, Alert } from 'react-native';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { Plus, ArrowUpDown, Filter, Download } from 'lucide-react-native';
|
||||
import { Plus, ArrowUpDown, Filter, Download, Search, X } from 'lucide-react-native';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
import DraggableFlatList, { RenderItemParams } from 'react-native-draggable-flatlist';
|
||||
|
|
@ -38,6 +38,8 @@ export default function InboxScreen() {
|
|||
const [tasks, setTasks] = useState<Task[]>([]);
|
||||
const [showSort, setShowSort] = useState(false);
|
||||
const [showFilter, setShowFilter] = useState(false);
|
||||
const [showSearch, setShowSearch] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const systemScheme = useColorScheme();
|
||||
const theme = useSettingsStore((s) => s.theme);
|
||||
const isDark = (theme === 'system' ? systemScheme : theme) === 'dark';
|
||||
|
|
@ -110,7 +112,15 @@ export default function InboxScreen() {
|
|||
};
|
||||
|
||||
const filtersActive = hasActiveFilters();
|
||||
const canDrag = sortBy === 'position';
|
||||
const isSearching = searchQuery.length > 0;
|
||||
const canDrag = sortBy === 'position' && !isSearching;
|
||||
|
||||
const filteredTasks = isSearching
|
||||
? tasks.filter((task) => {
|
||||
const q = searchQuery.toLowerCase();
|
||||
return task.title.toLowerCase().includes(q) || (task.notes?.toLowerCase().includes(q) ?? false);
|
||||
})
|
||||
: tasks;
|
||||
|
||||
const renderItem = ({ item, drag }: RenderItemParams<Task>) => (
|
||||
<SwipeableRow
|
||||
|
|
@ -135,34 +145,56 @@ export default function InboxScreen() {
|
|||
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={handleExportICS} className="mr-3 p-1">
|
||||
<Download size={20} color={isDark ? '#A0A0A0' : '#6B6B6B'} />
|
||||
</Pressable>
|
||||
<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>
|
||||
{showSearch ? (
|
||||
<View className={`flex-row items-center border-b px-4 py-2 ${isDark ? 'border-[#3A3A3A]' : 'border-[#E5E7EB]'}`}>
|
||||
<Search size={18} color={isDark ? '#A0A0A0' : '#6B6B6B'} />
|
||||
<TextInput
|
||||
className={`mx-2 flex-1 text-sm ${isDark ? 'text-[#F5F5F5]' : 'text-[#1A1A1A]'}`}
|
||||
style={{ fontFamily: 'Inter_400Regular', paddingVertical: 4 }}
|
||||
placeholder={t('search.placeholder')}
|
||||
placeholderTextColor={isDark ? '#666' : '#999'}
|
||||
value={searchQuery}
|
||||
onChangeText={setSearchQuery}
|
||||
autoFocus
|
||||
returnKeyType="search"
|
||||
/>
|
||||
<Pressable onPress={() => { setShowSearch(false); setSearchQuery(''); }} className="p-1">
|
||||
<X size={20} color={isDark ? '#A0A0A0' : '#6B6B6B'} />
|
||||
</Pressable>
|
||||
</View>
|
||||
) : (
|
||||
<View className={`flex-row items-center justify-end border-b px-4 py-2 ${isDark ? 'border-[#3A3A3A]' : 'border-[#E5E7EB]'}`}>
|
||||
<Pressable onPress={() => setShowSearch(true)} className="mr-3 p-1">
|
||||
<Search size={20} color={isDark ? '#A0A0A0' : '#6B6B6B'} />
|
||||
</Pressable>
|
||||
<Pressable onPress={handleExportICS} className="mr-3 p-1">
|
||||
<Download size={20} color={isDark ? '#A0A0A0' : '#6B6B6B'} />
|
||||
</Pressable>
|
||||
<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 ? (
|
||||
{filteredTasks.length === 0 ? (
|
||||
<View className="flex-1 items-center justify-center px-8">
|
||||
<Text
|
||||
className={`text-center text-base ${isDark ? 'text-[#A0A0A0]' : 'text-[#6B6B6B]'}`}
|
||||
style={{ fontFamily: 'Inter_400Regular' }}
|
||||
>
|
||||
{filtersActive ? t('empty.list') : t('empty.inbox')}
|
||||
{isSearching ? t('search.noResults', { query: searchQuery }) : filtersActive ? t('empty.list') : t('empty.inbox')}
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||
<DraggableFlatList
|
||||
data={tasks}
|
||||
data={filteredTasks}
|
||||
keyExtractor={(item) => item.id}
|
||||
contentContainerStyle={{ paddingBottom: 100 }}
|
||||
renderItem={renderItem}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import { View, Text, Pressable, useColorScheme, Alert } from 'react-native';
|
||||
import { View, Text, Pressable, TextInput, useColorScheme, Alert } from 'react-native';
|
||||
import { useRouter, useLocalSearchParams } from 'expo-router';
|
||||
import {
|
||||
ArrowLeft, Plus, ArrowUpDown, Filter, Download,
|
||||
ArrowLeft, Plus, ArrowUpDown, Filter, Download, Search, X,
|
||||
List, ShoppingCart, Briefcase, Home, Heart, Star, BookOpen,
|
||||
GraduationCap, Dumbbell, Utensils, Plane, Music, Code, Wrench,
|
||||
Gift, Camera, Palette, Dog, Leaf, Zap,
|
||||
|
|
@ -54,6 +54,8 @@ export default function ListDetailScreen() {
|
|||
const [listIcon, setListIcon] = useState<string | null>(null);
|
||||
const [showSort, setShowSort] = useState(false);
|
||||
const [showFilter, setShowFilter] = useState(false);
|
||||
const [showSearch, setShowSearch] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const systemScheme = useColorScheme();
|
||||
const theme = useSettingsStore((s) => s.theme);
|
||||
const isDark = (theme === 'system' ? systemScheme : theme) === 'dark';
|
||||
|
|
@ -134,7 +136,15 @@ export default function ListDetailScreen() {
|
|||
};
|
||||
|
||||
const filtersActive = hasActiveFilters();
|
||||
const canDrag = sortBy === 'position';
|
||||
const isSearching = searchQuery.length > 0;
|
||||
const canDrag = sortBy === 'position' && !isSearching;
|
||||
|
||||
const filteredTasks = isSearching
|
||||
? tasks.filter((task) => {
|
||||
const q = searchQuery.toLowerCase();
|
||||
return task.title.toLowerCase().includes(q) || (task.notes?.toLowerCase().includes(q) ?? false);
|
||||
})
|
||||
: tasks;
|
||||
|
||||
const renderItem = ({ item, drag }: RenderItemParams<Task>) => (
|
||||
<SwipeableRow
|
||||
|
|
@ -181,6 +191,9 @@ export default function ListDetailScreen() {
|
|||
</Text>
|
||||
</View>
|
||||
<View className="flex-row items-center">
|
||||
<Pressable onPress={() => setShowSearch(true)} className="mr-3 p-1">
|
||||
<Search size={20} color={isDark ? '#A0A0A0' : '#6B6B6B'} />
|
||||
</Pressable>
|
||||
<Pressable onPress={handleExportICS} className="mr-3 p-1">
|
||||
<Download size={20} color={isDark ? '#A0A0A0' : '#6B6B6B'} />
|
||||
</Pressable>
|
||||
|
|
@ -196,19 +209,39 @@ export default function ListDetailScreen() {
|
|||
</View>
|
||||
</View>
|
||||
|
||||
{tasks.length === 0 ? (
|
||||
{/* Search bar */}
|
||||
{showSearch && (
|
||||
<View className={`flex-row items-center border-b px-4 py-2 ${isDark ? 'border-[#3A3A3A]' : 'border-[#E5E7EB]'}`}>
|
||||
<Search size={18} color={isDark ? '#A0A0A0' : '#6B6B6B'} />
|
||||
<TextInput
|
||||
className={`mx-2 flex-1 text-sm ${isDark ? 'text-[#F5F5F5]' : 'text-[#1A1A1A]'}`}
|
||||
style={{ fontFamily: 'Inter_400Regular', paddingVertical: 4 }}
|
||||
placeholder={t('search.placeholder')}
|
||||
placeholderTextColor={isDark ? '#666' : '#999'}
|
||||
value={searchQuery}
|
||||
onChangeText={setSearchQuery}
|
||||
autoFocus
|
||||
returnKeyType="search"
|
||||
/>
|
||||
<Pressable onPress={() => { setShowSearch(false); setSearchQuery(''); }} className="p-1">
|
||||
<X size={20} color={isDark ? '#A0A0A0' : '#6B6B6B'} />
|
||||
</Pressable>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{filteredTasks.length === 0 ? (
|
||||
<View className="flex-1 items-center justify-center px-8">
|
||||
<Text
|
||||
className={`text-center text-base ${isDark ? 'text-[#A0A0A0]' : 'text-[#6B6B6B]'}`}
|
||||
style={{ fontFamily: 'Inter_400Regular' }}
|
||||
>
|
||||
{t('empty.list')}
|
||||
{isSearching ? t('search.noResults', { query: searchQuery }) : t('empty.list')}
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||
<DraggableFlatList
|
||||
data={tasks}
|
||||
data={filteredTasks}
|
||||
keyExtractor={(item) => item.id}
|
||||
contentContainerStyle={{ paddingBottom: 100 }}
|
||||
renderItem={renderItem}
|
||||
|
|
|
|||
|
|
@ -116,6 +116,10 @@
|
|||
"success": "File exported",
|
||||
"noTasks": "No tasks to export"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Search tasks...",
|
||||
"noResults": "No results for \"{{query}}\""
|
||||
},
|
||||
"empty": {
|
||||
"inbox": "No tasks yet.\nTap + to get started.",
|
||||
"list": "This list is empty."
|
||||
|
|
|
|||
|
|
@ -116,6 +116,10 @@
|
|||
"success": "Fichier exporté",
|
||||
"noTasks": "Aucune tâche à exporter"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Rechercher des tâches...",
|
||||
"noResults": "Aucun résultat pour « {{query}} »"
|
||||
},
|
||||
"empty": {
|
||||
"inbox": "Aucune tâche.\nAppuyez sur + pour commencer.",
|
||||
"list": "Cette liste est vide."
|
||||
|
|
|
|||
Loading…
Reference in a new issue