← All repositories

listless

A simple list app for Apple platforms

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


commit 22d14a88e25a2f69ffc59fda81c7c5b4d8532fa7
parent 432762286b16522f3b498337b30b34b76d89a3ed
Author: Michael Camilleri <[email protected]>
Date:   Tue, 17 Feb 2026 15:22:27 +0900

Add pull to create

Co-Authored-By: Codex GPT 5.3 <[email protected]>

Diffstat

MListless.xcodeproj/project.pbxproj | 20++++++++++++++++++++
MListless/Models/TaskStore.swift | 12++++++++----
MListless/Views/TaskListView.swift | 194+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MListlessMac/Views/ClickableTextField.swift | 16+++++++++-------
AListlessMac/Views/TaskListView+NavigationHeader.swift | 7+++++++
AListlessMac/Views/TaskListView+PullToCreate.swift | 7+++++++
MListlessMac/Views/TaskRowDragGesture.swift | 4+++-
MListlessiOS/ListlessiOSApp.swift | 20+++++++++++---------
AListlessiOS/Views/PullToCreate.swift | 49+++++++++++++++++++++++++++++++++++++++++++++++++
MListlessiOS/Views/TappableTextField.swift | 120+++++++++++++++++++++++++++++++++++++++++++++----------------------------------
AListlessiOS/Views/TaskListView+NavigationHeader.swift | 16++++++++++++++++
AListlessiOS/Views/TaskListView+PullToCreate.swift | 9+++++++++
MListlessiOS/Views/TaskRowDragGesture.swift | 97++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------

13 files changed, 485 insertions(+), 86 deletions(-)

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

@@ -14,6 +14,7 @@
 		269B93D5543770B464DFB37A /* TaskStoreOrderingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D3A2DDCE24E54ABCCFBBD4C /* TaskStoreOrderingTests.swift */; };
 		3ABE52A15C2059D8D5570528 /* TaskStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC3DEE364304587D280C5672 /* TaskStore.swift */; };
 		3D1F551A03B97ECF4E3DC8B0 /* TaskRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E06485DBE35B60868E14202A /* TaskRowView.swift */; };
+		3FCEFB586D9A9085A201AE7D /* TaskListView+PullToCreate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 95BF73E1B8FE337B76D3E757 /* TaskListView+PullToCreate.swift */; };
 		42E4CDE1D17463554CC4F41F /* TaskListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 537A913AC421BAEF60D26D9C /* TaskListView.swift */; };
 		5B60B409CE4BA668DB30A65D /* Listless.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = C093494053E6C348F245D4EC /* Listless.xcdatamodeld */; };
 		614FCCA450EC0BFFD8B40640 /* ListlessMacApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DA467DF2E59BDBE6EEF6A7D /* ListlessMacApp.swift */; };
@@ -24,11 +25,14 @@
 		83378A0575640417ED41FC25 /* ClickableTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F2BB7ABCABA4EB67180A200 /* ClickableTextField.swift */; };
 		8E08F4C82D6F9E67667CB20A /* TaskListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 537A913AC421BAEF60D26D9C /* TaskListView.swift */; };
 		8E680EC18B0339931E785F0C /* PlatformScrollIndicatorsModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48AE1AE43296C1692FA6F755 /* PlatformScrollIndicatorsModifier.swift */; };
+		907478D07E5CCD4966579306 /* TaskListView+NavigationHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0C28F7AF2AFAF677F678087 /* TaskListView+NavigationHeader.swift */; };
 		91EDF52C7C5C0B35E9D8B51E /* TaskStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC3DEE364304587D280C5672 /* TaskStore.swift */; };
+		92932AEC64B65AD6F95A1E42 /* TaskListView+PullToCreate.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF51ACA55E28AA1A11D3962C /* TaskListView+PullToCreate.swift */; };
 		96617677059FABDBB80D642B /* Listless.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = C093494053E6C348F245D4EC /* Listless.xcdatamodeld */; };
 		99977BFA37FBAAA49AF6B71E /* TaskStoreEdgeCaseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B2BBE01D99CDA278BCB9F49 /* TaskStoreEdgeCaseTests.swift */; };
 		A0AA8FD4C542E9AEB2437BC2 /* PersistenceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C14858BDFD1FD5119F1F24A6 /* PersistenceController.swift */; };
 		A8D13DD6196772ECA146CF2A /* PlatformScrollIndicatorsModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4D74AE2501F35974F57D21F /* PlatformScrollIndicatorsModifier.swift */; };
+		AB5D681B98FC1D2FD7BF2FAA /* TaskListView+NavigationHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7AF738EEB6ABDF86B0B781F /* TaskListView+NavigationHeader.swift */; };
 		C1BD61D6DFA687E9CB9ACA60 /* TaskRowDragGesture.swift in Sources */ = {isa = PBXBuildFile; fileRef = 138612F350ECE29526F689B9 /* TaskRowDragGesture.swift */; };
 		C1FE091454864C4BBBBEB077 /* TaskItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBB8A3BEB346267B30B4675F /* TaskItem.swift */; };
 		D6B3DBDD6A3F0A6E166CFFD5 /* TaskRowDragGesture.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B3D0B4710C7A0EE4F227851 /* TaskRowDragGesture.swift */; };
@@ -38,6 +42,7 @@
 		DC71C45D92524C3893BF9FDB /* PlatformTextFieldWidthModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81880970CCFC2CB00B65047E /* PlatformTextFieldWidthModifier.swift */; };
 		DC73A39A269AB495BCE1AC48 /* PersistenceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C14858BDFD1FD5119F1F24A6 /* PersistenceController.swift */; };
 		E3C6D4AE5E729D34086F132D /* TappableTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 95568F6A132636D04B8CF593 /* TappableTextField.swift */; };
+		E47136CA7428927395D8C7C7 /* PullToCreate.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFF7D84B54AE70036D205CA4 /* PullToCreate.swift */; };
 		ECD5E7EA05AE1C00B38C939E /* TaskStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 967F7ECEB3915CEDCE584872 /* TaskStoreTests.swift */; };
 		F0B2B806BD84A4F2FDF8E038 /* ListlessiOSApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC245331D715EA85887C0BA0 /* ListlessiOSApp.swift */; };
 		F50E8B8F73F64D8E641DC74C /* TaskStoreCompletionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E88DDD119EEEECCD45F36D2 /* TaskStoreCompletionTests.swift */; };
@@ -81,11 +86,15 @@
 		93FF5B9F5B979D54D5DEE192 /* TaskListView+Toolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TaskListView+Toolbar.swift"; sourceTree = "<group>"; };
 		944BAE054AAC1B9C4FC954F9 /* .gitkeep */ = {isa = PBXFileReference; path = .gitkeep; sourceTree = "<group>"; };
 		95568F6A132636D04B8CF593 /* TappableTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TappableTextField.swift; sourceTree = "<group>"; };
+		95BF73E1B8FE337B76D3E757 /* TaskListView+PullToCreate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TaskListView+PullToCreate.swift"; sourceTree = "<group>"; };
 		967F7ECEB3915CEDCE584872 /* TaskStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskStoreTests.swift; sourceTree = "<group>"; };
 		9B2BBE01D99CDA278BCB9F49 /* TaskStoreEdgeCaseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskStoreEdgeCaseTests.swift; sourceTree = "<group>"; };
 		9FBEB58DD41817F09B0EB9F0 /* Listless.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = Listless.xcdatamodel; sourceTree = "<group>"; };
+		A0C28F7AF2AFAF677F678087 /* TaskListView+NavigationHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TaskListView+NavigationHeader.swift"; sourceTree = "<group>"; };
+		A7AF738EEB6ABDF86B0B781F /* TaskListView+NavigationHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TaskListView+NavigationHeader.swift"; sourceTree = "<group>"; };
 		AC245331D715EA85887C0BA0 /* ListlessiOSApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListlessiOSApp.swift; sourceTree = "<group>"; };
 		B4D74AE2501F35974F57D21F /* PlatformScrollIndicatorsModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlatformScrollIndicatorsModifier.swift; sourceTree = "<group>"; };
+		BFF7D84B54AE70036D205CA4 /* PullToCreate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PullToCreate.swift; sourceTree = "<group>"; };
 		C14858BDFD1FD5119F1F24A6 /* PersistenceController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceController.swift; sourceTree = "<group>"; };
 		C71466C5CD1A5BA984352F8D /* Listless iOS Unit Tests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = "Listless iOS Unit Tests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; };
 		C9B14DC786A336008AAB78EE /* .gitkeep */ = {isa = PBXFileReference; path = .gitkeep; sourceTree = "<group>"; };
@@ -93,6 +102,7 @@
 		D123BB181208FC825777B0A7 /* .gitkeep */ = {isa = PBXFileReference; path = .gitkeep; sourceTree = "<group>"; };
 		D41D6E0CED14D79F31C45062 /* HoverCursorModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HoverCursorModifier.swift; sourceTree = "<group>"; };
 		DC3DEE364304587D280C5672 /* TaskStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskStore.swift; sourceTree = "<group>"; };
+		DF51ACA55E28AA1A11D3962C /* TaskListView+PullToCreate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TaskListView+PullToCreate.swift"; sourceTree = "<group>"; };
 		E06485DBE35B60868E14202A /* TaskRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskRowView.swift; sourceTree = "<group>"; };
 		EF6B0195EA0176B72DE9B092 /* PlatformTextFieldWidthModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlatformTextFieldWidthModifier.swift; sourceTree = "<group>"; };
 		FBB8A3BEB346267B30B4675F /* TaskItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskItem.swift; sourceTree = "<group>"; };
@@ -138,7 +148,10 @@
 				82A3509AD32A54434BCC8017 /* HoverCursorModifier.swift */,
 				48AE1AE43296C1692FA6F755 /* PlatformScrollIndicatorsModifier.swift */,
 				81880970CCFC2CB00B65047E /* PlatformTextFieldWidthModifier.swift */,
+				BFF7D84B54AE70036D205CA4 /* PullToCreate.swift */,
 				95568F6A132636D04B8CF593 /* TappableTextField.swift */,
+				A0C28F7AF2AFAF677F678087 /* TaskListView+NavigationHeader.swift */,
+				DF51ACA55E28AA1A11D3962C /* TaskListView+PullToCreate.swift */,
 				93FF5B9F5B979D54D5DEE192 /* TaskListView+Toolbar.swift */,
 				1B3D0B4710C7A0EE4F227851 /* TaskRowDragGesture.swift */,
 				44C16D36971A140364159FB9 /* TaskRowSwipeGesture.swift */,
@@ -184,6 +197,8 @@
 				D41D6E0CED14D79F31C45062 /* HoverCursorModifier.swift */,
 				B4D74AE2501F35974F57D21F /* PlatformScrollIndicatorsModifier.swift */,
 				EF6B0195EA0176B72DE9B092 /* PlatformTextFieldWidthModifier.swift */,
+				A7AF738EEB6ABDF86B0B781F /* TaskListView+NavigationHeader.swift */,
+				95BF73E1B8FE337B76D3E757 /* TaskListView+PullToCreate.swift */,
 				431B0EA72D590AF0CC868515 /* TaskListView+Toolbar.swift */,
 				138612F350ECE29526F689B9 /* TaskRowDragGesture.swift */,
 				E06485DBE35B60868E14202A /* TaskRowView.swift */,
@@ -359,6 +374,8 @@
 				A8D13DD6196772ECA146CF2A /* PlatformScrollIndicatorsModifier.swift in Sources */,
 				D878CD3A552C6A9685A30AA8 /* PlatformTextFieldWidthModifier.swift in Sources */,
 				C1FE091454864C4BBBBEB077 /* TaskItem.swift in Sources */,
+				AB5D681B98FC1D2FD7BF2FAA /* TaskListView+NavigationHeader.swift in Sources */,
+				3FCEFB586D9A9085A201AE7D /* TaskListView+PullToCreate.swift in Sources */,
 				074A81D9DAF58E8E088CBC89 /* TaskListView+Toolbar.swift in Sources */,
 				8E08F4C82D6F9E67667CB20A /* TaskListView.swift in Sources */,
 				C1BD61D6DFA687E9CB9ACA60 /* TaskRowDragGesture.swift in Sources */,
@@ -379,8 +396,11 @@
 				DC73A39A269AB495BCE1AC48 /* PersistenceController.swift in Sources */,
 				8E680EC18B0339931E785F0C /* PlatformScrollIndicatorsModifier.swift in Sources */,
 				DC71C45D92524C3893BF9FDB /* PlatformTextFieldWidthModifier.swift in Sources */,
+				E47136CA7428927395D8C7C7 /* PullToCreate.swift in Sources */,
 				E3C6D4AE5E729D34086F132D /* TappableTextField.swift in Sources */,
 				0ACA67F6578EFF181EE5C9A7 /* TaskItem.swift in Sources */,
+				907478D07E5CCD4966579306 /* TaskListView+NavigationHeader.swift in Sources */,
+				92932AEC64B65AD6F95A1E42 /* TaskListView+PullToCreate.swift in Sources */,
 				D9DA553485AD0CA28D5BE0C8 /* TaskListView+Toolbar.swift in Sources */,
 				42E4CDE1D17463554CC4F41F /* TaskListView.swift in Sources */,
 				D6B3DBDD6A3F0A6E166CFFD5 /* TaskRowDragGesture.swift in Sources */,

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

@@ -36,15 +36,19 @@ final class TaskStore {
         }
     }
 
-    func createTask(title: String = "") -> TaskItem {
+    func createTask(title: String = "", atBeginning: Bool = false) -> TaskItem {
         let task = TaskItem(context: context)
         task.title = title
 
-        // Set sortOrder to end of active tasks
         context.processPendingChanges()
         let activeTasks = fetchTasks().filter { !$0.isCompleted }
-        let maxOrder = activeTasks.map(\.sortOrder).max() ?? -1000
-        task.sortOrder = maxOrder + 1000
+        if atBeginning {
+            let minOrder = activeTasks.map(\.sortOrder).min() ?? 0
+            task.sortOrder = minOrder - 1000
+        } else {
+            let maxOrder = activeTasks.map(\.sortOrder).max() ?? -1000
+            task.sortOrder = maxOrder + 1000
+        }
 
         save()
         return task

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

@@ -25,6 +25,16 @@ struct TaskListView: View {
     @State private var swipingTaskID: UUID?
     @State private var visualOrder: [UUID]?
     @State private var pendingFocus: FocusField?
+    @State var pullOffset: CGFloat = 0
+    #if os(iOS)
+    private struct IOSDragState {
+        let taskID: UUID
+        var fingerPosition: CGPoint
+    }
+    @State private var iosDragState: IOSDragState? = nil
+    @State private var rowFrames: [UUID: CGRect] = [:]
+    @State private var scrollViewMinY: CGFloat = 0
+    #endif
 
     init(store: TaskStore = TaskStore()) {
         _store = State(wrappedValue: store)
@@ -33,8 +43,50 @@ struct TaskListView: View {
     var body: some View {
         ScrollView {
             VStack(alignment: .leading, spacing: vStackSpacing) {
+                navigationHeader
+                pullToCreateIndicatorRow
                 ForEach(Array(displayActiveTasks.enumerated()), id: \.element.id) { index, task in
                     let taskID = task.id
+                    #if os(iOS)
+                    Group {
+                        if iosDragState?.taskID == taskID {
+                            // Ghost spacer: placeholder silhouette while card is floating
+                            UnevenRoundedRectangle(
+                                topLeadingRadius: 0, bottomLeadingRadius: 0,
+                                bottomTrailingRadius: 14, topTrailingRadius: 14
+                            )
+                            .fill(Color.taskCard.opacity(0.4))
+                            .frame(height: rowFrames[taskID]?.height ?? 56)
+                        } else {
+                            TaskRowView(
+                                task: task,
+                                taskID: taskID,
+                                index: index,
+                                totalTasks: displayActiveTasks.count,
+                                isSelected: selectedTaskID == taskID,
+                                focusedField: $focusedField,
+                                onToggle: { toggleCompletion($0) },
+                                onTitleChange: { updateTitle($0, $1) },
+                                onDelete: { deleteTask($0) },
+                                onSelect: { selectTask($0) },
+                                onStartEdit: { startEditing($0) },
+                                onEndEdit: { endEditing($0, shouldCreateNewTask: $1) }
+                            )
+                            .taskDragGesture(
+                                isActive: !task.isCompleted && swipingTaskID == nil,
+                                taskID: taskID,
+                                onDragStart: { startDrag(taskID: taskID) },
+                                onDragChanged: { point in handleIOSDragChanged(taskID: taskID, point: point) },
+                                onDragEnded: { commitIOSDrag() }
+                            )
+                        }
+                    }
+                    .onGeometryChange(for: CGRect.self) { proxy in
+                        proxy.frame(in: .global)
+                    } action: { frame in
+                        rowFrames[taskID] = frame
+                    }
+                    #else
                     TaskRowView(
                         task: task,
                         taskID: taskID,
@@ -95,8 +147,10 @@ struct TaskListView: View {
                             }
                         }
                     }
+                    #endif
                 }
 
+                #if os(macOS)
                 // Drop zone at the end
                 if !activeTasks.isEmpty && draggedTaskID != nil {
                     Color.clear
@@ -109,6 +163,7 @@ struct TaskListView: View {
                                 }
                             })
                 }
+                #endif
 
                 ForEach(completedTasks) { task in
                     let taskID = task.id
@@ -129,14 +184,72 @@ struct TaskListView: View {
             .padding(.trailing, 16)
             .padding(.vertical, 12)
             #endif
+            .offset(y: -pullOffset)
+            #if os(macOS)
             .dropDestination(for: String.self) { items, location in
                 handleDrop(items: items)
             }
+            #endif
         }
         #if os(iOS)
         .background {
             Color.outerBackground.ignoresSafeArea()
         }
+        .overlay(alignment: .topLeading) {
+            if let state = iosDragState,
+               let task = activeTasks.first(where: { $0.id == state.taskID }),
+               let frame = rowFrames[state.taskID] {
+                HStack(spacing: 0) {
+                    Rectangle()
+                        .fill(
+                            accentColor(
+                                forIndex: visualOrder?.firstIndex(of: state.taskID) ?? 0,
+                                total: displayActiveTasks.count
+                            )
+                        )
+                        .frame(width: 8)
+                    Text(task.title.isEmpty ? "New task" : task.title)
+                        .font(.body)
+                        .frame(maxWidth: .infinity, alignment: .leading)
+                        .padding()
+                        .background(Color.taskCard)
+                }
+                .clipShape(
+                    UnevenRoundedRectangle(
+                        topLeadingRadius: 0, bottomLeadingRadius: 0,
+                        bottomTrailingRadius: 14, topTrailingRadius: 14
+                    )
+                )
+                .shadow(color: .black.opacity(0.18), radius: 10, y: 4)
+                .frame(width: frame.width, height: frame.height)
+                .position(
+                    x: UIScreen.main.bounds.midX,
+                    y: state.fingerPosition.y - scrollViewMinY
+                )
+                .ignoresSafeArea()
+                .allowsHitTesting(false)
+                .zIndex(100)
+            }
+        }
+        .onGeometryChange(for: CGFloat.self) { proxy in
+            proxy.frame(in: .global).minY
+        } action: { minY in
+            scrollViewMinY = minY
+        }
+        .onScrollGeometryChange(for: CGFloat.self) { geo in
+            max(0, -(geo.contentOffset.y + geo.contentInsets.top))
+        } action: { _, pullDistance in
+            pullOffset = pullDistance
+        }
+        .onScrollPhaseChange { oldPhase, newPhase in
+            if oldPhase == .interacting && newPhase != .interacting {
+                if pullOffset >= pullCreateThreshold { createNewTaskAtTop() }
+                pullOffset = 0
+            }
+        }
+        .sensoryFeedback(.impact(weight: .medium), trigger: pullOffset >= pullCreateThreshold) { old, new in
+            !old && new
+        }
         #endif
         .contentShape(Rectangle())
         .onTapGesture {
@@ -227,6 +340,15 @@ struct TaskListView: View {
         return lastTask.id == taskID
     }
 
+    private func createNewTaskAtTop() {
+        draggedTaskID = nil
+        visualOrder = nil
+        let task = store.createTask(title: "", atBeginning: true)
+        pendingFocus = .task(task.id)
+        focusedField = .task(task.id)
+        selectedTaskID = task.id
+    }
+
     func createNewTask() {
         // Clear any lingering drag state
         draggedTaskID = nil
@@ -243,6 +365,7 @@ struct TaskListView: View {
         selectedTaskID = task.id
     }
 
+
     private func handleBackgroundTap() {
         // Check if a task is focused (not just scrollView)
         let isTaskFocused = if case .task = focusedField { true } else { false }
@@ -510,6 +633,9 @@ struct TaskListView: View {
     private func startDrag(taskID: UUID) {
         draggedTaskID = taskID
         visualOrder = activeTasks.map(\.id)
+        #if os(iOS)
+        iosDragState = IOSDragState(taskID: taskID, fingerPosition: .zero)
+        #endif
     }
 
     private func updateVisualOrder(insertBefore targetID: UUID) {
@@ -600,4 +726,72 @@ struct TaskListView: View {
 
         return true
     }
+
+    // MARK: - iOS Drag Helpers
+
+    #if os(iOS)
+    private func dropIndexForY(_ y: CGFloat) -> Int {
+        let midpoints = displayActiveTasks.enumerated().compactMap { i, task -> (Int, CGFloat)? in
+            guard let frame = rowFrames[task.id] else { return nil }
+            return (i, frame.midY)
+        }
+        return midpoints.first(where: { $0.1 > y })?.0 ?? displayActiveTasks.count
+    }
+
+    private func updateVisualOrderToIndex(_ index: Int) {
+        guard let draggedID = draggedTaskID, let order = visualOrder else { return }
+        var newOrder = order.filter { $0 != draggedID }
+        let clamped = min(max(0, index), newOrder.count)
+        newOrder.insert(draggedID, at: clamped)
+        if newOrder != visualOrder {
+            withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
+                visualOrder = newOrder
+            }
+        }
+    }
+
+    private func handleIOSDragChanged(taskID: UUID, point: CGPoint) {
+        iosDragState?.fingerPosition = point
+        let index = dropIndexForY(point.y)
+        updateVisualOrderToIndex(index)
+    }
+
+    private func commitIOSDrag() {
+        guard let state = iosDragState,
+              let order = visualOrder,
+              let finalIndex = order.firstIndex(of: state.taskID) else {
+            iosDragState = nil
+            draggedTaskID = nil
+            visualOrder = nil
+            return
+        }
+        store.moveTask(taskID: state.taskID, toIndex: finalIndex)
+        iosDragState = nil
+        draggedTaskID = nil
+        visualOrder = nil
+    }
+
+    private func accentColor(forIndex index: Int, total: Int) -> Color {
+        guard total > 1 else { return Color(hue: 0.98, saturation: 0.85, brightness: 1.0) }
+        let progress = Double(index) / Double(total - 1)
+        let top    = (h: 0.98, s: 0.85, b: 1.00)
+        let mid    = (h: 0.88, s: 0.75, b: 0.95)
+        let bottom = (h: 0.72, s: 0.65, b: 0.85)
+        if progress < 0.5 {
+            let t = progress * 2.0
+            return Color(
+                hue: top.h + (mid.h - top.h) * t,
+                saturation: top.s + (mid.s - top.s) * t,
+                brightness: top.b + (mid.b - top.b) * t
+            )
+        } else {
+            let t = (progress - 0.5) * 2.0
+            return Color(
+                hue: mid.h + (bottom.h - mid.h) * t,
+                saturation: mid.s + (bottom.s - mid.s) * t,
+                brightness: mid.b + (bottom.b - mid.b) * t
+            )
+        }
+    }
+    #endif
 }

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

@@ -27,6 +27,7 @@ struct ClickableTextField: NSViewRepresentable {
         textField.drawsBackground = false
         textField.focusRingType = .none
         textField.font = .systemFont(ofSize: NSFont.systemFontSize)
+        textField.placeholderString = "Enter task"
         textField.lineBreakMode = .byWordWrapping
         textField.maximumNumberOfLines = 5
         textField.usesSingleLineMode = false
@@ -97,7 +98,7 @@ struct ClickableTextField: NSViewRepresentable {
     // Calculate text width
     private func calculateWidth(for text: String, font: NSFont) -> CGFloat {
         let attributedString = NSAttributedString(
-            string: text.isEmpty ? "New task" : text,
+            string: text.isEmpty ? "Enter task" : text,
             attributes: [.font: font]
         )
         let size = attributedString.size()
@@ -107,7 +108,7 @@ struct ClickableTextField: NSViewRepresentable {
     // Calculate text height with wrapping
     private func calculateHeight(for text: String, width: CGFloat, font: NSFont) -> CGFloat {
         let attributedString = NSAttributedString(
-            string: text.isEmpty ? "New task" : text,
+            string: text.isEmpty ? "Enter task" : text,
             attributes: [.font: font]
         )
         let textStorage = NSTextStorage(attributedString: attributedString)
@@ -140,17 +141,18 @@ struct ClickableTextField: NSViewRepresentable {
 
         @MainActor
         func applyStyle(to textField: NSTextField, text: String, isCompleted: Bool) {
-            let displayText = text.isEmpty ? "New task" : text
+            guard !text.isEmpty else {
+                textField.stringValue = ""
+                return
+            }
             let attributes: [NSAttributedString.Key: Any] = [
                 .font: NSFont.systemFont(ofSize: NSFont.systemFontSize),
-                .foregroundColor: text.isEmpty
-                    ? NSColor.secondaryLabelColor
-                    : (isCompleted ? NSColor.secondaryLabelColor : NSColor.labelColor),
+                .foregroundColor: isCompleted ? NSColor.secondaryLabelColor : NSColor.labelColor,
                 .strikethroughStyle: isCompleted ? NSUnderlineStyle.single.rawValue : 0,
                 .strikethroughColor: NSColor.secondaryLabelColor,
             ]
             textField.attributedStringValue = NSAttributedString(
-                string: displayText, attributes: attributes)
+                string: text, attributes: attributes)
         }
 
         func handleBecomeFirstResponder() {

diff --git a/ListlessMac/Views/TaskListView+NavigationHeader.swift b/ListlessMac/Views/TaskListView+NavigationHeader.swift

@@ -0,0 +1,7 @@
+import SwiftUI
+
+extension TaskListView {
+    var navigationHeader: some View {
+        EmptyView()
+    }
+}

diff --git a/ListlessMac/Views/TaskListView+PullToCreate.swift b/ListlessMac/Views/TaskListView+PullToCreate.swift

@@ -0,0 +1,7 @@
+import SwiftUI
+
+extension TaskListView {
+    var pullToCreateIndicatorRow: some View {
+        EmptyView()
+    }
+}

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

@@ -5,7 +5,9 @@ extension View {
     func taskDragGesture(
         isActive: Bool,
         taskID: UUID,
-        onDragStart: @escaping () -> Void
+        onDragStart: @escaping () -> Void,
+        onDragChanged: @escaping (CGPoint) -> Void = { _ in },
+        onDragEnded: @escaping () -> Void = { }
     ) -> some View {
         self.modifier(
             TaskRowDragGesture(

diff --git a/ListlessiOS/ListlessiOSApp.swift b/ListlessiOS/ListlessiOSApp.swift

@@ -6,15 +6,17 @@ struct ListlessiOSApp: App {
 
     var body: some Scene {
         WindowGroup {
-            NavigationStack {
-                TaskListView(store: TaskStore(persistenceController: persistenceController))
-                    .navigationTitle("Listless")
-                    .navigationBarTitleDisplayMode(.large)
-                    .safeAreaInset(edge: .top) {
-                        Color.clear.frame(height: 8)
-                    }
-                    .environment(\.managedObjectContext, persistenceController.viewContext)
-            }
+            TaskListView(store: TaskStore(persistenceController: persistenceController))
+                .safeAreaInset(edge: .top) {
+                    Color.clear.frame(height: 8)
+                }
+                .environment(\.managedObjectContext, persistenceController.viewContext)
+                .overlay(alignment: .top) {
+                    Color.outerBackground
+                        .opacity(0.9)
+                        .ignoresSafeArea(edges: .top)
+                        .frame(height: 0)
+                }
         }
     }
 }

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

@@ -0,0 +1,49 @@
+import SwiftUI
+
+/// Pull distance at which the indicator signals readiness and task creation triggers.
+let pullCreateThreshold: CGFloat = 70
+
+struct PullToCreateIndicator: View {
+    let pullOffset: CGFloat
+
+    private var progress: CGFloat { min(1, pullOffset / pullCreateThreshold) }
+    private var isReady: Bool { pullOffset >= pullCreateThreshold }
+
+    // Matches the "top" gradient stop used for the first active task row
+    private let accentColor = Color(hue: 0.98, saturation: 0.85, brightness: 1.0)
+
+    var body: some View {
+        HStack(spacing: 0) {
+            Rectangle()
+                .fill(accentColor)
+                .frame(width: 8)
+            HStack(spacing: 6) {
+                Image(systemName: isReady ? "checkmark" : "plus")
+                    .foregroundStyle(.secondary)
+                    .fontWeight(.semibold)
+                    .animation(.easeInOut(duration: 0.15), value: isReady)
+                Text(isReady ? "Release to add" : "New task")
+                    .foregroundStyle(.secondary)
+                    .font(.body)
+                    .animation(.easeInOut(duration: 0.15), value: isReady)
+                Spacer()
+            }
+            .padding(.horizontal, 16)
+            .frame(maxHeight: .infinity)
+            .background(Color.taskCard)
+        }
+        .frame(height: 56)
+        .clipShape(
+            UnevenRoundedRectangle(
+                topLeadingRadius: 0, bottomLeadingRadius: 0,
+                bottomTrailingRadius: 14, topTrailingRadius: 14
+            )
+        )
+        .padding(.trailing, 16)
+        // Reveal from the top downward as the user pulls
+        .frame(height: min(pullOffset, 56), alignment: .top)
+        .clipped()
+        .opacity(Double(progress))
+        .allowsHitTesting(false)
+    }
+}

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

@@ -1,66 +1,74 @@
 import SwiftUI
 import UIKit
 
-/// UITextField that's always present, manages its own editing state.
-/// Mirrors the interface of ClickableTextField (macOS) so TaskListView
-/// can drive both platforms through the same focusedField binding.
+/// UITextView that's always present, manages its own editing state, and expands
+/// vertically to fit its content. Mirrors the interface of ClickableTextField (macOS)
+/// so TaskListView can drive both platforms through the same focusedField binding.
 struct TappableTextField: UIViewRepresentable {
     @Binding var text: String
     let isCompleted: Bool
     let onEditingChanged: (Bool, _ shouldCreateNewTask: Bool) -> Void
 
-    func makeUIView(context: Context) -> UITextField {
-        let textField = UITextField()
-        textField.delegate = context.coordinator
-        textField.borderStyle = .none
-        textField.font = .systemFont(ofSize: 18)
-        textField.returnKeyType = .done
-        textField.autocorrectionType = .default
-        textField.autocapitalizationType = .sentences
-        textField.attributedPlaceholder = NSAttributedString(
-            string: "Task",
-            attributes: [
-                .foregroundColor: UIColor.placeholderText,
-                .font: UIFont.systemFont(ofSize: 18),
-            ]
-        )
-        textField.addTarget(
-            context.coordinator,
-            action: #selector(Coordinator.textChanged(_:)),
-            for: .editingChanged
-        )
-        return textField
+    func makeUIView(context: Context) -> UITextView {
+        let textView = UITextView()
+        textView.delegate = context.coordinator
+        textView.font = .systemFont(ofSize: 18)
+        textView.backgroundColor = .clear
+        textView.textContainerInset = .zero
+        textView.textContainer.lineFragmentPadding = 0
+        textView.isScrollEnabled = false
+        textView.autocorrectionType = .default
+        textView.autocapitalizationType = .sentences
+        textView.returnKeyType = .done
+
+        let placeholder = UILabel()
+        placeholder.text = "Enter task"
+        placeholder.font = .systemFont(ofSize: 18)
+        placeholder.textColor = .placeholderText
+        placeholder.tag = 100
+        placeholder.translatesAutoresizingMaskIntoConstraints = false
+        textView.addSubview(placeholder)
+        NSLayoutConstraint.activate([
+            placeholder.leadingAnchor.constraint(equalTo: textView.leadingAnchor),
+            placeholder.topAnchor.constraint(equalTo: textView.topAnchor),
+        ])
+
+        return textView
     }
 
-    func updateUIView(_ textField: UITextField, context: Context) {
-        // Only update content when NOT editing to avoid interfering with active input
-        if !textField.isFirstResponder {
-            applyStyle(to: textField, text: text, isCompleted: isCompleted)
+    func updateUIView(_ textView: UITextView, context: Context) {
+        if !textView.isFirstResponder {
+            applyStyle(to: textView, text: text, isCompleted: isCompleted)
+        }
+        textView.isEditable = !isCompleted
+        textView.isSelectable = !isCompleted
+        if let placeholder = textView.viewWithTag(100) as? UILabel {
+            placeholder.isHidden = !text.isEmpty
         }
-        textField.isEnabled = !isCompleted
+    }
+
+    func sizeThatFits(_ proposal: ProposedViewSize, uiView: UITextView, context: Context) -> CGSize? {
+        let width = proposal.width ?? UIScreen.main.bounds.width
+        return uiView.sizeThatFits(CGSize(width: width, height: .infinity))
     }
 
     func makeCoordinator() -> Coordinator {
         Coordinator(text: $text, onEditingChanged: onEditingChanged)
     }
 
-    private func applyStyle(to textField: UITextField, text: String, isCompleted: Bool) {
-        if text.isEmpty {
-            textField.attributedText = NSAttributedString(string: "")
-        } else {
-            var attributes: [NSAttributedString.Key: Any] = [
-                .font: UIFont.systemFont(ofSize: 18),
-                .foregroundColor: isCompleted ? UIColor.secondaryLabel : UIColor.label,
-            ]
-            if isCompleted {
-                attributes[.strikethroughStyle] = NSUnderlineStyle.single.rawValue
-                attributes[.strikethroughColor] = UIColor.secondaryLabel
-            }
-            textField.attributedText = NSAttributedString(string: text, attributes: attributes)
+    private func applyStyle(to textView: UITextView, text: String, isCompleted: Bool) {
+        var attributes: [NSAttributedString.Key: Any] = [
+            .font: UIFont.systemFont(ofSize: 18),
+            .foregroundColor: isCompleted ? UIColor.secondaryLabel : UIColor.label,
+        ]
+        if isCompleted {
+            attributes[.strikethroughStyle] = NSUnderlineStyle.single.rawValue
+            attributes[.strikethroughColor] = UIColor.secondaryLabel
         }
+        textView.attributedText = NSAttributedString(string: text, attributes: attributes)
     }
 
-    final class Coordinator: NSObject, UITextFieldDelegate {
+    final class Coordinator: NSObject, UITextViewDelegate {
         @Binding var text: String
         let onEditingChanged: (Bool, _ shouldCreateNewTask: Bool) -> Void
         var returnKeyPressed: Bool = false
@@ -73,29 +81,37 @@ struct TappableTextField: UIViewRepresentable {
             self.onEditingChanged = onEditingChanged
         }
 
-        @objc func textChanged(_ textField: UITextField) {
-            text = textField.text ?? ""
+        func textViewDidChange(_ textView: UITextView) {
+            text = textView.text
+            if let placeholder = textView.viewWithTag(100) as? UILabel {
+                placeholder.isHidden = !textView.text.isEmpty
+            }
         }
 
-        func textFieldDidBeginEditing(_ textField: UITextField) {
+        func textViewDidBeginEditing(_ textView: UITextView) {
             onEditingChanged(true, false)
         }
 
-        func textFieldDidEndEditing(_ textField: UITextField) {
+        func textViewDidEndEditing(_ textView: UITextView) {
             if returnKeyPressed {
-                // onEditingChanged(false, true) already fired in textFieldShouldReturn;
-                // skip the duplicate call here.
                 returnKeyPressed = false
                 return
             }
             onEditingChanged(false, false)
         }
 
-        func textFieldShouldReturn(_ textField: UITextField) -> Bool {
+        func textView(
+            _ textView: UITextView,
+            shouldChangeTextIn range: NSRange,
+            replacementText text: String
+        ) -> Bool {
+            guard text == "\n" else { return true }
+            // Intercept Return: trigger new-task creation without inserting a newline.
+            // Return false keeps the text view as first responder, matching the UITextField
+            // behaviour where textFieldShouldReturn returned false — SwiftUI then
+            // transfers first responder atomically in the same render pass.
             returnKeyPressed = true
             onEditingChanged(false, true)
-            // Return false: UIKit does NOT auto-resign first responder, so the
-            // keyboard stays visible while SwiftUI focuses the next field.
             return false
         }
     }

diff --git a/ListlessiOS/Views/TaskListView+NavigationHeader.swift b/ListlessiOS/Views/TaskListView+NavigationHeader.swift

@@ -0,0 +1,16 @@
+import SwiftUI
+
+extension TaskListView {
+    var navigationHeader: some View {
+        Text("Tasks")
+            .font(.largeTitle)
+            .fontWeight(.bold)
+            .frame(maxWidth: .infinity, alignment: .leading)
+            .padding(.horizontal, 16)
+            .padding(.bottom, 8)
+            .onTapGesture {
+                selectedTaskID = nil
+                focusedField = .scrollView
+            }
+    }
+}

diff --git a/ListlessiOS/Views/TaskListView+PullToCreate.swift b/ListlessiOS/Views/TaskListView+PullToCreate.swift

@@ -0,0 +1,9 @@
+import SwiftUI
+
+extension TaskListView {
+    @ViewBuilder var pullToCreateIndicatorRow: some View {
+        if pullOffset > 0 {
+            PullToCreateIndicator(pullOffset: pullOffset)
+        }
+    }
+}

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

@@ -1,17 +1,21 @@
 import SwiftUI
-import UniformTypeIdentifiers
+import UIKit
 
 extension View {
     func taskDragGesture(
         isActive: Bool,
         taskID: UUID,
-        onDragStart: @escaping () -> Void
+        onDragStart: @escaping () -> Void,
+        onDragChanged: @escaping (CGPoint) -> Void,
+        onDragEnded: @escaping () -> Void
     ) -> some View {
         self.modifier(
             TaskRowDragGesture(
                 isActive: isActive,
                 taskID: taskID,
-                onDragStart: onDragStart
+                onDragStart: onDragStart,
+                onDragChanged: onDragChanged,
+                onDragEnded: onDragEnded
             ))
     }
 }
@@ -20,18 +24,85 @@ struct TaskRowDragGesture: ViewModifier {
     let isActive: Bool
     let taskID: UUID
     let onDragStart: () -> Void
+    let onDragChanged: (CGPoint) -> Void
+    let onDragEnded: () -> Void
 
     func body(content: Content) -> some View {
-        if isActive {
-            content
-                .onDrag {
-                    onDragStart()
-                    return NSItemProvider(object: taskID.uuidString as NSString)
-                } preview: {
-                    Color.clear.frame(width: 1, height: 1)
-                }
-        } else {
-            content
+        content
+            .gesture(
+                LongPressDragGesture(
+                    isActive: isActive,
+                    onDragStart: onDragStart,
+                    onDragChanged: onDragChanged,
+                    onDragEnded: onDragEnded
+                )
+            )
+    }
+}
+
+// MARK: - UIKit Long Press + Drag via UIGestureRecognizerRepresentable
+
+/// A UILongPressGestureRecognizer bridged into SwiftUI. Fires onDragStart after the
+/// minimum hold duration, then tracks finger movement via onDragChanged (window coords),
+/// and calls onDragEnded when the touch ends or is cancelled.
+private struct LongPressDragGesture: UIGestureRecognizerRepresentable {
+    let isActive: Bool
+    let onDragStart: () -> Void
+    let onDragChanged: (CGPoint) -> Void
+    let onDragEnded: () -> Void
+
+    func makeUIGestureRecognizer(context: Context) -> UILongPressGestureRecognizer {
+        let recognizer = UILongPressGestureRecognizer()
+        recognizer.minimumPressDuration = 0.4
+        recognizer.allowableMovement = .infinity
+        recognizer.cancelsTouchesInView = false
+        recognizer.delegate = context.coordinator
+        return recognizer
+    }
+
+    func updateUIGestureRecognizer(_ recognizer: UILongPressGestureRecognizer, context: Context) {
+        recognizer.isEnabled = isActive
+    }
+
+    func handleUIGestureRecognizerAction(
+        _ recognizer: UILongPressGestureRecognizer, context: Context
+    ) {
+        switch recognizer.state {
+        case .began:
+            onDragStart()
+            if let window = recognizer.view?.window {
+                onDragChanged(recognizer.location(in: window))
+            }
+        case .changed:
+            if let window = recognizer.view?.window {
+                onDragChanged(recognizer.location(in: window))
+            }
+        case .ended, .cancelled, .failed:
+            onDragEnded()
+        default:
+            break
+        }
+    }
+
+    func makeCoordinator(converter: CoordinateSpaceConverter) -> Coordinator {
+        Coordinator()
+    }
+
+    final class Coordinator: NSObject, UIGestureRecognizerDelegate {
+        func gestureRecognizer(
+            _ gestureRecognizer: UIGestureRecognizer,
+            shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer
+        ) -> Bool {
+            // Allow simultaneous recognition with the scroll view's pan gesture
+            if let pan = otherGestureRecognizer as? UIPanGestureRecognizer,
+               pan.view is UIScrollView {
+                return true
+            }
+            // Prevent simultaneous recognition with the swipe UIPanGestureRecognizer
+            if otherGestureRecognizer is UIPanGestureRecognizer {
+                return false
+            }
+            return true
         }
     }
 }