From 2e86835e44804f3794152dc3435bc49775473307 Mon Sep 17 00:00:00 2001 From: le king fu Date: Fri, 20 Feb 2026 21:44:15 -0500 Subject: [PATCH] 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 --- app/(tabs)/lists.tsx | 229 +++++++++++++++++++++++++++++++------ app/list/[id].tsx | 42 ++++++- app/task/new.tsx | 62 ++++++---- src/db/repository/lists.ts | 5 +- src/i18n/en.json | 21 ++++ src/i18n/fr.json | 21 ++++ 6 files changed, 320 insertions(+), 60 deletions(-) diff --git a/app/(tabs)/lists.tsx b/app/(tabs)/lists.tsx index 0ef6590..a91910e 100644 --- a/app/(tabs)/lists.tsx +++ b/app/(tabs)/lists.tsx @@ -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 = { + 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([]); - 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(null); + const [modalName, setModalName] = useState(''); + const [modalColor, setModalColor] = useState(LIST_COLORS[0]); + const [modalIcon, setModalIcon] = useState(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 ; + } + return ( + + ); + }; + return ( ( 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]' }`} > - + + {renderListIcon(item)} + )} - ListFooterComponent={ - showNewInput ? ( - - { 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' }} - /> - - ) : null - } /> {/* FAB */} 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 }} > + + {/* Create/Edit Modal */} + + + setShowModal(false)} className="flex-1 justify-center bg-black/40 px-6"> + e.stopPropagation()} + className={`rounded-2xl px-5 pb-5 pt-4 ${isDark ? 'bg-[#2A2A2A]' : 'bg-white'}`} + > + {/* Modal title */} + + {editingListId ? t('list.editList') : t('list.newList')} + + + {/* Name input */} + + + {/* Color picker */} + + {t('list.colorLabel')} + + + {LIST_COLORS.map((c) => ( + 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 && ( + + )} + + ))} + + + {/* Icon picker */} + + {t('list.iconLabel')} + + + + {/* No icon option */} + 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} + > + + + {ICON_NAMES.map((name) => { + const Icon = ICON_MAP[name]; + const isSelected = modalIcon === name; + return ( + 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} + > + + + ); + })} + + + + {/* Buttons */} + + setShowModal(false)} className="mr-3 px-4 py-2"> + + {t('common.cancel')} + + + + + {t('common.save')} + + + + + + + ); } diff --git a/app/list/[id].tsx b/app/list/[id].tsx index 509dcb6..c9d5ece 100644 --- a/app/list/[id].tsx +++ b/app/list/[id].tsx @@ -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 = { + 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([]); const [listName, setListName] = useState(''); + const [listColor, setListColor] = useState(null); + const [listIcon, setListIcon] = useState(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() { router.back()} className="mr-3 p-1"> + {listIcon && ICON_MAP[listIcon] ? ( + (() => { const Icon = ICON_MAP[listIcon]; return ; })() + ) : listColor ? ( + + ) : null} {listName} + + + setShowSort(true)} className="mr-3 p-1"> diff --git a/app/task/new.tsx b/app/task/new.tsx index c683c3d..71da841 100644 --- a/app/task/new.tsx +++ b/app/task/new.tsx @@ -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 = { + 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(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(null); const [availableTags, setAvailableTags] = useState<{ id: string; name: string; color: string }[]>([]); const [selectedTagIds, setSelectedTagIds] = useState([]); @@ -261,25 +273,35 @@ export default function NewTaskScreen() { {t('nav.lists')} - {lists.map((list) => ( - 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]' - }`} - > - { + 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 ( + 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} - - - ))} + {IconComp ? ( + + ) : list.color ? ( + + ) : null} + + {list.isInbox ? t('list.inbox') : list.name} + + + ); + })} )} diff --git a/src/db/repository/lists.ts b/src/db/repository/lists.ts index 4eee9a0..e410750 100644 --- a/src/db/repository/lists.ts +++ b/src/db/repository/lists.ts @@ -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() }) diff --git a/src/i18n/en.json b/src/i18n/en.json index 0225d49..15589f7 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -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." diff --git a/src/i18n/fr.json b/src/i18n/fr.json index c738e85..ed5b00a 100644 --- a/src/i18n/fr.json +++ b/src/i18n/fr.json @@ -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."