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:
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)
+ }
+}