feat: add swipe actions and drag-to-reorder for tasks and lists

Replace static delete buttons with swipe gestures (left to delete, right
to complete) and add drag-to-reorder support using react-native-draggable-flatlist.
Inbox is pinned at top of lists tab with a GripVertical drag handle for
custom lists. Polling is paused during drag operations to prevent state conflicts.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
le king fu 2026-02-21 08:43:34 -05:00
parent 47f698d86b
commit 4d62658ae7
11 changed files with 366 additions and 88 deletions

View file

@ -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<Task>) => (
<SwipeableRow
onSwipeLeft={() => handleDelete(item.id)}
onSwipeRight={() => handleToggle(item.id)}
isDark={isDark}
>
<TaskItem
task={item}
isDark={isDark}
onToggle={() => handleToggle(item.id)}
onPress={() => router.push(`/task/${item.id}` as any)}
onLongPress={canDrag ? () => {
isDraggingRef.current = true;
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
drag();
} : undefined}
/>
</SwipeableRow>
);
return (
<View className={`flex-1 ${isDark ? 'bg-[#1A1A1A]' : 'bg-creme'}`}>
@ -127,20 +160,17 @@ export default function InboxScreen() {
</Text>
</View>
) : (
<FlatList
data={tasks}
keyExtractor={(item) => item.id}
contentContainerStyle={{ paddingBottom: 100 }}
renderItem={({ item }) => (
<TaskItem
task={item}
isDark={isDark}
onToggle={() => handleToggle(item.id)}
onPress={() => router.push(`/task/${item.id}` as any)}
onDelete={() => handleDelete(item.id)}
/>
)}
/>
<GestureHandlerRootView style={{ flex: 1 }}>
<DraggableFlatList
data={tasks}
keyExtractor={(item) => item.id}
contentContainerStyle={{ paddingBottom: 100 }}
renderItem={renderItem}
onDragBegin={() => { isDraggingRef.current = true; }}
onDragEnd={handleDragEnd}
activationDistance={canDrag ? 0 : 10000}
/>
</GestureHandlerRootView>
)}
<Pressable

View file

@ -1,22 +1,25 @@
import { useEffect, useState, useCallback } from 'react';
import { useEffect, useState, useCallback, useRef } from 'react';
import {
View, Text, FlatList, Pressable, useColorScheme, TextInput, Alert,
View, Text, Pressable, useColorScheme, TextInput, Alert,
Modal, KeyboardAvoidingView, Platform, ScrollView,
} from 'react-native';
import { useRouter } from 'expo-router';
import {
Plus, ChevronRight, Trash2, Check,
Plus, ChevronRight, Check, GripVertical,
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 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 { getAllLists, createList, deleteList, updateList } from '@/src/db/repository/lists';
import { getAllLists, createList, deleteList, updateList, reorderLists } from '@/src/db/repository/lists';
import { getTasksByList } from '@/src/db/repository/tasks';
import SwipeableRow from '@/src/components/SwipeableRow';
const LIST_COLORS = ['#4A90A4', '#C17767', '#8BA889', '#D4A574', '#7B68EE', '#E57373', '#4DB6AC'];
@ -34,6 +37,7 @@ type ListWithCount = {
color: string | null;
icon: string | null;
isInbox: boolean;
position: number;
taskCount: number;
};
@ -44,6 +48,7 @@ export default function ListsScreen() {
const systemScheme = useColorScheme();
const theme = useSettingsStore((s) => 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<string | null>(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 (
<Pressable
onPress={() => 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]'
}`}
>
<View className="flex-1 flex-row items-center">
<View className="mr-3 w-5 items-center">
{renderListIcon(inbox)}
</View>
<Text
className={`text-base ${isDark ? 'text-[#F5F5F5]' : 'text-[#1A1A1A]'}`}
style={{ fontFamily: 'Inter_500Medium' }}
>
{t('list.inbox')}
</Text>
</View>
<View className="flex-row items-center">
<Text className={`mr-2 text-sm ${isDark ? 'text-[#A0A0A0]' : 'text-[#6B6B6B]'}`}>
{inbox.taskCount}
</Text>
<ChevronRight size={16} color={isDark ? '#A0A0A0' : '#6B6B6B'} />
</View>
</Pressable>
);
};
const renderItem = ({ item, drag }: RenderItemParams<ListWithCount>) => (
<SwipeableRow
onSwipeLeft={() => handleDeleteList(item.id)}
isDark={isDark}
>
<Pressable
onPress={() => 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 */}
<Pressable
onPressIn={drag}
className="mr-2 p-1"
accessibilityLabel={t('task.dragHandle')}
>
<GripVertical size={18} color={isDark ? '#555' : '#CCC'} />
</Pressable>
<View className="flex-1 flex-row items-center">
<View className="mr-3 w-5 items-center">
{renderListIcon(item)}
</View>
<Text
className={`text-base ${isDark ? 'text-[#F5F5F5]' : 'text-[#1A1A1A]'}`}
style={{ fontFamily: 'Inter_500Medium' }}
>
{item.name}
</Text>
</View>
<View className="flex-row items-center">
<Text className={`mr-2 text-sm ${isDark ? 'text-[#A0A0A0]' : 'text-[#6B6B6B]'}`}>
{item.taskCount}
</Text>
<ChevronRight size={16} color={isDark ? '#A0A0A0' : '#6B6B6B'} />
</View>
</Pressable>
</SwipeableRow>
);
return (
<View className={`flex-1 ${isDark ? 'bg-[#1A1A1A]' : 'bg-creme'}`}>
<FlatList
data={lists}
keyExtractor={(item) => item.id}
contentContainerStyle={{ paddingBottom: 100 }}
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 w-5 items-center">
{renderListIcon(item)}
</View>
<Text
className={`text-base ${isDark ? 'text-[#F5F5F5]' : 'text-[#1A1A1A]'}`}
style={{ fontFamily: 'Inter_500Medium' }}
>
{item.isInbox ? t('list.inbox') : item.name}
</Text>
</View>
<View className="flex-row items-center">
<Text className={`mr-2 text-sm ${isDark ? 'text-[#A0A0A0]' : 'text-[#6B6B6B]'}`}>
{item.taskCount}
</Text>
{!item.isInbox && (
<Pressable onPress={() => handleDeleteList(item.id, item.name)} className="mr-2 p-1">
<Trash2 size={16} color={colors.terracotta.DEFAULT} />
</Pressable>
)}
<ChevronRight size={16} color={isDark ? '#A0A0A0' : '#6B6B6B'} />
</View>
</Pressable>
)}
/>
<GestureHandlerRootView style={{ flex: 1 }}>
<DraggableFlatList
data={customLists}
keyExtractor={(item) => item.id}
contentContainerStyle={{ paddingBottom: 100 }}
ListHeaderComponent={renderInboxRow}
renderItem={renderItem}
onDragBegin={() => { isDraggingRef.current = true; }}
onDragEnd={handleDragEnd}
/>
</GestureHandlerRootView>
{/* FAB */}
<Pressable

View file

@ -1,5 +1,5 @@
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, useLocalSearchParams } from 'expo-router';
import {
ArrowLeft, Plus, ArrowUpDown, Filter, Download,
@ -10,14 +10,17 @@ import {
import type { LucideIcon } 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 { getAllLists } 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';
@ -54,11 +57,12 @@ export default function ListDetailScreen() {
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 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<Task>) => (
<SwipeableRow
onSwipeLeft={() => handleDelete(item.id)}
onSwipeRight={() => handleToggle(item.id)}
isDark={isDark}
>
<TaskItem
task={item}
isDark={isDark}
onToggle={() => handleToggle(item.id)}
onPress={() => router.push(`/task/${item.id}` as any)}
onLongPress={canDrag ? () => {
isDraggingRef.current = true;
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
drag();
} : undefined}
/>
</SwipeableRow>
);
return (
<View className={`flex-1 ${isDark ? 'bg-[#1A1A1A]' : 'bg-creme'}`}>
@ -173,20 +206,17 @@ export default function ListDetailScreen() {
</Text>
</View>
) : (
<FlatList
data={tasks}
keyExtractor={(item) => item.id}
contentContainerStyle={{ paddingBottom: 100 }}
renderItem={({ item }) => (
<TaskItem
task={item}
isDark={isDark}
onToggle={() => handleToggle(item.id)}
onPress={() => router.push(`/task/${item.id}` as any)}
onDelete={() => handleDelete(item.id)}
/>
)}
/>
<GestureHandlerRootView style={{ flex: 1 }}>
<DraggableFlatList
data={tasks}
keyExtractor={(item) => item.id}
contentContainerStyle={{ paddingBottom: 100 }}
renderItem={renderItem}
onDragBegin={() => { isDraggingRef.current = true; }}
onDragEnd={handleDragEnd}
activationDistance={canDrag ? 0 : 10000}
/>
</GestureHandlerRootView>
)}
<Pressable

15
package-lock.json generated
View file

@ -40,6 +40,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",
@ -10338,6 +10339,20 @@
"node": ">=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",

View file

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

View file

@ -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 (
<View style={styles.container}>
{/* Right swipe action (complete) — behind left side */}
{onSwipeRight && (
<Animated.View
style={[styles.actionLeft, { backgroundColor: leftColor }, leftActionStyle]}
>
<Check size={22} color="#FFFFFF" strokeWidth={3} />
</Animated.View>
)}
{/* Left swipe action (delete) — behind right side */}
{onSwipeLeft && (
<Animated.View
style={[styles.actionRight, { backgroundColor: rightColor }, rightActionStyle]}
>
<Trash2 size={22} color="#FFFFFF" />
</Animated.View>
)}
<GestureDetector gesture={panGesture}>
<Animated.View style={[styles.row, { backgroundColor: isDark ? '#1A1A1A' : '#FAF9F6' }, rowStyle]}>
{children}
</Animated.View>
</GestureDetector>
</View>
);
}
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,
},
});

View file

@ -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 (
<Pressable
onPress={onPress}
onLongPress={onLongPress}
className={`flex-row items-center border-b px-4 py-3.5 ${
isDark ? 'border-[#3A3A3A]' : 'border-[#E5E7EB]'
}`}
@ -99,11 +100,6 @@ export default function TaskItem({ task, isDark, onToggle, onPress, onDelete }:
style={{ backgroundColor: getPriorityColor(task.priority, isDark) }}
/>
)}
{/* Delete */}
<Pressable onPress={onDelete} className="ml-3 p-1">
<Trash2 size={16} color={isDark ? '#A0A0A0' : '#9CA3AF'} />
</Pressable>
</Pressable>
);
}

View file

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

View file

@ -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);

View file

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

View file

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