From 8091c7d94dd267d2ff35ee07dd975143c29cca3f Mon Sep 17 00:00:00 2001 From: Marcus Rehbock Date: Fri, 7 Aug 2026 20:47:28 -0700 Subject: [PATCH] Drag-to-reorder lists: manual sort_order is canonical, fractional re-slotting, haptic pickup/drop Co-Authored-By: Claude Fable 5 --- app/package-lock.json | 15 +++++++++++++++ app/package.json | 1 + app/src/components/TaskListScreen.tsx | 25 +++++++++++++++++++++---- app/src/components/TaskRow.tsx | 5 ++++- app/src/lib/store.ts | 26 ++++++++++++++++++++++++-- 5 files changed, 65 insertions(+), 7 deletions(-) diff --git a/app/package-lock.json b/app/package-lock.json index 4f35b1a..da8f13a 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -28,6 +28,7 @@ "react": "19.2.3", "react-dom": "19.2.3", "react-native": "0.86.2", + "react-native-draggable-flatlist": "^4.0.3", "react-native-gesture-handler": "~2.32.0", "react-native-reanimated": "4.5.1", "react-native-safe-area-context": "~5.7.0", @@ -6444,6 +6445,20 @@ } } }, + "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-drawer-layout": { "version": "4.2.10", "resolved": "https://registry.npmjs.org/react-native-drawer-layout/-/react-native-drawer-layout-4.2.10.tgz", diff --git a/app/package.json b/app/package.json index 84c41ca..28f0dcc 100644 --- a/app/package.json +++ b/app/package.json @@ -23,6 +23,7 @@ "react": "19.2.3", "react-dom": "19.2.3", "react-native": "0.86.2", + "react-native-draggable-flatlist": "^4.0.3", "react-native-gesture-handler": "~2.32.0", "react-native-reanimated": "4.5.1", "react-native-safe-area-context": "~5.7.0", diff --git a/app/src/components/TaskListScreen.tsx b/app/src/components/TaskListScreen.tsx index 5b65af1..0fd384f 100644 --- a/app/src/components/TaskListScreen.tsx +++ b/app/src/components/TaskListScreen.tsx @@ -1,6 +1,8 @@ import { useState } from "react"; -import { FlatList, RefreshControl, StyleSheet, Text, View } from "react-native"; -import { sync, tasksByStatus } from "../lib/store"; +import { RefreshControl, StyleSheet, Text, View } from "react-native"; +import DraggableFlatList, { ScaleDecorator } from "react-native-draggable-flatlist"; +import { haptic } from "../lib/haptics"; +import { reorderTask, sync, tasksByStatus } from "../lib/store"; import { C } from "../lib/theme"; import { useStore } from "../lib/useStore"; import type { TaskStatus } from "../lib/types"; @@ -19,10 +21,24 @@ export default function TaskListScreen({ return ( - t.id} - renderItem={({ item }) => } + activationDistance={12} + onDragBegin={() => haptic.pickup()} + onDragEnd={({ data, from, to }) => { + if (from !== to) { + haptic.success(); + reorderTask(data[to].id, status, to); + } + }} + renderItem={({ item, drag, isActive }) => ( + + + + + + )} refreshControl={ void; }) { const router = useRouter(); const meta = [ @@ -37,7 +40,7 @@ export default function TaskRow({ return ( - router.push(`/task/${task.id}`)}> + router.push(`/task/${task.id}`)} onLongPress={onLongPress} delayLongPress={200}> & { title: string }): Task { priority: data.priority ?? 4, estimate_min: data.estimate_min ?? null, completed_at: null, - sort_order: data.sort_order ?? 0, + // New tasks land at the top of their list. + sort_order: + data.sort_order ?? + Math.min(0, ...state.tasks.filter((t) => t.status === (data.status ?? "next")).map((t) => t.sort_order)) - 1, created_at: now(), updated_at: now(), }; @@ -248,8 +251,27 @@ function byPriorityDue(a: Task, b: Task): number { ); } +// Manual arrangement wins: sort_order is the canonical list order (set by +// drag-to-reorder); priority/due only break ties for rows that never moved. +function byManualOrder(a: Task, b: Task): number { + return a.sort_order - b.sort_order || byPriorityDue(a, b); +} + export function tasksByStatus(s: State, status: Task["status"]): Task[] { - return s.tasks.filter((t) => t.status === status).sort(byPriorityDue); + return s.tasks.filter((t) => t.status === status).sort(byManualOrder); +} + +/** Re-slot a task at `index` within its status list (fractional sort_order). */ +export function reorderTask(id: string, status: Task["status"], index: number) { + const list = tasksByStatus(getState(), status).filter((t) => t.id !== id); + const prev = list[index - 1]?.sort_order; + const next = list[index]?.sort_order; + const sort_order = + prev !== undefined && next !== undefined ? (prev + next) / 2 + : prev !== undefined ? prev + 1 + : next !== undefined ? next - 1 + : 0; + updateTask(id, { sort_order }); } /** Overdue + due-today + deferred-arriving-today, across all open lists. */