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:
le king fu 2026-02-21 08:53:02 -05:00
parent 4d62658ae7
commit 3558171bb9
4 changed files with 99 additions and 26 deletions

View file

@ -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}

View file

@ -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}

View file

@ -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."

View file

@ -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."