feat: add color and icon pickers for lists

Replace inline list creation with a modal supporting color palette and
icon grid selection. Long-press to edit existing lists. Display chosen
icon/color in list rows, detail header, and task creation chips.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
le king fu 2026-02-20 21:44:15 -05:00
parent 5fc1365ced
commit 2e86835e44
6 changed files with 320 additions and 60 deletions

View file

@ -1,18 +1,38 @@
import { useEffect, useState, useCallback } from 'react';
import { View, Text, FlatList, Pressable, useColorScheme, TextInput, Alert } from 'react-native';
import {
View, Text, FlatList, Pressable, useColorScheme, TextInput, Alert,
Modal, KeyboardAvoidingView, Platform, ScrollView,
} from 'react-native';
import { useRouter } from 'expo-router';
import { Plus, ChevronRight, Trash2 } from 'lucide-react-native';
import {
Plus, ChevronRight, Trash2, Check,
List, ShoppingCart, Briefcase, Home, Heart, Star, BookOpen,
GraduationCap, Dumbbell, Utensils, Plane, Music, Code, Wrench,
Gift, Camera, Palette, Dog, Leaf, Zap,
} from 'lucide-react-native';
import type { LucideIcon } from 'lucide-react-native';
import { useTranslation } from 'react-i18next';
import { colors } from '@/src/theme/colors';
import { useSettingsStore } from '@/src/stores/useSettingsStore';
import { getAllLists, createList, deleteList } from '@/src/db/repository/lists';
import { getAllLists, createList, deleteList, updateList } from '@/src/db/repository/lists';
import { getTasksByList } from '@/src/db/repository/tasks';
const LIST_COLORS = ['#4A90A4', '#C17767', '#8BA889', '#D4A574', '#7B68EE', '#E57373', '#4DB6AC'];
const ICON_MAP: Record<string, LucideIcon> = {
List, ShoppingCart, Briefcase, Home, Heart, Star, BookOpen,
GraduationCap, Dumbbell, Utensils, Plane, Music, Code, Wrench,
Gift, Camera, Palette, Dog, Leaf, Zap,
};
const ICON_NAMES = Object.keys(ICON_MAP);
type ListWithCount = {
id: string;
name: string;
color: string | null;
icon: string | null;
isInbox: boolean;
taskCount: number;
};
@ -21,12 +41,17 @@ export default function ListsScreen() {
const { t } = useTranslation();
const router = useRouter();
const [lists, setLists] = useState<ListWithCount[]>([]);
const [showNewInput, setShowNewInput] = useState(false);
const [newName, setNewName] = useState('');
const systemScheme = useColorScheme();
const theme = useSettingsStore((s) => s.theme);
const isDark = (theme === 'system' ? systemScheme : theme) === 'dark';
// Modal state
const [showModal, setShowModal] = useState(false);
const [editingListId, setEditingListId] = useState<string | null>(null);
const [modalName, setModalName] = useState('');
const [modalColor, setModalColor] = useState(LIST_COLORS[0]);
const [modalIcon, setModalIcon] = useState<string | null>(null);
const loadLists = useCallback(async () => {
const allLists = await getAllLists();
const withCounts = await Promise.all(
@ -47,11 +72,34 @@ export default function ListsScreen() {
return () => clearInterval(interval);
}, [loadLists]);
const handleCreateList = async () => {
if (!newName.trim()) return;
await createList(newName.trim());
setNewName('');
setShowNewInput(false);
const openNewList = () => {
setEditingListId(null);
setModalName('');
setModalColor(LIST_COLORS[0]);
setModalIcon(null);
setShowModal(true);
};
const openEditList = (item: ListWithCount) => {
setEditingListId(item.id);
setModalName(item.name);
setModalColor(item.color || LIST_COLORS[0]);
setModalIcon(item.icon);
setShowModal(true);
};
const handleSaveList = async () => {
if (!modalName.trim()) return;
if (editingListId) {
await updateList(editingListId, {
name: modalName.trim(),
color: modalColor,
icon: modalIcon,
});
} else {
await createList(modalName.trim(), modalColor, modalIcon ?? undefined);
}
setShowModal(false);
loadLists();
};
@ -69,6 +117,20 @@ export default function ListsScreen() {
]);
};
const renderListIcon = (item: ListWithCount, size: number = 20) => {
const iconColor = item.color || colors.bleu.DEFAULT;
if (item.icon && ICON_MAP[item.icon]) {
const IconComponent = ICON_MAP[item.icon];
return <IconComponent size={size} color={iconColor} />;
}
return (
<View
className="h-3 w-3 rounded-full"
style={{ backgroundColor: iconColor }}
/>
);
};
return (
<View className={`flex-1 ${isDark ? 'bg-[#1A1A1A]' : 'bg-creme'}`}>
<FlatList
@ -78,15 +140,15 @@ export default function ListsScreen() {
renderItem={({ item }) => (
<Pressable
onPress={() => router.push(`/list/${item.id}` as any)}
onLongPress={() => { if (!item.isInbox) openEditList(item); }}
className={`flex-row items-center justify-between border-b px-4 py-4 ${
isDark ? 'border-[#3A3A3A]' : 'border-[#E5E7EB]'
}`}
>
<View className="flex-1 flex-row items-center">
<View
className="mr-3 h-3 w-3 rounded-full"
style={{ backgroundColor: item.color || colors.bleu.DEFAULT }}
/>
<View className="mr-3 w-5 items-center">
{renderListIcon(item)}
</View>
<Text
className={`text-base ${isDark ? 'text-[#F5F5F5]' : 'text-[#1A1A1A]'}`}
style={{ fontFamily: 'Inter_500Medium' }}
@ -107,37 +169,132 @@ export default function ListsScreen() {
</View>
</Pressable>
)}
ListFooterComponent={
showNewInput ? (
<View className="flex-row items-center px-4 py-3">
<TextInput
autoFocus
value={newName}
onChangeText={setNewName}
onSubmitEditing={handleCreateList}
onBlur={() => { setShowNewInput(false); setNewName(''); }}
placeholder={t('list.namePlaceholder')}
placeholderTextColor={isDark ? '#A0A0A0' : '#6B6B6B'}
className={`flex-1 rounded-lg border px-3 py-2 text-base ${
isDark
? 'border-[#3A3A3A] bg-[#2A2A2A] text-[#F5F5F5]'
: 'border-[#E5E7EB] bg-white text-[#1A1A1A]'
}`}
style={{ fontFamily: 'Inter_400Regular' }}
/>
</View>
) : null
}
/>
{/* FAB */}
<Pressable
onPress={() => setShowNewInput(true)}
onPress={openNewList}
className="absolute bottom-6 right-6 h-14 w-14 items-center justify-center rounded-full bg-bleu"
style={{ elevation: 4, shadowColor: '#000', shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.25, shadowRadius: 4 }}
>
<Plus size={28} color="#FFFFFF" />
</Pressable>
{/* Create/Edit Modal */}
<Modal visible={showModal} transparent animationType="fade">
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
className="flex-1"
>
<Pressable onPress={() => setShowModal(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' }}
>
{editingListId ? t('list.editList') : t('list.newList')}
</Text>
{/* Name input */}
<TextInput
autoFocus
value={modalName}
onChangeText={setModalName}
onSubmitEditing={handleSaveList}
placeholder={t('list.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 */}
<Text
className={`mb-2 mt-4 text-xs uppercase tracking-wide ${isDark ? 'text-[#A0A0A0]' : 'text-[#6B6B6B]'}`}
style={{ fontFamily: 'Inter_600SemiBold' }}
>
{t('list.colorLabel')}
</Text>
<View className="flex-row justify-between">
{LIST_COLORS.map((c) => (
<Pressable
key={c}
onPress={() => setModalColor(c)}
className="h-8 w-8 items-center justify-center rounded-full"
style={{ backgroundColor: c, borderWidth: modalColor === c ? 3 : 0, borderColor: isDark ? '#F5F5F5' : '#1A1A1A' }}
>
{modalColor === c && (
<Check size={14} color="#FFFFFF" strokeWidth={3} />
)}
</Pressable>
))}
</View>
{/* Icon picker */}
<Text
className={`mb-2 mt-4 text-xs uppercase tracking-wide ${isDark ? 'text-[#A0A0A0]' : 'text-[#6B6B6B]'}`}
style={{ fontFamily: 'Inter_600SemiBold' }}
>
{t('list.iconLabel')}
</Text>
<ScrollView style={{ maxHeight: 160 }} showsVerticalScrollIndicator={false}>
<View className="flex-row flex-wrap">
{/* No icon option */}
<Pressable
onPress={() => setModalIcon(null)}
className={`mb-2 mr-2 h-10 w-10 items-center justify-center rounded-lg ${
modalIcon === null
? isDark ? 'bg-[#4A4A4A]' : 'bg-[#E5E7EB]'
: isDark ? 'bg-[#3A3A3A]' : 'bg-[#F3F4F6]'
}`}
style={modalIcon === null ? { borderWidth: 2, borderColor: modalColor } : undefined}
>
<View className="h-3 w-3 rounded-full" style={{ backgroundColor: modalColor }} />
</Pressable>
{ICON_NAMES.map((name) => {
const Icon = ICON_MAP[name];
const isSelected = modalIcon === name;
return (
<Pressable
key={name}
onPress={() => setModalIcon(name)}
className={`mb-2 mr-2 h-10 w-10 items-center justify-center rounded-lg ${
isSelected
? isDark ? 'bg-[#4A4A4A]' : 'bg-[#E5E7EB]'
: isDark ? 'bg-[#3A3A3A]' : 'bg-[#F3F4F6]'
}`}
style={isSelected ? { borderWidth: 2, borderColor: modalColor } : undefined}
>
<Icon size={20} color={isSelected ? modalColor : isDark ? '#A0A0A0' : '#6B6B6B'} />
</Pressable>
);
})}
</View>
</ScrollView>
{/* Buttons */}
<View className="mt-4 flex-row justify-end">
<Pressable onPress={() => setShowModal(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={handleSaveList} 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>
</View>
);
}

View file

@ -1,7 +1,13 @@
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, ArrowUpDown, Filter } from 'lucide-react-native';
import {
ArrowLeft, Plus, ArrowUpDown, Filter, Download,
List, ShoppingCart, Briefcase, Home, Heart, Star, BookOpen,
GraduationCap, Dumbbell, Utensils, Plane, Music, Code, Wrench,
Gift, Camera, Palette, Dog, Leaf, Zap,
} from 'lucide-react-native';
import type { LucideIcon } from 'lucide-react-native';
import { useTranslation } from 'react-i18next';
import * as Haptics from 'expo-haptics';
@ -14,11 +20,19 @@ 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';
import { exportAndShareICS } from '@/src/services/icsExport';
const ICON_MAP: Record<string, LucideIcon> = {
List, ShoppingCart, Briefcase, Home, Heart, Star, BookOpen,
GraduationCap, Dumbbell, Utensils, Plane, Music, Code, Wrench,
Gift, Camera, Palette, Dog, Leaf, Zap,
};
type Tag = { id: string; name: string; color: string };
type Task = {
id: string;
title: string;
notes: string | null;
completed: boolean;
priority: number;
dueDate: Date | null;
@ -33,6 +47,8 @@ export default function ListDetailScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
const [tasks, setTasks] = useState<Task[]>([]);
const [listName, setListName] = useState('');
const [listColor, setListColor] = useState<string | null>(null);
const [listIcon, setListIcon] = useState<string | null>(null);
const [showSort, setShowSort] = useState(false);
const [showFilter, setShowFilter] = useState(false);
const systemScheme = useColorScheme();
@ -58,6 +74,8 @@ export default function ListDetailScreen() {
const list = lists.find((l) => l.id === id);
if (list) {
setListName(list.isInbox ? t('list.inbox') : list.name);
setListColor(list.color);
setListIcon(list.icon);
}
}, [id, t, sortBy, sortOrder, filterPriority, filterTag, filterCompleted, filterDueDate]);
@ -91,6 +109,18 @@ export default function ListDetailScreen() {
]);
};
const handleExportICS = async () => {
if (tasks.length === 0) {
Alert.alert(t('export.noTasks'));
return;
}
try {
await exportAndShareICS(tasks, listName || t('list.inbox'));
} catch {
// User cancelled sharing
}
};
const filtersActive = hasActiveFilters();
return (
@ -105,14 +135,22 @@ export default function ListDetailScreen() {
<Pressable onPress={() => router.back()} className="mr-3 p-1">
<ArrowLeft size={24} color={isDark ? '#F5F5F5' : '#1A1A1A'} />
</Pressable>
{listIcon && ICON_MAP[listIcon] ? (
(() => { const Icon = ICON_MAP[listIcon]; return <Icon size={20} color={listColor || colors.bleu.DEFAULT} />; })()
) : listColor ? (
<View className="h-3 w-3 rounded-full" style={{ backgroundColor: listColor }} />
) : null}
<Text
className={`text-lg ${isDark ? 'text-[#F5F5F5]' : 'text-[#1A1A1A]'}`}
className={`text-lg ${listIcon || listColor ? 'ml-2' : ''} ${isDark ? 'text-[#F5F5F5]' : 'text-[#1A1A1A]'}`}
style={{ fontFamily: 'Inter_600SemiBold' }}
>
{listName}
</Text>
</View>
<View className="flex-row items-center">
<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>

View file

@ -9,7 +9,13 @@ import {
Platform,
} from 'react-native';
import { useRouter, useLocalSearchParams } from 'expo-router';
import { X, Calendar, Repeat } from 'lucide-react-native';
import {
X, Calendar, Repeat,
List, ShoppingCart, Briefcase, Home, Heart, Star, BookOpen,
GraduationCap, Dumbbell, Utensils, Plane, Music, Code, Wrench,
Gift, Camera, Palette, Dog, Leaf, Zap,
} from 'lucide-react-native';
import type { LucideIcon } from 'lucide-react-native';
import { useTranslation } from 'react-i18next';
import DateTimePicker, { DateTimePickerEvent } from '@react-native-community/datetimepicker';
@ -22,6 +28,12 @@ import { getPriorityOptions } from '@/src/lib/priority';
import { RECURRENCE_OPTIONS } from '@/src/lib/recurrence';
import TagChip from '@/src/components/task/TagChip';
const ICON_MAP: Record<string, LucideIcon> = {
List, ShoppingCart, Briefcase, Home, Heart, Star, BookOpen,
GraduationCap, Dumbbell, Utensils, Plane, Music, Code, Wrench,
Gift, Camera, Palette, Dog, Leaf, Zap,
};
export default function NewTaskScreen() {
const { t } = useTranslation();
const router = useRouter();
@ -36,7 +48,7 @@ export default function NewTaskScreen() {
const [dueDate, setDueDate] = useState<Date | null>(null);
const [showDatePicker, setShowDatePicker] = useState(false);
const [selectedListId, setSelectedListId] = useState(params.listId ?? getInboxId());
const [lists, setLists] = useState<{ id: string; name: string; isInbox: boolean }[]>([]);
const [lists, setLists] = useState<{ id: string; name: string; color: string | null; icon: string | null; isInbox: boolean }[]>([]);
const [recurrence, setRecurrence] = useState<string | null>(null);
const [availableTags, setAvailableTags] = useState<{ id: string; name: string; color: string }[]>([]);
const [selectedTagIds, setSelectedTagIds] = useState<string[]>([]);
@ -261,25 +273,35 @@ export default function NewTaskScreen() {
{t('nav.lists')}
</Text>
<View className="flex-row flex-wrap">
{lists.map((list) => (
<Pressable
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]'
}`}
>
<Text
className="text-sm"
style={{
fontFamily: selectedListId === list.id ? 'Inter_600SemiBold' : 'Inter_400Regular',
color: selectedListId === list.id ? colors.bleu.DEFAULT : isDark ? '#A0A0A0' : '#6B6B6B',
}}
{lists.map((list) => {
const isSelected = selectedListId === list.id;
const chipColor = isSelected ? colors.bleu.DEFAULT : isDark ? '#A0A0A0' : '#6B6B6B';
const IconComp = list.icon && ICON_MAP[list.icon] ? ICON_MAP[list.icon] : null;
return (
<Pressable
key={list.id}
onPress={() => setSelectedListId(list.id)}
className={`mb-2 mr-2 flex-row items-center rounded-full border px-3 py-1.5 ${
isSelected ? 'border-bleu bg-bleu/10' : isDark ? 'border-[#3A3A3A]' : 'border-[#E5E7EB]'
}`}
>
{list.isInbox ? t('list.inbox') : list.name}
</Text>
</Pressable>
))}
{IconComp ? (
<IconComp size={14} color={isSelected ? colors.bleu.DEFAULT : list.color || chipColor} />
) : list.color ? (
<View className="h-2.5 w-2.5 rounded-full" style={{ backgroundColor: isSelected ? colors.bleu.DEFAULT : list.color }} />
) : null}
<Text
className={`text-sm ${IconComp || list.color ? 'ml-1.5' : ''}`}
style={{
fontFamily: isSelected ? 'Inter_600SemiBold' : 'Inter_400Regular',
color: chipColor,
}}
>
{list.isInbox ? t('list.inbox') : list.name}
</Text>
</Pressable>
);
})}
</View>
</>
)}

View file

@ -29,7 +29,7 @@ export async function getAllLists() {
return db.select().from(lists).orderBy(lists.position);
}
export async function createList(name: string, color?: string) {
export async function createList(name: string, color?: string, icon?: string) {
const now = new Date();
const id = randomUUID();
const allLists = await getAllLists();
@ -39,6 +39,7 @@ export async function createList(name: string, color?: string) {
id,
name,
color: color ?? null,
icon: icon ?? null,
position: maxPosition + 1,
isInbox: false,
createdAt: now,
@ -47,7 +48,7 @@ export async function createList(name: string, color?: string) {
return id;
}
export async function updateList(id: string, data: { name?: string; color?: string }) {
export async function updateList(id: string, data: { name?: string; color?: string; icon?: string | null }) {
await db
.update(lists)
.set({ ...data, updatedAt: new Date() })

View file

@ -38,6 +38,9 @@
"newList": "New list",
"namePlaceholder": "List name...",
"deleteConfirm": "Are you sure you want to delete this list?",
"editList": "Edit list",
"colorLabel": "Color",
"iconLabel": "Icon",
"taskCount_one": "{{count}} task",
"taskCount_other": "{{count}} tasks"
},
@ -92,6 +95,24 @@
"about": "About",
"version": "Version"
},
"notifications": {
"title": "Notifications",
"enabled": "Reminders enabled",
"offset": "Remind before due time",
"atTime": "At time",
"hoursBefore": "{{count}}h before",
"dayBefore": "1 day before"
},
"calendar": {
"title": "Calendar",
"syncEnabled": "Sync with calendar",
"syncDescription": "Adds tasks with due dates to the system calendar"
},
"export": {
"ics": "Export as ICS",
"success": "File exported",
"noTasks": "No tasks to export"
},
"empty": {
"inbox": "No tasks yet.\nTap + to get started.",
"list": "This list is empty."

View file

@ -38,6 +38,9 @@
"newList": "Nouvelle liste",
"namePlaceholder": "Nom de la liste...",
"deleteConfirm": "Voulez-vous vraiment supprimer cette liste ?",
"editList": "Modifier la liste",
"colorLabel": "Couleur",
"iconLabel": "Icône",
"taskCount_one": "{{count}} tâche",
"taskCount_other": "{{count}} tâches"
},
@ -92,6 +95,24 @@
"about": "À propos",
"version": "Version"
},
"notifications": {
"title": "Notifications",
"enabled": "Rappels activés",
"offset": "Rappel avant l'échéance",
"atTime": "À l'heure",
"hoursBefore": "{{count}}h avant",
"dayBefore": "1 jour avant"
},
"calendar": {
"title": "Calendrier",
"syncEnabled": "Synchroniser avec le calendrier",
"syncDescription": "Ajoute les tâches avec échéance au calendrier système"
},
"export": {
"ics": "Exporter en ICS",
"success": "Fichier exporté",
"noTasks": "Aucune tâche à exporter"
},
"empty": {
"inbox": "Aucune tâche.\nAppuyez sur + pour commencer.",
"list": "Cette liste est vide."