← All repositories

listless

A simple list app for Apple platforms

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


commit 18193a4d9d55ad01ce3370420051a5efe5ef7c22
parent f5b4c349770f2a18b708dc02f2f73bbf2344fdef
Author: Michael Camilleri <[email protected]>
Date:   Wed, 18 Feb 2026 17:06:24 +0900

Extract pull-to-create state logic to struct

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

Diffstat

MListless.xcodeproj/project.pbxproj | 4++++
MListlessiOS/Extensions/TaskListView+PullToCreate.swift | 4++--
AListlessiOS/Helpers/PullToCreateState.swift | 56++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MListlessiOS/Views/TaskListView.swift | 39+++++++++++++++++----------------------

4 files changed, 79 insertions(+), 24 deletions(-)

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

@@ -40,6 +40,7 @@
 		BA6953E0EFE6F8255F05A3FD /* AccentColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17DD7EDA74DAAFA27C84CA08 /* AccentColor.swift */; };
 		C169823665158AA347A63990 /* TaskListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E51B8129962E5CC78ECDDC2B /* TaskListView.swift */; };
 		C1FE091454864C4BBBBEB077 /* TaskItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBB8A3BEB346267B30B4675F /* TaskItem.swift */; };
+		CAC36F0ED35740AB74A2BB15 /* PullToCreateState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D48D898398DB472D4321B91 /* PullToCreateState.swift */; };
 		CAD142ED738A83371DFF8F5B /* TaskListView+Logic.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72B4668483D05A6ECA142B89 /* TaskListView+Logic.swift */; };
 		DB5FF6C1AA57D4C9BDDD50FD /* ClickableTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = D640E7D21735C62A30A26DA4 /* ClickableTextField.swift */; };
 		DC73A39A269AB495BCE1AC48 /* PersistenceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C14858BDFD1FD5119F1F24A6 /* PersistenceController.swift */; };
@@ -89,6 +90,7 @@
 		74255E6B6C40899E9B17D927 /* .gitkeep */ = {isa = PBXFileReference; path = .gitkeep; sourceTree = "<group>"; };
 		75B048B19C5219862BBED2E7 /* TestHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestHelpers.swift; sourceTree = "<group>"; };
 		7C73E9D4C42CCABBF0F33543 /* Listless.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Listless.entitlements; sourceTree = "<group>"; };
+		8D48D898398DB472D4321B91 /* PullToCreateState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PullToCreateState.swift; sourceTree = "<group>"; };
 		9262207DAC21619BD9EDEE15 /* Listless.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Listless.entitlements; sourceTree = "<group>"; };
 		944BAE054AAC1B9C4FC954F9 /* .gitkeep */ = {isa = PBXFileReference; path = .gitkeep; sourceTree = "<group>"; };
 		967F7ECEB3915CEDCE584872 /* TaskStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskStoreTests.swift; sourceTree = "<group>"; };
@@ -125,6 +127,7 @@
 				B7588879D0FA1C2A8BCEF14F /* HoverCursorModifier.swift */,
 				FA5D58EC1FBAA96E83A79445 /* PlatformScrollIndicatorsModifier.swift */,
 				E2174C43654733E9D4023157 /* PlatformTextFieldWidthModifier.swift */,
+				8D48D898398DB472D4321B91 /* PullToCreateState.swift */,
 				6ECE0E961F87BA32FA87BF90 /* TappableTextField.swift */,
 				D57C3CC81C4380EFAE4DB910 /* TaskRowDragGesture.swift */,
 				067D666DF0AF1C53404ECF7C /* TaskRowSwipeGesture.swift */,
@@ -450,6 +453,7 @@
 				77FE96F070B1F7FE31A9CE51 /* PlatformTextFieldWidthModifier.swift in Sources */,
 				785721EB774EAC6BBA26C038 /* PullToClear.swift in Sources */,
 				E47136CA7428927395D8C7C7 /* PullToCreate.swift in Sources */,
+				CAC36F0ED35740AB74A2BB15 /* PullToCreateState.swift in Sources */,
 				0F12D56A528FCBF8A67864CB /* TappableTextField.swift in Sources */,
 				0ACA67F6578EFF181EE5C9A7 /* TaskItem.swift in Sources */,
 				82E75475E13FD847DDDC69D8 /* TaskListView+Drag.swift in Sources */,

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

@@ -2,9 +2,9 @@ import SwiftUI
 
 extension TaskListView {
     @ViewBuilder var pullToCreateIndicatorRow: some View {
-        if createIndicatorOffset > 0 || isCreateInsertionPending {
+        if pullToCreate.shouldShowIndicator {
             PullToCreateIndicator(
-                pullOffset: isCreateInsertionPending ? pullCreateThreshold : createIndicatorOffset
+                pullOffset: pullToCreate.indicatorDisplayOffset(threshold: pullCreateThreshold)
             )
         }
     }

diff --git a/ListlessiOS/Helpers/PullToCreateState.swift b/ListlessiOS/Helpers/PullToCreateState.swift

@@ -0,0 +1,56 @@
+import SwiftUI
+
+struct PullToCreateState {
+    enum Action {
+        case none
+        case createTask
+        case collapseIndicator
+    }
+
+    var pullOffset: CGFloat = 0
+    var indicatorOffset: CGFloat = 0
+    var isInsertionPending: Bool = false
+    var activeTaskCountBeforeCreate: Int = 0
+    var isScrollInteracting: Bool = false
+
+    var shouldShowIndicator: Bool {
+        indicatorOffset > 0 || isInsertionPending
+    }
+
+    func indicatorDisplayOffset(threshold: CGFloat) -> CGFloat {
+        isInsertionPending ? threshold : indicatorOffset
+    }
+
+    mutating func updatePullDistance(_ distance: CGFloat) {
+        pullOffset = distance
+        if isScrollInteracting {
+            indicatorOffset = distance
+        }
+    }
+
+    mutating func handlePhaseChange(
+        from oldPhase: ScrollPhase,
+        to newPhase: ScrollPhase,
+        activeTaskCount: Int,
+        threshold: CGFloat
+    ) -> Action {
+        isScrollInteracting = (newPhase == .interacting)
+        guard oldPhase == .interacting, newPhase != .interacting else { return .none }
+
+        if pullOffset >= threshold {
+            activeTaskCountBeforeCreate = activeTaskCount
+            isInsertionPending = true
+            return .createTask
+        }
+
+        isInsertionPending = false
+        return .collapseIndicator
+    }
+
+    mutating func resolvePendingInsertion(activeTaskCount: Int) -> Bool {
+        guard isInsertionPending, activeTaskCount > activeTaskCountBeforeCreate else { return false }
+        isInsertionPending = false
+        indicatorOffset = 0
+        return true
+    }
+}

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

@@ -24,13 +24,9 @@ struct TaskListView: View {
     @State var draggedTaskID: UUID?
     @State var visualOrder: [UUID]?
     @State var pendingFocus: FocusField?
-    @State var pullOffset: CGFloat = 0
-    @State var createIndicatorOffset: CGFloat = 0
-    @State var isCreateInsertionPending: Bool = false
-    @State var activeTaskCountBeforeCreate: Int = 0
+    @State var pullToCreate = PullToCreateState()
     @State var pullUpOffset: CGFloat = 0
     @State var isDragging: Bool = false
-    @State var isScrollInteracting: Bool = false
     @State var rowFrames: [UUID: CGRect] = [:]
 
     var vStackSpacing: CGFloat { 12 }
@@ -148,7 +144,7 @@ struct TaskListView: View {
             .frame(maxWidth: .infinity, alignment: .topLeading)
             .padding(.trailing, 16)
             .padding(.vertical, 12)
-            .offset(y: -pullOffset)
+            .offset(y: -pullToCreate.pullOffset)
         }
         .scrollDisabled(draggedTaskID != nil)
         .scrollBounceBehavior(.always)
@@ -161,10 +157,7 @@ struct TaskListView: View {
         .onScrollGeometryChange(for: CGFloat.self) { geo in
             max(0, -(geo.contentOffset.y + geo.contentInsets.top))
         } action: { _, pullDistance in
-            pullOffset = pullDistance
-            if isScrollInteracting {
-                createIndicatorOffset = pullDistance
-            }
+            pullToCreate.updatePullDistance(pullDistance)
         }
         .onScrollGeometryChange(for: CGFloat.self) { geo in
             let maxOffset = max(
@@ -176,38 +169,40 @@ struct TaskListView: View {
             pullUpOffset = pullDistance
         }
         .onScrollPhaseChange { oldPhase, newPhase in
-            isScrollInteracting = (newPhase == .interacting)
+            let action = pullToCreate.handlePhaseChange(
+                from: oldPhase,
+                to: newPhase,
+                activeTaskCount: activeTasks.count,
+                threshold: pullCreateThreshold
+            )
 
             if oldPhase == .interacting && newPhase != .interacting {
-                if pullOffset >= pullCreateThreshold {
-                    activeTaskCountBeforeCreate = activeTasks.count
-                    isCreateInsertionPending = true
+                switch action {
+                case .createTask:
                     var transaction = Transaction(animation: .spring(response: 0.28, dampingFraction: 0.9))
                     transaction.disablesAnimations = false
                     withTransaction(transaction) {
                         createNewTaskAtTop()
                     }
-                } else {
-                    isCreateInsertionPending = false
+                case .collapseIndicator:
                     withAnimation(.spring(response: 0.22, dampingFraction: 0.95)) {
-                        createIndicatorOffset = 0
+                        pullToCreate.indicatorOffset = 0
                     }
+                case .none:
+                    break
                 }
                 if pullUpOffset >= pullClearThreshold && !completedTasks.isEmpty { clearCompletedTasks() }
                 pullUpOffset = 0
             }
         }
         .onChange(of: activeTasks.count) { _, newCount in
-            guard isCreateInsertionPending else { return }
-            guard newCount > activeTaskCountBeforeCreate else { return }
             var transaction = Transaction(animation: nil)
             transaction.disablesAnimations = true
             withTransaction(transaction) {
-                isCreateInsertionPending = false
-                createIndicatorOffset = 0
+                _ = pullToCreate.resolvePendingInsertion(activeTaskCount: newCount)
             }
         }
-        .sensoryFeedback(.impact(weight: .medium), trigger: pullOffset >= pullCreateThreshold) { old, new in
+        .sensoryFeedback(.impact(weight: .medium), trigger: pullToCreate.pullOffset >= pullCreateThreshold) { old, new in
             !old && new
         }
         .sensoryFeedback(.impact(weight: .medium), trigger: pullUpOffset >= pullClearThreshold) { old, new in