diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index 15e9790..bcdab40 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -1,17 +1,20 @@ -import { useEffect, useState, useCallback } from 'react'; -import { View, Text, FlatList, Pressable, useColorScheme, Alert } from 'react-native'; +import { useEffect, useState, useCallback, useRef } from 'react'; +import { View, Text, Pressable, useColorScheme, Alert } from 'react-native'; import { useRouter } from 'expo-router'; import { Plus, ArrowUpDown, Filter, Download } from 'lucide-react-native'; import { useTranslation } from 'react-i18next'; import * as Haptics from 'expo-haptics'; +import DraggableFlatList, { RenderItemParams } from 'react-native-draggable-flatlist'; +import { GestureHandlerRootView } from 'react-native-gesture-handler'; 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 { getTasksByList, toggleComplete, deleteTask, reorderTasks } 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 SwipeableRow from '@/src/components/SwipeableRow'; import SortMenu from '@/src/components/SortMenu'; import FilterMenu from '@/src/components/FilterMenu'; import { exportAndShareICS } from '@/src/services/icsExport'; @@ -38,14 +41,15 @@ export default function InboxScreen() { const systemScheme = useColorScheme(); const theme = useSettingsStore((s) => s.theme); const isDark = (theme === 'system' ? systemScheme : theme) === 'dark'; + const isDraggingRef = useRef(false); const { sortBy, sortOrder, filterPriority, filterTag, filterCompleted, filterDueDate, hasActiveFilters } = useTaskStore(); const loadTasks = useCallback(async () => { + if (isDraggingRef.current) return; 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, @@ -97,7 +101,36 @@ export default function InboxScreen() { } }; + const handleDragEnd = async ({ data }: { data: Task[] }) => { + setTasks(data); + const updates = data.map((task, index) => ({ id: task.id, position: index + 1 })); + await reorderTasks(updates); + isDraggingRef.current = false; + loadTasks(); + }; + const filtersActive = hasActiveFilters(); + const canDrag = sortBy === 'position'; + + const renderItem = ({ item, drag }: RenderItemParams) => ( + handleDelete(item.id)} + onSwipeRight={() => handleToggle(item.id)} + isDark={isDark} + > + handleToggle(item.id)} + onPress={() => router.push(`/task/${item.id}` as any)} + onLongPress={canDrag ? () => { + isDraggingRef.current = true; + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); + drag(); + } : undefined} + /> + + ); return ( @@ -127,20 +160,17 @@ export default function InboxScreen() { ) : ( - item.id} - contentContainerStyle={{ paddingBottom: 100 }} - renderItem={({ item }) => ( - handleToggle(item.id)} - onPress={() => router.push(`/task/${item.id}` as any)} - onDelete={() => handleDelete(item.id)} - /> - )} - /> + + item.id} + contentContainerStyle={{ paddingBottom: 100 }} + renderItem={renderItem} + onDragBegin={() => { isDraggingRef.current = true; }} + onDragEnd={handleDragEnd} + activationDistance={canDrag ? 0 : 10000} + /> + )} s.theme); const isDark = (theme === 'system' ? systemScheme : theme) === 'dark'; + const isDraggingRef = useRef(false); // Modal state const [showModal, setShowModal] = useState(false); @@ -53,6 +58,7 @@ export default function ListsScreen() { const [modalIcon, setModalIcon] = useState(null); const loadLists = useCallback(async () => { + if (isDraggingRef.current) return; const allLists = await getAllLists(); const withCounts = await Promise.all( allLists.map(async (list) => { @@ -103,7 +109,7 @@ export default function ListsScreen() { loadLists(); }; - const handleDeleteList = (id: string, name: string) => { + const handleDeleteList = (id: string) => { Alert.alert(t('list.deleteConfirm'), '', [ { text: t('common.cancel'), style: 'cancel' }, { @@ -131,45 +137,105 @@ export default function ListsScreen() { ); }; + // Separate inbox from custom lists + const inbox = lists.find((l) => l.isInbox); + const customLists = lists.filter((l) => !l.isInbox); + + const handleDragEnd = async ({ data }: { data: ListWithCount[] }) => { + // Inbox stays at position 0, custom lists start at 1 + const updates = data.map((list, index) => ({ id: list.id, position: index + 1 })); + const fullLists = inbox ? [inbox, ...data] : data; + setLists(fullLists); + await reorderLists(updates); + isDraggingRef.current = false; + loadLists(); + }; + + const renderInboxRow = () => { + if (!inbox) return null; + return ( + router.push(`/list/${inbox.id}` as any)} + className={`flex-row items-center justify-between border-b px-4 py-4 ${ + isDark ? 'border-[#3A3A3A]' : 'border-[#E5E7EB]' + }`} + > + + + {renderListIcon(inbox)} + + + {t('list.inbox')} + + + + + {inbox.taskCount} + + + + + ); + }; + + const renderItem = ({ item, drag }: RenderItemParams) => ( + handleDeleteList(item.id)} + isDark={isDark} + > + router.push(`/list/${item.id}` as any)} + onLongPress={() => openEditList(item)} + className={`flex-row items-center justify-between border-b px-4 py-4 ${ + isDark ? 'border-[#3A3A3A]' : 'border-[#E5E7EB]' + }`} + > + {/* Drag handle */} + + + + + + + {renderListIcon(item)} + + + {item.name} + + + + + {item.taskCount} + + + + + + ); + return ( - item.id} - contentContainerStyle={{ paddingBottom: 100 }} - renderItem={({ item }) => ( - 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)} - - - {item.isInbox ? t('list.inbox') : item.name} - - - - - {item.taskCount} - - {!item.isInbox && ( - handleDeleteList(item.id, item.name)} className="mr-2 p-1"> - - - )} - - - - )} - /> + + item.id} + contentContainerStyle={{ paddingBottom: 100 }} + ListHeaderComponent={renderInboxRow} + renderItem={renderItem} + onDragBegin={() => { isDraggingRef.current = true; }} + onDragEnd={handleDragEnd} + /> + {/* FAB */} s.theme); const isDark = (theme === 'system' ? systemScheme : theme) === 'dark'; + const isDraggingRef = useRef(false); const { sortBy, sortOrder, filterPriority, filterTag, filterCompleted, filterDueDate, hasActiveFilters } = useTaskStore(); const loadData = useCallback(async () => { - if (!id) return; + if (!id || isDraggingRef.current) return; const result = await getTasksByList(id, { sortBy, sortOrder, filterPriority, filterTag, filterCompleted, filterDueDate, }); @@ -121,7 +125,36 @@ export default function ListDetailScreen() { } }; + const handleDragEnd = async ({ data }: { data: Task[] }) => { + setTasks(data); + const updates = data.map((task, index) => ({ id: task.id, position: index + 1 })); + await reorderTasks(updates); + isDraggingRef.current = false; + loadData(); + }; + const filtersActive = hasActiveFilters(); + const canDrag = sortBy === 'position'; + + const renderItem = ({ item, drag }: RenderItemParams) => ( + handleDelete(item.id)} + onSwipeRight={() => handleToggle(item.id)} + isDark={isDark} + > + handleToggle(item.id)} + onPress={() => router.push(`/task/${item.id}` as any)} + onLongPress={canDrag ? () => { + isDraggingRef.current = true; + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); + drag(); + } : undefined} + /> + + ); return ( @@ -173,20 +206,17 @@ export default function ListDetailScreen() { ) : ( - item.id} - contentContainerStyle={{ paddingBottom: 100 }} - renderItem={({ item }) => ( - handleToggle(item.id)} - onPress={() => router.push(`/task/${item.id}` as any)} - onDelete={() => handleDelete(item.id)} - /> - )} - /> + + item.id} + contentContainerStyle={{ paddingBottom: 100 }} + renderItem={renderItem} + onDragBegin={() => { isDraggingRef.current = true; }} + onDragEnd={handleDragEnd} + activationDistance={canDrag ? 0 : 10000} + /> + )} =10" } }, + "node_modules/react-native-draggable-flatlist": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/react-native-draggable-flatlist/-/react-native-draggable-flatlist-4.0.3.tgz", + "integrity": "sha512-2F4x5BFieWdGq9SetD2nSAR7s7oQCSgNllYgERRXXtNfSOuAGAVbDb/3H3lP0y5f7rEyNwabKorZAD/SyyNbDw==", + "license": "MIT", + "dependencies": { + "@babel/preset-typescript": "^7.17.12" + }, + "peerDependencies": { + "react-native": ">=0.64.0", + "react-native-gesture-handler": ">=2.0.0", + "react-native-reanimated": ">=2.8.0" + } + }, "node_modules/react-native-gesture-handler": { "version": "2.28.0", "resolved": "https://registry.npmjs.org/react-native-gesture-handler/-/react-native-gesture-handler-2.28.0.tgz", diff --git a/package.json b/package.json index 658bdef..ed63438 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "react-dom": "19.1.0", "react-i18next": "^16.5.4", "react-native": "0.81.5", + "react-native-draggable-flatlist": "^4.0.3", "react-native-gesture-handler": "~2.28.0", "react-native-reanimated": "~4.1.1", "react-native-safe-area-context": "~5.6.0", diff --git a/src/components/SwipeableRow.tsx b/src/components/SwipeableRow.tsx new file mode 100644 index 0000000..1c5c618 --- /dev/null +++ b/src/components/SwipeableRow.tsx @@ -0,0 +1,118 @@ +import React from 'react'; +import { View, StyleSheet } from 'react-native'; +import { Gesture, GestureDetector } from 'react-native-gesture-handler'; +import Animated, { + useSharedValue, + useAnimatedStyle, + withSpring, + runOnJS, +} from 'react-native-reanimated'; +import { Trash2, Check } from 'lucide-react-native'; + +const ACTION_WIDTH = 80; +const SNAP_THRESHOLD = ACTION_WIDTH * 0.5; + +interface SwipeableRowProps { + onSwipeLeft?: () => void; + onSwipeRight?: () => void; + leftColor?: string; + rightColor?: string; + isDark: boolean; + children: React.ReactNode; +} + +export default function SwipeableRow({ + onSwipeLeft, + onSwipeRight, + leftColor = '#4CAF50', + rightColor = '#EF4444', + isDark, + children, +}: SwipeableRowProps) { + const translateX = useSharedValue(0); + + const panGesture = Gesture.Pan() + .activeOffsetX([-10, 10]) + .failOffsetY([-5, 5]) + .onUpdate((e) => { + // Clamp: only allow left swipe if onSwipeLeft, right if onSwipeRight + if (e.translationX > 0 && onSwipeRight) { + translateX.value = Math.min(e.translationX, ACTION_WIDTH); + } else if (e.translationX < 0 && onSwipeLeft) { + translateX.value = Math.max(e.translationX, -ACTION_WIDTH); + } + }) + .onEnd(() => { + if (translateX.value >= SNAP_THRESHOLD && onSwipeRight) { + translateX.value = withSpring(0, { damping: 20, stiffness: 200 }); + runOnJS(onSwipeRight)(); + } else if (translateX.value <= -SNAP_THRESHOLD && onSwipeLeft) { + translateX.value = withSpring(0, { damping: 20, stiffness: 200 }); + runOnJS(onSwipeLeft)(); + } else { + translateX.value = withSpring(0, { damping: 20, stiffness: 200 }); + } + }); + + const rowStyle = useAnimatedStyle(() => ({ + transform: [{ translateX: translateX.value }], + })); + + const leftActionStyle = useAnimatedStyle(() => ({ + opacity: translateX.value > 0 ? Math.min(translateX.value / SNAP_THRESHOLD, 1) : 0, + })); + + const rightActionStyle = useAnimatedStyle(() => ({ + opacity: translateX.value < 0 ? Math.min(-translateX.value / SNAP_THRESHOLD, 1) : 0, + })); + + return ( + + {/* Right swipe action (complete) — behind left side */} + {onSwipeRight && ( + + + + )} + + {/* Left swipe action (delete) — behind right side */} + {onSwipeLeft && ( + + + + )} + + + + {children} + + + + ); +} + +const styles = StyleSheet.create({ + container: { + position: 'relative', + overflow: 'hidden', + }, + row: { + zIndex: 1, + }, + actionLeft: { + ...StyleSheet.absoluteFillObject, + justifyContent: 'center', + alignItems: 'flex-start', + paddingLeft: 24, + }, + actionRight: { + ...StyleSheet.absoluteFillObject, + justifyContent: 'center', + alignItems: 'flex-end', + paddingRight: 24, + }, +}); diff --git a/src/components/task/TaskItem.tsx b/src/components/task/TaskItem.tsx index 9206728..77847d2 100644 --- a/src/components/task/TaskItem.tsx +++ b/src/components/task/TaskItem.tsx @@ -1,5 +1,5 @@ import { View, Text, Pressable } from 'react-native'; -import { Check, Trash2, Repeat } from 'lucide-react-native'; +import { Check, Repeat } from 'lucide-react-native'; import { format } from 'date-fns'; import { fr, enUS } from 'date-fns/locale'; import { useTranslation } from 'react-i18next'; @@ -20,16 +20,17 @@ interface TaskItemProps { isDark: boolean; onToggle: () => void; onPress: () => void; - onDelete: () => void; + onLongPress?: () => void; } -export default function TaskItem({ task, isDark, onToggle, onPress, onDelete }: TaskItemProps) { +export default function TaskItem({ task, isDark, onToggle, onPress, onLongPress }: TaskItemProps) { const { i18n } = useTranslation(); const dateLocale = i18n.language === 'fr' ? fr : enUS; return ( )} - - {/* Delete */} - - - ); } diff --git a/src/db/repository/lists.ts b/src/db/repository/lists.ts index e410750..7250f1f 100644 --- a/src/db/repository/lists.ts +++ b/src/db/repository/lists.ts @@ -55,6 +55,14 @@ export async function updateList(id: string, data: { name?: string; color?: stri .where(eq(lists.id, id)); } +export async function reorderLists(updates: { id: string; position: number }[]) { + await db.transaction(async (tx) => { + for (const { id, position } of updates) { + await tx.update(lists).set({ position, updatedAt: new Date() }).where(eq(lists.id, id)); + } + }); +} + export async function deleteList(id: string) { await db.delete(lists).where(eq(lists.id, id)); } diff --git a/src/db/repository/tasks.ts b/src/db/repository/tasks.ts index 0b23eca..8127441 100644 --- a/src/db/repository/tasks.ts +++ b/src/db/repository/tasks.ts @@ -256,6 +256,14 @@ export async function toggleComplete(id: string) { } } +export async function reorderTasks(updates: { id: string; position: number }[]) { + await db.transaction(async (tx) => { + for (const { id, position } of updates) { + await tx.update(tasks).set({ position, updatedAt: new Date() }).where(eq(tasks.id, id)); + } + }); +} + export async function deleteTask(id: string) { const task = await getTaskById(id); diff --git a/src/i18n/en.json b/src/i18n/en.json index 15589f7..ed0baff 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -20,7 +20,10 @@ "addSubtask": "Add a subtask", "completed": "Completed", "newTask": "New task", - "deleteConfirm": "Are you sure you want to delete this task?" + "deleteConfirm": "Are you sure you want to delete this task?", + "swipeDelete": "Swipe to delete", + "swipeComplete": "Swipe to complete", + "dragHandle": "Hold to reorder" }, "priority": { "none": "None", diff --git a/src/i18n/fr.json b/src/i18n/fr.json index ed5b00a..68ddf54 100644 --- a/src/i18n/fr.json +++ b/src/i18n/fr.json @@ -20,7 +20,10 @@ "addSubtask": "Ajouter une sous-tâche", "completed": "Terminée", "newTask": "Nouvelle tâche", - "deleteConfirm": "Voulez-vous vraiment supprimer cette tâche ?" + "deleteConfirm": "Voulez-vous vraiment supprimer cette tâche ?", + "swipeDelete": "Glisser pour supprimer", + "swipeComplete": "Glisser pour compléter", + "dragHandle": "Maintenir pour réordonner" }, "priority": { "none": "Aucune",