← All repositories

listless

A simple list app for Apple platforms

git clone https://code.inqk.net/listless.git


commit b85e2e1eedfdc2655e3329289f749eeac6fb15ac
parent 8e06b2b8c9b00004155370d91009312c282b8d1a
Author: Michael Camilleri <[email protected]>
Date:   Wed,  4 Feb 2026 18:46:56 +0900

Initial drag and drop

Co-Authored-By: Claude 4.5 Sonnet <[email protected]>

Diffstat

MListless.xcodeproj/project.pbxproj | 8++++++++
MListless/Models/Listless.xcdatamodeld/Listless.xcdatamodel/contents | 1+
MListless/Models/TaskItem.swift | 2++
MListless/Models/TaskStore.swift | 34++++++++++++++++++++++++++++++++++
MListless/Sync/PersistenceController.swift | 29+++++++++++++++++++++++++++++
MListless/Views/TaskListView.swift | 43+++++++++++++++++++++++++++++++++++++++++--
MListless/Views/TaskRowView.swift | 23++++++++++++++++++++++-
AListlessMac/Views/TaskRowDragGesture.swift | 69+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
AListlessiOS/Views/TaskRowDragGesture.swift | 69+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

9 files changed, 275 insertions(+), 3 deletions(-)

diff --git a/Listless.xcodeproj/project.pbxproj b/Listless.xcodeproj/project.pbxproj

@@ -20,8 +20,10 @@
 		96617677059FABDBB80D642B /* Listless.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = C093494053E6C348F245D4EC /* Listless.xcdatamodeld */; };
 		A0AA8FD4C542E9AEB2437BC2 /* PersistenceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C14858BDFD1FD5119F1F24A6 /* PersistenceController.swift */; };
 		A8D13DD6196772ECA146CF2A /* PlatformScrollIndicatorsModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4D74AE2501F35974F57D21F /* PlatformScrollIndicatorsModifier.swift */; };
+		C1BD61D6DFA687E9CB9ACA60 /* TaskRowDragGesture.swift in Sources */ = {isa = PBXBuildFile; fileRef = 138612F350ECE29526F689B9 /* TaskRowDragGesture.swift */; };
 		C1FE091454864C4BBBBEB077 /* TaskItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBB8A3BEB346267B30B4675F /* TaskItem.swift */; };
 		C2400278D5F6F79C85A68897 /* TaskRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 007D500D2EFF3E66B780ADE0 /* TaskRowView.swift */; };
+		D6B3DBDD6A3F0A6E166CFFD5 /* TaskRowDragGesture.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B3D0B4710C7A0EE4F227851 /* TaskRowDragGesture.swift */; };
 		D878CD3A552C6A9685A30AA8 /* PlatformTextFieldWidthModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF6B0195EA0176B72DE9B092 /* PlatformTextFieldWidthModifier.swift */; };
 		D8EFF49E9156083D675D47F0 /* KeyboardNavigationModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E7B37EF6A2389656D105FF8 /* KeyboardNavigationModifier.swift */; };
 		DC71C45D92524C3893BF9FDB /* PlatformTextFieldWidthModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81880970CCFC2CB00B65047E /* PlatformTextFieldWidthModifier.swift */; };
@@ -34,6 +36,8 @@
 		01E141436176F83594E2F26B /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
 		0E7B37EF6A2389656D105FF8 /* KeyboardNavigationModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardNavigationModifier.swift; sourceTree = "<group>"; };
 		126108860D7878DDC3BECC4B /* Listless iOS.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = "Listless iOS.app"; sourceTree = BUILT_PRODUCTS_DIR; };
+		138612F350ECE29526F689B9 /* TaskRowDragGesture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskRowDragGesture.swift; sourceTree = "<group>"; };
+		1B3D0B4710C7A0EE4F227851 /* TaskRowDragGesture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskRowDragGesture.swift; sourceTree = "<group>"; };
 		1DA467DF2E59BDBE6EEF6A7D /* ListlessMacApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListlessMacApp.swift; sourceTree = "<group>"; };
 		3313FEDB101EECA4B344EEF4 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
 		48AE1AE43296C1692FA6F755 /* PlatformScrollIndicatorsModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlatformScrollIndicatorsModifier.swift; sourceTree = "<group>"; };
@@ -92,6 +96,7 @@
 			children = (
 				48AE1AE43296C1692FA6F755 /* PlatformScrollIndicatorsModifier.swift */,
 				81880970CCFC2CB00B65047E /* PlatformTextFieldWidthModifier.swift */,
+				1B3D0B4710C7A0EE4F227851 /* TaskRowDragGesture.swift */,
 			);
 			path = Views;
 			sourceTree = "<group>";
@@ -131,6 +136,7 @@
 			children = (
 				B4D74AE2501F35974F57D21F /* PlatformScrollIndicatorsModifier.swift */,
 				EF6B0195EA0176B72DE9B092 /* PlatformTextFieldWidthModifier.swift */,
+				138612F350ECE29526F689B9 /* TaskRowDragGesture.swift */,
 			);
 			path = Views;
 			sourceTree = "<group>";
@@ -254,6 +260,7 @@
 				D878CD3A552C6A9685A30AA8 /* PlatformTextFieldWidthModifier.swift in Sources */,
 				C1FE091454864C4BBBBEB077 /* TaskItem.swift in Sources */,
 				8E08F4C82D6F9E67667CB20A /* TaskListView.swift in Sources */,
+				C1BD61D6DFA687E9CB9ACA60 /* TaskRowDragGesture.swift in Sources */,
 				C2400278D5F6F79C85A68897 /* TaskRowView.swift in Sources */,
 				3ABE52A15C2059D8D5570528 /* TaskStore.swift in Sources */,
 			);
@@ -271,6 +278,7 @@
 				DC71C45D92524C3893BF9FDB /* PlatformTextFieldWidthModifier.swift in Sources */,
 				0ACA67F6578EFF181EE5C9A7 /* TaskItem.swift in Sources */,
 				42E4CDE1D17463554CC4F41F /* TaskListView.swift in Sources */,
+				D6B3DBDD6A3F0A6E166CFFD5 /* TaskRowDragGesture.swift in Sources */,
 				9041B7CED5298439BF7DC2C1 /* TaskRowView.swift in Sources */,
 				91EDF52C7C5C0B35E9D8B51E /* TaskStore.swift in Sources */,
 			);

diff --git a/Listless/Models/Listless.xcdatamodeld/Listless.xcdatamodel/contents b/Listless/Models/Listless.xcdatamodeld/Listless.xcdatamodel/contents

@@ -4,6 +4,7 @@
         <attribute name="createdAt" attributeType="Date" optional="YES" usesScalarValueType="NO"/>
         <attribute name="id" attributeType="UUID" optional="YES" usesScalarValueType="NO"/>
         <attribute name="isCompleted" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
+        <attribute name="sortOrder" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES" indexed="YES"/>
         <attribute name="title" attributeType="String" defaultValueString=""/>
         <attribute name="updatedAt" attributeType="Date" optional="YES" usesScalarValueType="NO"/>
     </entity>

diff --git a/Listless/Models/TaskItem.swift b/Listless/Models/TaskItem.swift

@@ -8,6 +8,7 @@ public class TaskItem: NSManagedObject, Identifiable {
     @NSManaged public var isCompleted: Bool
     @NSManaged public var createdAt: Date
     @NSManaged public var updatedAt: Date
+    @NSManaged public var sortOrder: Int64
 
     @nonobjc public class func fetchRequest() -> NSFetchRequest<TaskItem> {
         return NSFetchRequest<TaskItem>(entityName: "TaskItem")
@@ -20,6 +21,7 @@ public class TaskItem: NSManagedObject, Identifiable {
         setPrimitiveValue(Date(), forKey: "updatedAt")
         setPrimitiveValue(false, forKey: "isCompleted")
         setPrimitiveValue("", forKey: "title")
+        setPrimitiveValue(0, forKey: "sortOrder")
     }
 
     public override func willSave() {

diff --git a/Listless/Models/TaskStore.swift b/Listless/Models/TaskStore.swift

@@ -32,6 +32,12 @@ final class TaskStore {
     func createTask(title: String = "") -> TaskItem {
         let task = TaskItem(context: context)
         task.title = title
+
+        // Set sortOrder to end of active tasks
+        let activeTasks = fetchTasks().filter { !$0.isCompleted }
+        let maxOrder = activeTasks.map(\.sortOrder).max() ?? 0
+        task.sortOrder = maxOrder + 1000
+
         save()
         return task
     }
@@ -45,6 +51,12 @@ final class TaskStore {
     func uncomplete(taskID: UUID) {
         guard let task = findTask(id: taskID) else { return }
         task.isCompleted = false
+
+        // Move to end of active tasks
+        let activeTasks = fetchTasks().filter { !$0.isCompleted }
+        let maxOrder = activeTasks.map(\.sortOrder).max() ?? 0
+        task.sortOrder = maxOrder + 1000
+
         save()
     }
 
@@ -60,6 +72,28 @@ final class TaskStore {
         save()
     }
 
+    func moveTask(taskID: UUID, toIndex: Int) {
+        let activeTasks = fetchTasks().filter { !$0.isCompleted }
+            .sorted { $0.sortOrder < $1.sortOrder }
+
+        guard let currentIndex = activeTasks.firstIndex(where: { $0.id == taskID }) else { return }
+        guard currentIndex != toIndex else { return }
+
+        var reordered = activeTasks
+        let task = reordered.remove(at: currentIndex)
+
+        // Clamp toIndex to valid range after removal
+        let insertIndex = min(toIndex, reordered.count)
+        reordered.insert(task, at: insertIndex)
+
+        // Reassign sortOrder with gaps of 1000
+        for (index, task) in reordered.enumerated() {
+            task.sortOrder = Int64(index) * 1000
+        }
+
+        save()
+    }
+
     private func findTask(id: UUID) -> TaskItem? {
         let request = TaskItem.fetchRequest()
         request.predicate = NSPredicate(format: "id == %@", id as CVarArg)

diff --git a/Listless/Sync/PersistenceController.swift b/Listless/Sync/PersistenceController.swift

@@ -38,6 +38,8 @@ final class PersistenceController {
 
         container.viewContext.automaticallyMergesChangesFromParent = true
         container.viewContext.mergePolicy = NSMergePolicy(merge: .mergeByPropertyObjectTrumpMergePolicyType)
+
+        performDataMigrationIfNeeded()
     }
 
     func save() {
@@ -52,4 +54,31 @@ final class PersistenceController {
             }
         }
     }
+
+    private func performDataMigrationIfNeeded() {
+        let hasRun = UserDefaults.standard.bool(forKey: "didMigrateSortOrder_v1")
+        guard !hasRun else { return }
+
+        let context = container.viewContext
+        let request = TaskItem.fetchRequest()
+        request.sortDescriptors = [
+            NSSortDescriptor(keyPath: \TaskItem.isCompleted, ascending: true),
+            NSSortDescriptor(keyPath: \TaskItem.createdAt, ascending: true),
+        ]
+
+        guard let tasks = try? context.fetch(request) else { return }
+
+        let activeTasks = tasks.filter { !$0.isCompleted }
+
+        // Only set sortOrder for active tasks
+        for (index, task) in activeTasks.enumerated() {
+            task.sortOrder = Int64(index) * 1000
+        }
+
+        // Completed tasks: sortOrder can be 0 (not used)
+        // They'll be sorted by updatedAt instead
+
+        try? context.save()
+        UserDefaults.standard.set(true, forKey: "didMigrateSortOrder_v1")
+    }
 }

diff --git a/Listless/Views/TaskListView.swift b/Listless/Views/TaskListView.swift

@@ -18,6 +18,7 @@ struct TaskListView: View {
     @FocusState private var focusedField: FocusField?
     @State private var selectedTaskID: UUID?
     @State private var refreshID = UUID()
+    @State private var draggedTask: UUID?
 
     init(store: TaskStore = TaskStore()) {
         _store = State(wrappedValue: store)
@@ -54,19 +55,40 @@ struct TaskListView: View {
                             .padding(.vertical, 8)
                     }
 
-                    ForEach(activeTasks) { task in
+                    ForEach(Array(activeTasks.enumerated()), id: \.element.id) { index, task in
                         TaskRowView(
                             task: task,
                             taskID: task.id,
                             isSelected: selectedTaskID == task.id,
+                            isDragging: draggedTask == task.id,
                             focusedField: $focusedField,
                             onToggle: toggleCompletion(_:),
                             onSubmit: handleSubmit(_:),
                             onTitleChange: updateTitle(_:_:),
                             onDelete: deleteTask(_:),
-                            onSelect: { selectTask(task.id) }
+                            onSelect: { selectTask(task.id) },
+                            onDragStart: { handleDragStart(task.id) },
+                            onDragEnd: handleDragEnd,
+                            onDrop: { droppedTaskID in
+                                handleDrop(taskID: droppedTaskID, at: index)
+                            }
                         )
                     }
+
+                    // Drop zone at the end to allow dropping below the last item
+                    if !activeTasks.isEmpty {
+                        Color.clear
+                            .frame(height: 44)
+                            .contentShape(Rectangle())
+                            .dropDestination(for: String.self) { items, location in
+                                guard let droppedUUIDString = items.first,
+                                      let droppedUUID = UUID(uuidString: droppedUUIDString) else {
+                                    return false
+                                }
+                                handleDrop(taskID: droppedUUID, at: activeTasks.count)
+                                return true
+                            }
+                    }
                 }
                 .frame(maxWidth: .infinity, alignment: .topLeading)
                 .onChange(of: selectedTaskID) { oldValue, newValue in
@@ -102,10 +124,12 @@ struct TaskListView: View {
 
     private var activeTasks: [TaskItem] {
         Array(tasks.filter { !$0.isCompleted })
+            .sorted { $0.sortOrder < $1.sortOrder }
     }
 
     private var completedTasks: [TaskItem] {
         Array(tasks.filter { $0.isCompleted })
+            .sorted { $0.updatedAt > $1.updatedAt }
     }
 
     private var allTasksInDisplayOrder: [TaskItem] {
@@ -249,4 +273,19 @@ struct TaskListView: View {
     private func focusTextField(_ taskID: UUID) {
         focusedField = .task(taskID)
     }
+
+    // MARK: - Drag and Drop
+
+    private func handleDragStart(_ taskID: UUID) {
+        draggedTask = taskID
+    }
+
+    private func handleDragEnd() {
+        draggedTask = nil
+    }
+
+    private func handleDrop(taskID: UUID, at index: Int) {
+        store.moveTask(taskID: taskID, toIndex: index)
+        draggedTask = nil
+    }
 }

diff --git a/Listless/Views/TaskRowView.swift b/Listless/Views/TaskRowView.swift

@@ -4,11 +4,15 @@ struct TaskRowView: View {
     let task: TaskItem
     let taskID: UUID
     let isSelected: Bool
+    let isDragging: Bool
     let onToggle: (TaskItem) -> Void
     let onSubmit: (TaskItem) -> Void
     let onTitleChange: (TaskItem, String) -> Void
     let onDelete: (TaskItem) -> Void
     let onSelect: () -> Void
+    let onDragStart: () -> Void
+    let onDragEnd: () -> Void
+    let onDrop: (UUID) -> Void
     @FocusState.Binding var focusedField: TaskListView.FocusField?
 
     @State private var title: String
@@ -17,21 +21,29 @@ struct TaskRowView: View {
         task: TaskItem,
         taskID: UUID,
         isSelected: Bool,
+        isDragging: Bool = false,
         focusedField: FocusState<TaskListView.FocusField?>.Binding,
         onToggle: @escaping (TaskItem) -> Void,
         onSubmit: @escaping (TaskItem) -> Void,
         onTitleChange: @escaping (TaskItem, String) -> Void,
         onDelete: @escaping (TaskItem) -> Void,
-        onSelect: @escaping () -> Void
+        onSelect: @escaping () -> Void,
+        onDragStart: @escaping () -> Void = {},
+        onDragEnd: @escaping () -> Void = {},
+        onDrop: @escaping (UUID) -> Void = { _ in }
     ) {
         self.task = task
         self.taskID = taskID
         self.isSelected = isSelected
+        self.isDragging = isDragging
         self.onToggle = onToggle
         self.onSubmit = onSubmit
         self.onTitleChange = onTitleChange
         self.onDelete = onDelete
         self.onSelect = onSelect
+        self.onDragStart = onDragStart
+        self.onDragEnd = onDragEnd
+        self.onDrop = onDrop
         _focusedField = focusedField
         _title = State(initialValue: task.title)
     }
@@ -100,6 +112,15 @@ struct TaskRowView: View {
                 onSelect()
             }
         }
+        .opacity(isDragging ? 0.5 : 1.0)
+        .taskDragGesture(
+            isActive: !task.isCompleted,
+            taskID: task.id,
+            taskTitle: task.title,
+            onDragStart: onDragStart,
+            onDragEnd: onDragEnd,
+            onDrop: onDrop
+        )
     }
 
     private var selectionBackground: some View {

diff --git a/ListlessMac/Views/TaskRowDragGesture.swift b/ListlessMac/Views/TaskRowDragGesture.swift

@@ -0,0 +1,69 @@
+import SwiftUI
+import UniformTypeIdentifiers
+
+extension View {
+    func taskDragGesture(
+        isActive: Bool,
+        taskID: UUID,
+        taskTitle: String,
+        onDragStart: @escaping () -> Void,
+        onDragEnd: @escaping () -> Void,
+        onDrop: @escaping (UUID) -> Void
+    ) -> some View {
+        self.modifier(TaskRowDragGesture(
+            isActive: isActive,
+            taskID: taskID,
+            taskTitle: taskTitle,
+            onDragStart: onDragStart,
+            onDragEnd: onDragEnd,
+            onDrop: onDrop
+        ))
+    }
+}
+
+struct TaskRowDragGesture: ViewModifier {
+    let isActive: Bool
+    let taskID: UUID
+    let taskTitle: String
+    let onDragStart: () -> Void
+    let onDragEnd: () -> Void
+    let onDrop: (UUID) -> Void
+
+    func body(content: Content) -> some View {
+        if isActive {
+            content
+                .onDrag {
+                    onDragStart()
+                    return NSItemProvider(object: taskID.uuidString as NSString)
+                } preview: {
+                    dragPreview
+                }
+                .dropDestination(for: String.self) { items, location in
+                    guard let droppedUUIDString = items.first,
+                          let droppedUUID = UUID(uuidString: droppedUUIDString) else {
+                        return false
+                    }
+                    DispatchQueue.main.async {
+                        onDrop(droppedUUID)
+                        onDragEnd()
+                    }
+                    return true
+                }
+        } else {
+            content
+        }
+    }
+
+    private var dragPreview: some View {
+        HStack(spacing: 12) {
+            Image(systemName: "circle")
+                .frame(width: 20, height: 20)
+            Text(taskTitle.isEmpty ? "New task" : taskTitle)
+                .font(.body)
+        }
+        .padding(.vertical, 8)
+        .padding(.horizontal, 16)
+        .background(Color(nsColor: .controlBackgroundColor))
+        .cornerRadius(6)
+    }
+}

diff --git a/ListlessiOS/Views/TaskRowDragGesture.swift b/ListlessiOS/Views/TaskRowDragGesture.swift

@@ -0,0 +1,69 @@
+import SwiftUI
+import UniformTypeIdentifiers
+
+extension View {
+    func taskDragGesture(
+        isActive: Bool,
+        taskID: UUID,
+        taskTitle: String,
+        onDragStart: @escaping () -> Void,
+        onDragEnd: @escaping () -> Void,
+        onDrop: @escaping (UUID) -> Void
+    ) -> some View {
+        self.modifier(TaskRowDragGesture(
+            isActive: isActive,
+            taskID: taskID,
+            taskTitle: taskTitle,
+            onDragStart: onDragStart,
+            onDragEnd: onDragEnd,
+            onDrop: onDrop
+        ))
+    }
+}
+
+struct TaskRowDragGesture: ViewModifier {
+    let isActive: Bool
+    let taskID: UUID
+    let taskTitle: String
+    let onDragStart: () -> Void
+    let onDragEnd: () -> Void
+    let onDrop: (UUID) -> Void
+
+    func body(content: Content) -> some View {
+        if isActive {
+            content
+                .onDrag {
+                    onDragStart()
+                    return NSItemProvider(object: taskID.uuidString as NSString)
+                } preview: {
+                    dragPreview
+                }
+                .dropDestination(for: String.self) { items, location in
+                    guard let droppedUUIDString = items.first,
+                          let droppedUUID = UUID(uuidString: droppedUUIDString) else {
+                        return false
+                    }
+                    DispatchQueue.main.async {
+                        onDrop(droppedUUID)
+                        onDragEnd()
+                    }
+                    return true
+                }
+        } else {
+            content
+        }
+    }
+
+    private var dragPreview: some View {
+        HStack(spacing: 12) {
+            Image(systemName: "circle")
+                .frame(width: 20, height: 20)
+            Text(taskTitle.isEmpty ? "New task" : taskTitle)
+                .font(.body)
+        }
+        .padding(.vertical, 8)
+        .padding(.horizontal, 16)
+        .background(Color(uiColor: .systemBackground))
+        .cornerRadius(6)
+    }
+}