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:
parent
47f698d86b
commit
4d62658ae7
11 changed files with 366 additions and 88 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
15
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
118
src/components/SwipeableRow.tsx
Normal file
118
src/components/SwipeableRow.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in a new issue