← All repositories

listless

A simple list app for Apple platforms

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


commit f142af13f39857376af597f172e82b800be5cb41
parent 429307378949e60f1bd010a6cd83e1cdc81761d2
Author: Michael Camilleri <[email protected]>
Date:   Tue, 24 Feb 2026 05:16:56 +0900

Group state by concern in iOS version

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

Diffstat

MListlessiOS/Views/TaskListView.swift | 126++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------
MListlessiOS/Views/TaskRowView.swift | 10+++++++---

2 files changed, 113 insertions(+), 23 deletions(-)

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

@@ -11,6 +11,24 @@ struct TaskListView: View {
         case dragging(id: UUID, order: [UUID])
     }
 
+    struct FocusStateData {
+        var focusedField: FocusField?
+        var selectedTaskID: UUID?
+        var pendingFocus: FocusField?
+    }
+
+    struct InteractionStateData {
+        var dragState: DragState = .idle
+        var pullToCreate = PullToCreateState()
+        var pullUpOffset: CGFloat = 0
+        var isDragging: Bool = false
+        var rowFrames: [UUID: CGRect] = [:]
+    }
+
+    struct TaskStateData {
+        var refreshID = UUID()
+    }
+
     @Environment(\.undoManager) var undoManager
     @Environment(\.managedObjectContext) var managedObjectContext
 
@@ -24,15 +42,79 @@ struct TaskListView: View {
         animation: .default
     )
     var tasks: FetchedResults<TaskItem>
-    @FocusState var focusedField: FocusField?
-    @State var selectedTaskID: UUID?
-    @State private var refreshID = UUID()
-    @State var dragState: DragState = .idle
-    @State var pendingFocus: FocusField?
-    @State var pullToCreate = PullToCreateState()
-    @State var pullUpOffset: CGFloat = 0
-    @State var isDragging: Bool = false
-    @State var rowFrames: [UUID: CGRect] = [:]
+    @FocusState private var focusedFieldBinding: FocusField?
+    @State private var fState = FocusStateData()
+    @State private var iState = InteractionStateData()
+    @State private var tState = TaskStateData()
+
+    var focusedField: FocusField? {
+        get { fState.focusedField }
+        nonmutating set {
+            fState.focusedField = newValue
+            focusedFieldBinding = newValue
+        }
+    }
+
+    var selectedTaskID: UUID? {
+        get { fState.selectedTaskID }
+        nonmutating set { fState.selectedTaskID = newValue }
+    }
+
+    var pendingFocus: FocusField? {
+        get { fState.pendingFocus }
+        nonmutating set { fState.pendingFocus = newValue }
+    }
+
+    var dragState: DragState {
+        get { iState.dragState }
+        nonmutating set { iState.dragState = newValue }
+    }
+
+    var pullToCreate: PullToCreateState {
+        get { iState.pullToCreate }
+        nonmutating set { iState.pullToCreate = newValue }
+    }
+
+    var pullUpOffset: CGFloat {
+        get { iState.pullUpOffset }
+        nonmutating set { iState.pullUpOffset = newValue }
+    }
+
+    var isDragging: Bool {
+        get { iState.isDragging }
+        nonmutating set { iState.isDragging = newValue }
+    }
+
+    var rowFrames: [UUID: CGRect] {
+        get { iState.rowFrames }
+        nonmutating set { iState.rowFrames = newValue }
+    }
+
+    var refreshID: UUID {
+        get { tState.refreshID }
+        nonmutating set { tState.refreshID = newValue }
+    }
+
+    private var isDraggingStateBinding: Binding<Bool> {
+        Binding(
+            get: { iState.isDragging },
+            set: { iState.isDragging = $0 }
+        )
+    }
+
+    private var pullToCreateStateBinding: Binding<PullToCreateState> {
+        Binding(
+            get: { iState.pullToCreate },
+            set: { iState.pullToCreate = $0 }
+        )
+    }
+
+    private var pullUpOffsetStateBinding: Binding<CGFloat> {
+        Binding(
+            get: { iState.pullUpOffset },
+            set: { iState.pullUpOffset = $0 }
+        )
+    }
 
     var vStackSpacing: CGFloat { 12 }
     var pullCreateThreshold: CGFloat { 70 }
@@ -56,7 +138,7 @@ struct TaskListView: View {
                 handleBackgroundTap()
             }
             .focusable()
-            .focused($focusedField, equals: .scrollView)
+            .focused($focusedFieldBinding, equals: .scrollView)
             .focusEffectDisabled()
             .accessibilityIdentifier("task-list-scrollview")
             .keyboardNavigation([
@@ -67,21 +149,25 @@ struct TaskListView: View {
                 ShortcutKey(key: .delete): deleteSelectedTask,
             ])
             .onAppear {
-                if focusedField == nil {
-                    focusedField = .scrollView
+                if focusedFieldBinding == nil {
+                    focusedFieldBinding = .scrollView
                 }
+                fState.focusedField = focusedFieldBinding
             }
-            .onChange(of: focusedField) { oldValue, newValue in
+            .onChange(of: focusedFieldBinding) { oldValue, newValue in
+                fState.focusedField = newValue
                 handleFocusChange(from: oldValue, to: newValue)
 
                 if newValue == nil {
                     if let pending = pendingFocus {
                         print("🟣 onChange resolving pendingFocus: \(pending)")
-                        focusedField = pending
+                        focusedFieldBinding = pending
+                        fState.focusedField = pending
                         pendingFocus = nil
                     } else {
                         print("🟣 onChange repairing nil focus to .scrollView")
-                        focusedField = .scrollView
+                        focusedFieldBinding = .scrollView
+                        fState.focusedField = .scrollView
                     }
                 }
             }
@@ -136,8 +222,8 @@ struct TaskListView: View {
                         index: index,
                         totalTasks: displayActiveTasks.count,
                         isSelected: selectedTaskID == taskID,
-                        isDragging: $isDragging,
-                        focusedField: $focusedField,
+                        isDragging: isDraggingStateBinding,
+                        focusedField: $focusedFieldBinding,
                         onToggle: { toggleCompletion($0) },
                         onTitleChange: { updateTitle($0, $1) },
                         onDelete: { deleteTask($0) },
@@ -171,7 +257,7 @@ struct TaskListView: View {
                         task: task,
                         taskID: taskID,
                         isSelected: selectedTaskID == taskID,
-                        focusedField: $focusedField,
+                        focusedField: $focusedFieldBinding,
                         onToggle: { toggleCompletion($0) },
                         onTitleChange: { updateTitle($0, $1) },
                         onDelete: { deleteTask($0) },
@@ -202,8 +288,8 @@ struct TaskListView: View {
             pullToClearIndicatorRow
         }
         .pullCreationGesture(
-            pullToCreate: $pullToCreate,
-            pullUpOffset: $pullUpOffset,
+            pullToCreate: pullToCreateStateBinding,
+            pullUpOffset: pullUpOffsetStateBinding,
             activeTaskIDs: activeTasks.map(\.id),
             hasCompletedTasks: !completedTasks.isEmpty,
             pullCreateThreshold: pullCreateThreshold,

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

@@ -72,9 +72,13 @@ struct TaskRowView: View {
                 text: $editingTitle,
                 isCompleted: task.isCompleted,
                 onEditingChanged: { editing, shouldCreateNewTask in
-                    isCurrentlyEditing = editing
-                    if editing { onStartEdit(taskID) }
-                    else { onEndEdit(taskID, shouldCreateNewTask) }
+                    // TappableTextField is UIKit-backed; defer state mutations to avoid
+                    // "Modifying state during view update" warnings from SwiftUI.
+                    DispatchQueue.main.async {
+                        isCurrentlyEditing = editing
+                        if editing { onStartEdit(taskID) }
+                        else { onEndEdit(taskID, shouldCreateNewTask) }
+                    }
                 }
             )
             .focused($focusedField, equals: .task(taskID))