diff --git a/app/src/app/index.tsx b/app/src/app/index.tsx index 0a6d81a..3556a63 100644 --- a/app/src/app/index.tsx +++ b/app/src/app/index.tsx @@ -1,12 +1,14 @@ import { useFocusEffect, useRouter } from "expo-router"; -import { Pressable, ScrollView, StyleSheet, Text, View } from "react-native"; +import { Platform, Pressable, StyleSheet, Text, View } from "react-native"; +import { NestableDraggableFlatList, NestableScrollContainer, ScaleDecorator } from "react-native-draggable-flatlist"; +import WebSortableList from "../components/WebSortableList"; import Icon, { type IconName } from "../components/Icon"; import TaskRow, { TaskSeparator } from "../components/TaskRow"; -import { Card, Empty, Screen, SectionHeader, Title } from "../components/ui"; +import { Card, cardEdges, Empty, Screen, SectionHeader, Title } from "../components/ui"; import { haptic } from "../lib/haptics"; import { daysSince, useLastReview } from "../lib/review"; -import { todayTasks, visibleTasksByStatus } from "../lib/store"; -import { C, shadow, space, type } from "../lib/theme"; +import { reorderTask, todayTasks, visibleTasksByStatus } from "../lib/store"; +import { C, radius, shadow, space, type } from "../lib/theme"; import { useStore } from "../lib/useStore"; const MENU: { icon: IconName; label: string; href: string }[] = [ @@ -54,20 +56,49 @@ export default function Home() { return ( - + Next up {preview.length === 0 ? ( Nothing queued. Add the next thing on your mind. - ) : ( - - {preview.map((t, i) => ( - - {i > 0 && } - + ) : Platform.OS === "web" ? ( + reorderTask(id, "next", to)} + separator={} + renderItem={(t, i) => ( + + - ))} - + )} + /> + ) : ( + // Draggable so the "do this now" slot is a hand-picked choice, not + // just whatever sorted first. Preview indices are next[0..N], so a + // drop reorders the real Next list. + t.id} + activationDistance={12} + onDragBegin={() => haptic.pickup()} + onDragEnd={({ data, from, to }) => { + if (from !== to) { + haptic.success(); + reorderTask(data[to].id, "next", to); + } + }} + renderItem={({ item, drag, isActive, getIndex }) => { + const i = getIndex() ?? 0; + return ( + + + + + + ); + }} + ItemSeparatorComponent={TaskSeparator} + /> )} {next.length > PREVIEW && ( @@ -116,7 +147,7 @@ export default function Home() { )} - + {MENU.map((m) => ( @@ -149,6 +180,7 @@ export default function Home() { const s = StyleSheet.create({ content: { paddingHorizontal: space.lg, paddingBottom: 120 }, + lifted: { borderRadius: radius.lg, ...shadow.fab }, viewAll: { flexDirection: "row", diff --git a/app/src/components/TaskListScreen.tsx b/app/src/components/TaskListScreen.tsx index 0ab2658..3204d78 100644 --- a/app/src/components/TaskListScreen.tsx +++ b/app/src/components/TaskListScreen.tsx @@ -1,6 +1,7 @@ import { useState } from "react"; -import { Pressable, RefreshControl, StyleSheet, Text, View } from "react-native"; +import { Platform, Pressable, RefreshControl, ScrollView, StyleSheet, Text, View } from "react-native"; import DraggableFlatList, { ScaleDecorator } from "react-native-draggable-flatlist"; +import WebSortableList from "./WebSortableList"; import { haptic } from "../lib/haptics"; import { reorderTask, sync, tasksByStatus, visibleTasksByStatus } from "../lib/store"; import { C, radius, shadow, space, type } from "../lib/theme"; @@ -38,6 +39,42 @@ export default function TaskListScreen({ } const hiddenCount = all.length - tasks.length; + const deferredFooter = + hidesDeferred && (hiddenCount > 0 || showDeferred) ? ( + setShowDeferred((v) => !v)} + style={({ pressed }) => [s.deferredBtn, pressed && { opacity: 0.6 }]} + > + + {showDeferred ? "Hide deferred" : `${hiddenCount} deferred — show`} + + + ) : null; + + // The native drag library's drop handling is dead on react-native-web, so + // the web build reorders through WebSortableList's grab handles instead. + if (Platform.OS === "web") { + return ( + + + {tasks.length === 0 && {emptyHint}} + reorderTask(id, status, to)} + separator={} + renderItem={(item, i) => ( + + + + )} + /> + {deferredFooter} + + + ); + } + return ( } ListEmptyComponent={{emptyHint}} - ListFooterComponent={ - hidesDeferred && (hiddenCount > 0 || showDeferred) ? ( - setShowDeferred((v) => !v)} - style={({ pressed }) => [s.deferredBtn, pressed && { opacity: 0.6 }]} - > - - {showDeferred ? "Hide deferred" : `${hiddenCount} deferred — show`} - - - ) : null - } + ListFooterComponent={deferredFooter} contentContainerStyle={s.content} /> diff --git a/app/src/components/WebSortableList.tsx b/app/src/components/WebSortableList.tsx new file mode 100644 index 0000000..49e8429 --- /dev/null +++ b/app/src/components/WebSortableList.tsx @@ -0,0 +1,115 @@ +// Web-only reorderable list. react-native-draggable-flatlist's drop handling +// never completes on react-native-web (onDragEnd doesn't fire), and the +// PanResponder path is unreliable there too — so each row gets a grab handle +// driven by raw pointer events: pointerdown on the handle, window-level +// move/up listeners, and on release the target slot is computed from measured +// row heights and handed to onReorder (the same store mutation native uses). +import { useState, type ReactNode } from "react"; +import { StyleSheet, Text, View, type GestureResponderEvent } from "react-native"; +import { C, space } from "../lib/theme"; + +export default function WebSortableList({ + data, + renderItem, + onReorder, + separator, + disabled, +}: { + data: T[]; + renderItem: (item: T, index: number) => ReactNode; + /** Called with the dragged row's id and the index it was dropped at. */ + onReorder: (id: string, to: number) => void; + separator?: ReactNode; + disabled?: boolean; +}) { + // Stable mutable array (not a ref: refs may not be read during render). + const [heights] = useState([]); + const [drag, setDrag] = useState<{ index: number; dy: number } | null>(null); + + const targetIndex = (from: number, dy: number) => { + let idx = from; + let acc = 0; + while (dy > 0 && idx < data.length - 1 && dy > acc + (heights[idx + 1] ?? 48) / 2) { + acc += heights[idx + 1] ?? 48; + idx++; + } + while (dy < 0 && idx > 0 && -dy > acc + (heights[idx - 1] ?? 48) / 2) { + acc += heights[idx - 1] ?? 48; + idx--; + } + return idx; + }; + + const startDrag = (i: number, e: GestureResponderEvent) => { + if (disabled) return; + // RNW delivers a real PointerEvent; keep it off the row's Pressable. + e.stopPropagation(); + e.preventDefault(); + const native = e.nativeEvent as unknown as { clientY?: number; pageY: number }; + const startY = native.clientY ?? native.pageY; + setDrag({ index: i, dy: 0 }); + let dy = 0; + const move = (ev: PointerEvent) => { + dy = ev.clientY - startY; + setDrag({ index: i, dy }); + }; + const up = () => { + window.removeEventListener("pointermove", move); + window.removeEventListener("pointerup", up); + setDrag(null); + const to = targetIndex(i, dy); + if (to !== i) onReorder(data[i].id, to); + }; + window.addEventListener("pointermove", move); + window.addEventListener("pointerup", up); + }; + + return ( + + {data.map((item, i) => { + const dragging = drag?.index === i; + return ( + + {i > 0 && separator} + (heights[i] = e.nativeEvent.layout.height)} + style={dragging && [s.dragging, { transform: [{ translateY: drag.dy }] }]} + > + {renderItem(item, i)} + {!disabled && ( + startDrag(i, e as unknown as GestureResponderEvent)} style={s.handle}> + ⠿ + + )} + + + ); + })} + + ); +} + +const s = StyleSheet.create({ + dragging: { + zIndex: 10, + elevation: 10, + shadowColor: "#000", + shadowOpacity: 0.15, + shadowRadius: 12, + shadowOffset: { width: 0, height: 6 }, + }, + handle: { + position: "absolute", + right: 0, + top: 0, + bottom: 0, + width: 44, + alignItems: "center", + justifyContent: "center", + // @ts-expect-error web-only style + cursor: "grab", + touchAction: "none", + userSelect: "none", + }, + handleGlyph: { color: C.muted, fontSize: 15, paddingHorizontal: space.sm }, +});