listless

A simple list app for Apple platforms
Log | Files | Refs | README | LICENSE

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