commit 6b47c9892ef2fbd01bb52c7208251a1b7558b3e8
parent 6f5fb8048948fbf4a485decac7561b64ef68ea07
Author: Michael Camilleri <[email protected]>
Date: Wed, 18 Feb 2026 04:15:28 +0900
Commit to separation of platform-specific views
Co-Authored-By: Claude 4.6 Opus <[email protected]>
Diffstat:
9 files changed, 802 insertions(+), 761 deletions(-)
diff --git a/Listless.xcodeproj/project.pbxproj b/Listless.xcodeproj/project.pbxproj
@@ -12,14 +12,12 @@
172F2DD978988E207610055F /* KeyboardNavigationModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4669414A460FC0758D5B49A8 /* KeyboardNavigationModifier.swift */; };
182D9FB61E3A3650D7D83D2A /* TaskRowSwipeGesture.swift in Sources */ = {isa = PBXBuildFile; fileRef = 067D666DF0AF1C53404ECF7C /* TaskRowSwipeGesture.swift */; };
1AA328A921EF8A7FDD03119A /* TestHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75B048B19C5219862BBED2E7 /* TestHelpers.swift */; };
- 2374527E3629DD87AE3D2761 /* TaskListView+PullToCreate.swift in Sources */ = {isa = PBXBuildFile; fileRef = A741111B4DEB3A7406A95C93 /* TaskListView+PullToCreate.swift */; };
269B93D5543770B464DFB37A /* TaskStoreOrderingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D3A2DDCE24E54ABCCFBBD4C /* TaskStoreOrderingTests.swift */; };
26F609518DE1055DF09B0159 /* TaskListView+NavigationHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 712ADAD0FE66175DAA9A6D50 /* TaskListView+NavigationHeader.swift */; };
2C53717A0EAFA39240615C9A /* TaskRowDragGesture.swift in Sources */ = {isa = PBXBuildFile; fileRef = D57C3CC81C4380EFAE4DB910 /* TaskRowDragGesture.swift */; };
322EE74CBED492693B92AD59 /* TaskListView+Toolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A440253D045A0896D94ADD0 /* TaskListView+Toolbar.swift */; };
3ABE52A15C2059D8D5570528 /* TaskStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC3DEE364304587D280C5672 /* TaskStore.swift */; };
3D1F551A03B97ECF4E3DC8B0 /* TaskRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E06485DBE35B60868E14202A /* TaskRowView.swift */; };
- 42E4CDE1D17463554CC4F41F /* TaskListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 537A913AC421BAEF60D26D9C /* TaskListView.swift */; };
5035EC4C7518A5FF9AD454CA /* TaskRowDragGesture.swift in Sources */ = {isa = PBXBuildFile; fileRef = F416DD868A4C044F0D64F8D0 /* TaskRowDragGesture.swift */; };
5761B201BF46FCA9C5C98CEF /* PlatformScrollIndicatorsModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 466F9B0E407DF1F5B4789531 /* PlatformScrollIndicatorsModifier.swift */; };
5B60B409CE4BA668DB30A65D /* Listless.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = C093494053E6C348F245D4EC /* Listless.xcdatamodeld */; };
@@ -30,25 +28,28 @@
77FE96F070B1F7FE31A9CE51 /* PlatformTextFieldWidthModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2174C43654733E9D4023157 /* PlatformTextFieldWidthModifier.swift */; };
7E366008FF9335A77FE1636D /* TaskRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 199CC2F58DD7CBA3F2229366 /* TaskRowView.swift */; };
80BCA654FE694D8A77D9BF93 /* TaskListView+PullToCreate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AD8D4C29E09FEE78AE5AB79 /* TaskListView+PullToCreate.swift */; };
+ 82E75475E13FD847DDDC69D8 /* TaskListView+Drag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FA75CA18C6270D68A755AEB /* TaskListView+Drag.swift */; };
882695E7AE463C0F39ACFF3C /* HoverCursorModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F5D8B5866362D422A2A331C /* HoverCursorModifier.swift */; };
- 8E08F4C82D6F9E67667CB20A /* TaskListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 537A913AC421BAEF60D26D9C /* TaskListView.swift */; };
91EDF52C7C5C0B35E9D8B51E /* TaskStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC3DEE364304587D280C5672 /* TaskStore.swift */; };
96617677059FABDBB80D642B /* Listless.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = C093494053E6C348F245D4EC /* Listless.xcdatamodeld */; };
99977BFA37FBAAA49AF6B71E /* TaskStoreEdgeCaseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B2BBE01D99CDA278BCB9F49 /* TaskStoreEdgeCaseTests.swift */; };
99D17075DA3F00F52A18BB4D /* AccentColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17DD7EDA74DAAFA27C84CA08 /* AccentColor.swift */; };
A0AA8FD4C542E9AEB2437BC2 /* PersistenceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C14858BDFD1FD5119F1F24A6 /* PersistenceController.swift */; };
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 */; };
- CAC6A40C680611163775CDFE /* TaskListView+NavigationHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E67FE94ED8E9B248A0C3A97 /* TaskListView+NavigationHeader.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 */; };
DEE187A790A4058FE4AFDB2E /* PlatformTextFieldWidthModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8448C5778F75F52719114AF /* PlatformTextFieldWidthModifier.swift */; };
E47136CA7428927395D8C7C7 /* PullToCreate.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFF7D84B54AE70036D205CA4 /* PullToCreate.swift */; };
+ E5878BAA0EA66A94440E2B0F /* TaskListView+Logic.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72B4668483D05A6ECA142B89 /* TaskListView+Logic.swift */; };
E60F81C7B930AACE67746759 /* HoverCursorModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7588879D0FA1C2A8BCEF14F /* HoverCursorModifier.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 */; };
F6587B84ECC6BFE92A5FB493 /* KeyboardNavigationModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4669414A460FC0758D5B49A8 /* KeyboardNavigationModifier.swift */; };
+ FDD09FECEED48EC9598538F4 /* TaskListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 632DA39B24C4CF1528A1A24D /* TaskListView.swift */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -71,15 +72,16 @@
2A440253D045A0896D94ADD0 /* TaskListView+Toolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TaskListView+Toolbar.swift"; sourceTree = "<group>"; };
2D3A2DDCE24E54ABCCFBBD4C /* TaskStoreOrderingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskStoreOrderingTests.swift; sourceTree = "<group>"; };
3313FEDB101EECA4B344EEF4 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
- 3E67FE94ED8E9B248A0C3A97 /* TaskListView+NavigationHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TaskListView+NavigationHeader.swift"; sourceTree = "<group>"; };
+ 3FA75CA18C6270D68A755AEB /* TaskListView+Drag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TaskListView+Drag.swift"; sourceTree = "<group>"; };
4669414A460FC0758D5B49A8 /* KeyboardNavigationModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardNavigationModifier.swift; sourceTree = "<group>"; };
466F9B0E407DF1F5B4789531 /* PlatformScrollIndicatorsModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlatformScrollIndicatorsModifier.swift; sourceTree = "<group>"; };
4FC64B9F9370041BEDBD1E14 /* .gitkeep */ = {isa = PBXFileReference; path = .gitkeep; sourceTree = "<group>"; };
- 537A913AC421BAEF60D26D9C /* TaskListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskListView.swift; sourceTree = "<group>"; };
5B0E22B8F7B2B7283CAF749E /* Listless macOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Listless macOS.app"; sourceTree = BUILT_PRODUCTS_DIR; };
5E88DDD119EEEECCD45F36D2 /* TaskStoreCompletionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskStoreCompletionTests.swift; sourceTree = "<group>"; };
+ 632DA39B24C4CF1528A1A24D /* TaskListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskListView.swift; sourceTree = "<group>"; };
6ECE0E961F87BA32FA87BF90 /* TappableTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TappableTextField.swift; sourceTree = "<group>"; };
712ADAD0FE66175DAA9A6D50 /* TaskListView+NavigationHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TaskListView+NavigationHeader.swift"; sourceTree = "<group>"; };
+ 72B4668483D05A6ECA142B89 /* TaskListView+Logic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TaskListView+Logic.swift"; sourceTree = "<group>"; };
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>"; };
@@ -90,7 +92,6 @@
9B2BBE01D99CDA278BCB9F49 /* TaskStoreEdgeCaseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskStoreEdgeCaseTests.swift; sourceTree = "<group>"; };
9F5D8B5866362D422A2A331C /* HoverCursorModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HoverCursorModifier.swift; sourceTree = "<group>"; };
9FBEB58DD41817F09B0EB9F0 /* Listless.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = Listless.xcdatamodel; sourceTree = "<group>"; };
- A741111B4DEB3A7406A95C93 /* TaskListView+PullToCreate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TaskListView+PullToCreate.swift"; sourceTree = "<group>"; };
AC245331D715EA85887C0BA0 /* ListlessiOSApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListlessiOSApp.swift; sourceTree = "<group>"; };
B7588879D0FA1C2A8BCEF14F /* HoverCursorModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HoverCursorModifier.swift; sourceTree = "<group>"; };
BFF7D84B54AE70036D205CA4 /* PullToCreate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PullToCreate.swift; sourceTree = "<group>"; };
@@ -99,12 +100,12 @@
C9B14DC786A336008AAB78EE /* .gitkeep */ = {isa = PBXFileReference; path = .gitkeep; sourceTree = "<group>"; };
CB43816B8E7F083A2AD07F28 /* TaskListView+Toolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TaskListView+Toolbar.swift"; sourceTree = "<group>"; };
D10B5491A53E77C80F8F75CD /* .gitkeep */ = {isa = PBXFileReference; path = .gitkeep; sourceTree = "<group>"; };
- D123BB181208FC825777B0A7 /* .gitkeep */ = {isa = PBXFileReference; path = .gitkeep; sourceTree = "<group>"; };
D57C3CC81C4380EFAE4DB910 /* TaskRowDragGesture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskRowDragGesture.swift; sourceTree = "<group>"; };
D640E7D21735C62A30A26DA4 /* ClickableTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClickableTextField.swift; sourceTree = "<group>"; };
DC3DEE364304587D280C5672 /* TaskStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskStore.swift; sourceTree = "<group>"; };
E06485DBE35B60868E14202A /* TaskRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskRowView.swift; sourceTree = "<group>"; };
E2174C43654733E9D4023157 /* PlatformTextFieldWidthModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlatformTextFieldWidthModifier.swift; sourceTree = "<group>"; };
+ E51B8129962E5CC78ECDDC2B /* TaskListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskListView.swift; sourceTree = "<group>"; };
E8448C5778F75F52719114AF /* PlatformTextFieldWidthModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlatformTextFieldWidthModifier.swift; sourceTree = "<group>"; };
F1E998119283F784B9ADEE28 /* AppColors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppColors.swift; sourceTree = "<group>"; };
F416DD868A4C044F0D64F8D0 /* TaskRowDragGesture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskRowDragGesture.swift; sourceTree = "<group>"; };
@@ -113,15 +114,6 @@
/* End PBXFileReference section */
/* Begin PBXGroup section */
- 14254B7ADE539045774E2A31 /* Views */ = {
- isa = PBXGroup;
- children = (
- D123BB181208FC825777B0A7 /* .gitkeep */,
- 537A913AC421BAEF60D26D9C /* TaskListView.swift */,
- );
- path = Views;
- sourceTree = "<group>";
- };
19A2FAA566465414CA20E6D8 /* Helpers */ = {
isa = PBXGroup;
children = (
@@ -161,11 +153,11 @@
58051CBDE2390F9E13647235 /* Listless */ = {
isa = PBXGroup;
children = (
+ A1A9B54C4CBA03BEE12B34A9 /* Extensions */,
78E0184C210B140892690CD4 /* Helpers */,
AA563E991F69ED14DCD9A1AB /* Infrastructure */,
8656EEF3161BE20196B8042E /* Models */,
E98EF3B4638A9E8473EA62FA /* Sync */,
- 14254B7ADE539045774E2A31 /* Views */,
);
path = Listless;
sourceTree = "<group>";
@@ -174,6 +166,7 @@
isa = PBXGroup;
children = (
BFF7D84B54AE70036D205CA4 /* PullToCreate.swift */,
+ E51B8129962E5CC78ECDDC2B /* TaskListView.swift */,
199CC2F58DD7CBA3F2229366 /* TaskRowView.swift */,
);
path = Views;
@@ -191,6 +184,7 @@
8629C1C94770B3B0D08B580D /* Extensions */ = {
isa = PBXGroup;
children = (
+ 3FA75CA18C6270D68A755AEB /* TaskListView+Drag.swift */,
712ADAD0FE66175DAA9A6D50 /* TaskListView+NavigationHeader.swift */,
9AD8D4C29E09FEE78AE5AB79 /* TaskListView+PullToCreate.swift */,
CB43816B8E7F083A2AD07F28 /* TaskListView+Toolbar.swift */,
@@ -222,6 +216,14 @@
path = ListlessiOS;
sourceTree = "<group>";
};
+ A1A9B54C4CBA03BEE12B34A9 /* Extensions */ = {
+ isa = PBXGroup;
+ children = (
+ 72B4668483D05A6ECA142B89 /* TaskListView+Logic.swift */,
+ );
+ path = Extensions;
+ sourceTree = "<group>";
+ };
AA563E991F69ED14DCD9A1AB /* Infrastructure */ = {
isa = PBXGroup;
children = (
@@ -233,6 +235,7 @@
D12ECC901ABED96B86CC85B5 /* Views */ = {
isa = PBXGroup;
children = (
+ 632DA39B24C4CF1528A1A24D /* TaskListView.swift */,
E06485DBE35B60868E14202A /* TaskRowView.swift */,
);
path = Views;
@@ -298,8 +301,6 @@
F7279CE4B2501F9F5111A3D8 /* Extensions */ = {
isa = PBXGroup;
children = (
- 3E67FE94ED8E9B248A0C3A97 /* TaskListView+NavigationHeader.swift */,
- A741111B4DEB3A7406A95C93 /* TaskListView+PullToCreate.swift */,
2A440253D045A0896D94ADD0 /* TaskListView+Toolbar.swift */,
);
path = Extensions;
@@ -419,10 +420,9 @@
5761B201BF46FCA9C5C98CEF /* PlatformScrollIndicatorsModifier.swift in Sources */,
DEE187A790A4058FE4AFDB2E /* PlatformTextFieldWidthModifier.swift in Sources */,
C1FE091454864C4BBBBEB077 /* TaskItem.swift in Sources */,
- CAC6A40C680611163775CDFE /* TaskListView+NavigationHeader.swift in Sources */,
- 2374527E3629DD87AE3D2761 /* TaskListView+PullToCreate.swift in Sources */,
+ E5878BAA0EA66A94440E2B0F /* TaskListView+Logic.swift in Sources */,
322EE74CBED492693B92AD59 /* TaskListView+Toolbar.swift in Sources */,
- 8E08F4C82D6F9E67667CB20A /* TaskListView.swift in Sources */,
+ FDD09FECEED48EC9598538F4 /* TaskListView.swift in Sources */,
5035EC4C7518A5FF9AD454CA /* TaskRowDragGesture.swift in Sources */,
3D1F551A03B97ECF4E3DC8B0 /* TaskRowView.swift in Sources */,
3ABE52A15C2059D8D5570528 /* TaskStore.swift in Sources */,
@@ -445,10 +445,12 @@
E47136CA7428927395D8C7C7 /* PullToCreate.swift in Sources */,
0F12D56A528FCBF8A67864CB /* TappableTextField.swift in Sources */,
0ACA67F6578EFF181EE5C9A7 /* TaskItem.swift in Sources */,
+ 82E75475E13FD847DDDC69D8 /* TaskListView+Drag.swift in Sources */,
+ CAD142ED738A83371DFF8F5B /* TaskListView+Logic.swift in Sources */,
26F609518DE1055DF09B0159 /* TaskListView+NavigationHeader.swift in Sources */,
80BCA654FE694D8A77D9BF93 /* TaskListView+PullToCreate.swift in Sources */,
77970A2323A598E830D95301 /* TaskListView+Toolbar.swift in Sources */,
- 42E4CDE1D17463554CC4F41F /* TaskListView.swift in Sources */,
+ C169823665158AA347A63990 /* TaskListView.swift in Sources */,
2C53717A0EAFA39240615C9A /* TaskRowDragGesture.swift in Sources */,
182D9FB61E3A3650D7D83D2A /* TaskRowSwipeGesture.swift in Sources */,
7E366008FF9335A77FE1636D /* TaskRowView.swift in Sources */,
diff --git a/Listless/Extensions/TaskListView+Logic.swift b/Listless/Extensions/TaskListView+Logic.swift
@@ -0,0 +1,391 @@
+import SwiftUI
+
+extension TaskListView {
+
+ // MARK: - Computed Properties
+
+ var activeTasks: [TaskItem] {
+ Array(tasks.filter { !$0.isCompleted })
+ .sorted { $0.sortOrder < $1.sortOrder }
+ }
+
+ var displayActiveTasks: [TaskItem] {
+ guard let visualOrder = visualOrder else {
+ return activeTasks
+ }
+
+ return visualOrder.compactMap { id in
+ activeTasks.first(where: { $0.id == id })
+ }
+ }
+
+ var completedTasks: [TaskItem] {
+ Array(tasks.filter { $0.isCompleted })
+ .sorted { $0.updatedAt > $1.updatedAt }
+ }
+
+ var allTasksInDisplayOrder: [TaskItem] {
+ displayActiveTasks + completedTasks
+ }
+
+ var editingTaskID: UUID? {
+ if case .task(let id) = focusedField {
+ return id
+ }
+ return nil
+ }
+
+ private func isLastActiveTask(_ taskID: UUID) -> Bool {
+ guard let lastTask = activeTasks.last else { return false }
+ return lastTask.id == taskID
+ }
+
+ // MARK: - Task Creation
+
+ 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() {
+ draggedTaskID = nil
+ visualOrder = nil
+ let task = store.createTask(title: "")
+ pendingFocus = .task(task.id)
+ focusedField = .task(task.id)
+ selectedTaskID = task.id
+ }
+
+ // MARK: - Interaction Handlers
+
+ func handleBackgroundTap() {
+ let isTaskFocused = if case .task = focusedField { true } else { false }
+
+ if isTaskFocused || selectedTaskID != nil {
+ selectedTaskID = nil
+ focusedField = nil
+ } else {
+ createNewTask()
+ focusedField = nil
+ }
+ }
+
+ func handleFocusChange(from oldValue: FocusField?, to newValue: FocusField?) {
+ print(
+ "🟣 handleFocusChange() from: \(String(describing: oldValue)) to: \(String(describing: newValue))"
+ )
+ let oldID = taskID(from: oldValue)
+ let newID = taskID(from: newValue)
+
+ guard oldID != newID, let oldID else {
+ print("🟣 handleFocusChange() no action needed")
+ return
+ }
+ print("🟣 handleFocusChange() calling deleteIfEmpty for task \(oldID)")
+ deleteIfEmpty(taskID: oldID)
+ }
+
+ private func taskID(from field: FocusField?) -> UUID? {
+ guard case .task(let id) = field else { return nil }
+ return id
+ }
+
+ private func deleteIfEmpty(taskID: UUID) {
+ if case .task(let pendingTaskID) = pendingFocus, pendingTaskID == taskID {
+ print("🔴 deleteIfEmpty() skipping - task is pending focus")
+ return
+ }
+
+ guard let task = tasks.first(where: { $0.id == taskID }) else {
+ return
+ }
+ let trimmedTitle = task.title.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard trimmedTitle.isEmpty else { return }
+
+ managedObjectContext.undoManager?.removeAllActions(withTarget: task)
+ managedObjectContext.undoManager?.disableUndoRegistration()
+ deleteTask(task)
+ managedObjectContext.undoManager?.enableUndoRegistration()
+ }
+
+ func updateTitle(_ task: TaskItem, _ title: String) {
+ guard task.title != title else { return }
+ store.updateWithoutSaving(taskID: task.id, title: title)
+ }
+
+ func toggleCompletion(_ task: TaskItem) {
+ if task.isCompleted {
+ store.uncomplete(taskID: task.id)
+ } else {
+ store.complete(taskID: task.id)
+ }
+ }
+
+ func handleSwipeComplete(_ taskID: UUID) {
+ guard let task = tasks.first(where: { $0.id == taskID }) else { return }
+ toggleCompletion(task)
+ }
+
+ func handleSwipeDelete(_ taskID: UUID) {
+ guard let task = tasks.first(where: { $0.id == taskID }) else { return }
+ deleteTask(task)
+ }
+
+ func selectTask(_ taskID: UUID) {
+ selectedTaskID = taskID
+ }
+
+ func deleteTask(_ task: TaskItem) {
+ let taskID = task.id
+ print("🔴 deleteTask() called for task \(taskID)")
+
+ if selectedTaskID == taskID {
+ print("🔴 deleteTask() clearing selectedTaskID")
+ selectedTaskID = nil
+ }
+
+ store.delete(taskID: taskID)
+ print("🔴 deleteTask() completed")
+ }
+
+ func clearCompletedTasks() {
+ for task in completedTasks.reversed() {
+ store.delete(taskID: task.id)
+ }
+ }
+
+ // MARK: - Keyboard Navigation
+
+ func navigateUp() -> KeyPress.Result {
+ print("⬆️ navigateUp() called, focusedField: \(String(describing: focusedField))")
+ guard focusedField == .scrollView else {
+ print("⬆️ navigateUp() IGNORED - focus is not .scrollView")
+ return .ignored
+ }
+
+ guard let currentID = selectedTaskID else {
+ selectedTaskID = activeTasks.last?.id
+ return .handled
+ }
+
+ let displayOrder = allTasksInDisplayOrder
+ guard let currentIndex = displayOrder.firstIndex(where: { $0.id == currentID }) else {
+ return .handled
+ }
+
+ if currentIndex > 0 {
+ selectedTaskID = displayOrder[currentIndex - 1].id
+ }
+ return .handled
+ }
+
+ func navigateDown() -> KeyPress.Result {
+ print("⬇️ navigateDown() called, focusedField: \(String(describing: focusedField))")
+ guard focusedField == .scrollView else {
+ print("⬇️ navigateDown() IGNORED - focus is not .scrollView")
+ return .ignored
+ }
+
+ guard let currentID = selectedTaskID else {
+ selectedTaskID = activeTasks.first?.id ?? completedTasks.first?.id
+ return .handled
+ }
+
+ let displayOrder = allTasksInDisplayOrder
+ guard let currentIndex = displayOrder.firstIndex(where: { $0.id == currentID }) else {
+ return .handled
+ }
+
+ if currentIndex < displayOrder.count - 1 {
+ selectedTaskID = displayOrder[currentIndex + 1].id
+ }
+ return .handled
+ }
+
+ func toggleSelectedTask() -> KeyPress.Result {
+ guard focusedField == .scrollView else { return .ignored }
+ guard let currentID = selectedTaskID else { return .handled }
+ guard let task = allTasksInDisplayOrder.first(where: { $0.id == currentID }) else {
+ return .handled
+ }
+ toggleCompletion(task)
+ return .handled
+ }
+
+ func focusSelectedTask() -> KeyPress.Result {
+ guard focusedField == .scrollView else { return .ignored }
+ guard let currentID = selectedTaskID else { return .handled }
+ guard let task = allTasksInDisplayOrder.first(where: { $0.id == currentID }) else {
+ return .handled
+ }
+ guard !task.isCompleted else { return .handled }
+ startEditing(currentID)
+ return .handled
+ }
+
+ func deleteSelectedTask() -> KeyPress.Result {
+ print("🗑️ deleteSelectedTask() called, focusedField: \(String(describing: focusedField))")
+ guard focusedField == .scrollView else {
+ print("🗑️ deleteSelectedTask() IGNORED - focus is not .scrollView")
+ return .ignored
+ }
+ guard let currentID = selectedTaskID else {
+ print("🗑️ deleteSelectedTask() no task selected")
+ return .handled
+ }
+ guard let task = allTasksInDisplayOrder.first(where: { $0.id == currentID }) else {
+ print("🗑️ deleteSelectedTask() task not found")
+ return .handled
+ }
+ print("🗑️ deleteSelectedTask() deleting task \(currentID)")
+ deleteTask(task)
+ return .handled
+ }
+
+ // MARK: - Focus Management
+
+ func focusTextField(_ taskID: UUID) {
+ focusedField = .task(taskID)
+ }
+
+ func startEditing(_ taskID: UUID) {
+ print("🟢 startEditing called for task \(taskID)")
+ selectedTaskID = taskID
+ focusedField = .task(taskID)
+ pendingFocus = nil
+ print("🟢 startEditing set focusedField = .task(\(taskID))")
+ }
+
+ func endEditing(_ taskID: UUID, shouldCreateNewTask: Bool) {
+ print(
+ "🟢 endEditing() called for task \(taskID), shouldCreateNewTask: \(shouldCreateNewTask)"
+ )
+ store.save()
+
+ let wasLastActiveTask = isLastActiveTask(taskID)
+ let willBeDeleted = shouldDeleteIfEmpty(taskID: taskID)
+ print(
+ "🟢 endEditing() wasLastActiveTask: \(wasLastActiveTask), willBeDeleted: \(willBeDeleted)"
+ )
+
+ if willBeDeleted {
+ print("🟢 endEditing() deleting task - focus will be repaired automatically by onChange")
+ selectedTaskID = nil
+ deleteIfEmpty(taskID: taskID)
+ } else if wasLastActiveTask && shouldCreateNewTask {
+ print("🟢 endEditing() creating new task")
+ createNewTask()
+ } else if shouldCreateNewTask {
+ print("🟢 endEditing() Return on non-last task — dismiss keyboard, enter navigation mode")
+ focusedField = .scrollView
+ } else {
+ print("🟢 endEditing() done, selection unchanged")
+ }
+
+ print("🟢 endEditing() completed, final focus: \(String(describing: focusedField))")
+ }
+
+ private func shouldDeleteIfEmpty(taskID: UUID) -> Bool {
+ guard let task = tasks.first(where: { $0.id == taskID }) else {
+ return false
+ }
+ let trimmedTitle = task.title.trimmingCharacters(in: .whitespacesAndNewlines)
+ return trimmedTitle.isEmpty
+ }
+
+ // MARK: - Drag and Drop
+
+ func startDrag(taskID: UUID) {
+ guard draggedTaskID == nil else { return }
+ draggedTaskID = taskID
+ visualOrder = activeTasks.map(\.id)
+ didStartDrag()
+ }
+
+ func updateVisualOrder(insertBefore targetID: UUID) {
+ guard let draggedID = draggedTaskID,
+ let order = visualOrder
+ else { return }
+
+ var newOrder = order.filter { $0 != draggedID }
+ if let targetIndex = newOrder.firstIndex(of: targetID) {
+ newOrder.insert(draggedID, at: targetIndex)
+ }
+
+ if newOrder != visualOrder {
+ withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
+ visualOrder = newOrder
+ }
+ }
+ }
+
+ func updateVisualOrder(insertAfter targetID: UUID) {
+ guard let draggedID = draggedTaskID,
+ let order = visualOrder
+ else { return }
+
+ var newOrder = order.filter { $0 != draggedID }
+ if let targetIndex = newOrder.firstIndex(of: targetID) {
+ newOrder.insert(draggedID, at: targetIndex + 1)
+ }
+
+ if newOrder != visualOrder {
+ withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
+ visualOrder = newOrder
+ }
+ }
+ }
+
+ func updateVisualOrderSmart(relativeTo targetID: UUID) {
+ guard let draggedID = draggedTaskID,
+ let order = visualOrder
+ else { return }
+
+ guard let draggedIndex = order.firstIndex(of: draggedID),
+ let targetIndex = order.firstIndex(of: targetID)
+ else { return }
+
+ if draggedIndex < targetIndex {
+ updateVisualOrder(insertAfter: targetID)
+ } else {
+ updateVisualOrder(insertBefore: targetID)
+ }
+ }
+
+ func updateVisualOrder(insertAtEnd: Bool) {
+ guard let draggedID = draggedTaskID,
+ let order = visualOrder
+ else { return }
+
+ var newOrder = order.filter { $0 != draggedID }
+ newOrder.append(draggedID)
+
+ if newOrder != visualOrder {
+ withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
+ visualOrder = newOrder
+ }
+ }
+ }
+
+ func handleDrop(items: [String]) -> Bool {
+ guard let droppedUUIDString = items.first,
+ let droppedUUID = UUID(uuidString: droppedUUIDString),
+ let order = visualOrder,
+ let finalIndex = order.firstIndex(of: droppedUUID)
+ else {
+ draggedTaskID = nil
+ visualOrder = nil
+ return false
+ }
+
+ store.moveTask(taskID: droppedUUID, toIndex: finalIndex)
+ draggedTaskID = nil
+ visualOrder = nil
+
+ return true
+ }
+}
diff --git a/Listless/Views/.gitkeep b/Listless/Views/.gitkeep
diff --git a/Listless/Views/TaskListView.swift b/Listless/Views/TaskListView.swift
@@ -1,723 +0,0 @@
-import SwiftUI
-
-struct TaskListView: View {
- enum FocusField: Hashable {
- case task(UUID)
- case scrollView
- }
-
- @Environment(\.undoManager) private var undoManager
- @Environment(\.managedObjectContext) private var managedObjectContext
-
- @State private var store: TaskStore
- @FetchRequest(
- sortDescriptors: [
- NSSortDescriptor(keyPath: \TaskItem.isCompleted, ascending: true),
- NSSortDescriptor(keyPath: \TaskItem.createdAt, ascending: true),
- ],
- animation: .default
- )
- private var tasks: FetchedResults<TaskItem>
- @FocusState var focusedField: FocusField?
- @State var selectedTaskID: UUID?
- @State private var refreshID = UUID()
- @State private var draggedTaskID: UUID?
- @State private var visualOrder: [UUID]?
- @State private var pendingFocus: FocusField?
- @State var pullOffset: CGFloat = 0
- #if os(iOS)
- @State private var isDragging: Bool = false
- @State private var rowFrames: [UUID: CGRect] = [:]
- #endif
-
- init(store: TaskStore = TaskStore()) {
- _store = State(wrappedValue: store)
- }
-
- 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)
- TaskRowView(
- task: task,
- taskID: taskID,
- index: index,
- totalTasks: displayActiveTasks.count,
- isSelected: selectedTaskID == taskID,
- isDragging: $isDragging,
- focusedField: $focusedField,
- onToggle: { toggleCompletion($0) },
- onTitleChange: { updateTitle($0, $1) },
- onDelete: { deleteTask($0) },
- onSelect: { selectTask($0) },
- onStartEdit: { startEditing($0) },
- onEndEdit: { endEditing($0, shouldCreateNewTask: $1) }
- )
- .scaleEffect(draggedTaskID == taskID ? 1.05 : 1.0)
- .shadow(
- color: draggedTaskID == taskID ? .black.opacity(0.3) : .clear,
- radius: 12, y: 4
- )
- .zIndex(draggedTaskID == taskID ? 1 : 0)
- .taskDragGesture(
- isActive: !task.isCompleted,
- 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,
- 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,
- taskID: task.id,
- onDragStart: { startDrag(taskID: task.id) }
- )
- .overlay {
- if draggedTaskID != nil && draggedTaskID != task.id {
- VStack(spacing: 0) {
- // Top 1/6 - insert BEFORE
- Color.clear
- .frame(maxHeight: .infinity)
- .layoutPriority(1)
- .dropDestination(
- for: String.self, action: { _, _ in false },
- isTargeted: { isTargeted in
- if isTargeted {
- updateVisualOrder(insertBefore: task.id)
- }
- })
-
- // Middle 2/3 - insert based on direction
- Color.clear
- .frame(maxHeight: .infinity)
- .layoutPriority(4)
- .dropDestination(
- for: String.self, action: { _, _ in false },
- isTargeted: { isTargeted in
- if isTargeted {
- updateVisualOrderSmart(relativeTo: task.id)
- }
- })
-
- // Bottom 1/6 - insert AFTER
- Color.clear
- .frame(maxHeight: .infinity)
- .layoutPriority(1)
- .dropDestination(
- for: String.self, action: { _, _ in false },
- isTargeted: { isTargeted in
- if isTargeted {
- updateVisualOrder(insertAfter: task.id)
- }
- })
- }
- }
- }
- #endif
- }
-
- #if os(macOS)
- // Drop zone at the end
- if !activeTasks.isEmpty && draggedTaskID != nil {
- Color.clear
- .frame(height: 44)
- .dropDestination(
- for: String.self, action: { _, _ in false },
- isTargeted: { isTargeted in
- if isTargeted {
- updateVisualOrder(insertAtEnd: true)
- }
- })
- }
- #endif
-
- ForEach(completedTasks) { task in
- let taskID = task.id
- TaskRowView(
- task: task,
- taskID: taskID,
- isSelected: selectedTaskID == taskID,
- focusedField: $focusedField,
- onToggle: { toggleCompletion($0) },
- onTitleChange: { updateTitle($0, $1) },
- onDelete: { deleteTask($0) },
- onSelect: { selectTask($0) }
- )
- }
- }
- .frame(maxWidth: .infinity, alignment: .topLeading)
- #if os(iOS)
- .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)
- .scrollDisabled(draggedTaskID != nil)
- .background {
- Color.outerBackground.ignoresSafeArea()
- }
- .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 {
- handleBackgroundTap()
- }
- .focusable()
- .focused($focusedField, equals: .scrollView)
- .focusEffectDisabled()
- .accessibilityIdentifier("task-list-scrollview")
- .keyboardNavigation([
- ShortcutKey(key: .upArrow): navigateUp,
- ShortcutKey(key: .downArrow): navigateDown,
- ShortcutKey(key: .space): toggleSelectedTask,
- ShortcutKey(key: .return): focusSelectedTask,
- ShortcutKey(key: .delete): deleteSelectedTask,
- ])
- .onAppear {
- // Set initial focus to enable keyboard navigation
- if focusedField == nil {
- focusedField = .scrollView
- }
- }
- .onChange(of: focusedField) { oldValue, newValue in
- handleFocusChange(from: oldValue, to: newValue)
-
- // Focus repair/resolution: when focus becomes nil, either resolve pending focus or repair to scrollView
- if newValue == nil {
- if let pending = pendingFocus {
- print("🟣 onChange resolving pendingFocus: \(pending)")
- focusedField = pending
- pendingFocus = nil
- } else {
- print("🟣 onChange repairing nil focus to .scrollView")
- focusedField = .scrollView
- }
- }
- }
- .onChange(of: undoManager, initial: true) { _, newValue in
- // Connect SwiftUI's undo manager to Core Data context for automatic undo/redo
- managedObjectContext.undoManager = newValue
- }
- .toolbar {
- platformToolbar
- }
- }
-
- private var vStackSpacing: CGFloat {
- #if os(iOS)
- 12
- #else
- 0
- #endif
- }
-
- private var activeTasks: [TaskItem] {
- Array(tasks.filter { !$0.isCompleted })
- .sorted { $0.sortOrder < $1.sortOrder }
- }
-
- private var displayActiveTasks: [TaskItem] {
- guard let visualOrder = visualOrder else {
- return activeTasks
- }
-
- return visualOrder.compactMap { id in
- activeTasks.first(where: { $0.id == id })
- }
- }
-
- var completedTasks: [TaskItem] {
- Array(tasks.filter { $0.isCompleted })
- .sorted { $0.updatedAt > $1.updatedAt }
- }
-
- var allTasksInDisplayOrder: [TaskItem] {
- displayActiveTasks + completedTasks
- }
-
- private var editingTaskID: UUID? {
- if case .task(let id) = focusedField {
- return id
- }
- return nil
- }
-
- private func isLastActiveTask(_ taskID: UUID) -> Bool {
- guard let lastTask = activeTasks.last else { return false }
- 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
- visualOrder = nil
-
- // Create Core Data task (Core Data assigns the ID)
- let task = store.createTask(title: "")
-
- // Record intent to focus the new task.
- // pendingFocus is retained for the background-tap flow (focusedField → nil there).
- // focusedField is also set directly for the TappableTextField Return flow (stays non-nil).
- pendingFocus = .task(task.id)
- focusedField = .task(task.id)
- selectedTaskID = task.id
- }
-
-
- private func handleBackgroundTap() {
- // Check if a task is focused (not just scrollView)
- let isTaskFocused = if case .task = focusedField { true } else { false }
-
- if isTaskFocused || selectedTaskID != nil {
- selectedTaskID = nil
- focusedField = nil
- } else {
- createNewTask()
- // Trigger focus resolution by setting to nil
- // onChange(of: focusedField) will then resolve pendingFocus
- focusedField = nil
- }
- }
-
- private func handleFocusChange(from oldValue: FocusField?, to newValue: FocusField?) {
- print(
- "🟣 handleFocusChange() from: \(String(describing: oldValue)) to: \(String(describing: newValue))"
- )
- let oldID = taskID(from: oldValue)
- let newID = taskID(from: newValue)
-
- guard oldID != newID, let oldID else {
- print("🟣 handleFocusChange() no action needed")
- return
- }
- print("🟣 handleFocusChange() calling deleteIfEmpty for task \(oldID)")
- deleteIfEmpty(taskID: oldID)
- }
-
- private func taskID(from field: FocusField?) -> UUID? {
- guard case .task(let id) = field else { return nil }
- return id
- }
-
- private func deleteIfEmpty(taskID: UUID) {
- // Don't delete if this is the pending focus target
- if case .task(let pendingTaskID) = pendingFocus, pendingTaskID == taskID {
- print("🔴 deleteIfEmpty() skipping - task is pending focus")
- return
- }
-
- guard let task = tasks.first(where: { $0.id == taskID }) else {
- return
- }
- let trimmedTitle = task.title.trimmingCharacters(in: .whitespacesAndNewlines)
- guard trimmedTitle.isEmpty else { return }
-
- // Remove this task from undo history since it was never really used
- managedObjectContext.undoManager?.removeAllActions(withTarget: task)
-
- // Disable undo registration for the delete operation itself
- managedObjectContext.undoManager?.disableUndoRegistration()
- deleteTask(task)
- managedObjectContext.undoManager?.enableUndoRegistration()
- }
-
- private func updateTitle(_ task: TaskItem, _ title: String) {
- guard task.title != title else { return }
- store.updateWithoutSaving(taskID: task.id, title: title)
- }
-
- private func toggleCompletion(_ task: TaskItem) {
- if task.isCompleted {
- store.uncomplete(taskID: task.id)
- } else {
- store.complete(taskID: task.id)
- }
- }
-
- private func handleSwipeComplete(_ taskID: UUID) {
- guard let task = tasks.first(where: { $0.id == taskID }) else { return }
- toggleCompletion(task)
- }
-
- private func handleSwipeDelete(_ taskID: UUID) {
- guard let task = tasks.first(where: { $0.id == taskID }) else { return }
- deleteTask(task)
- }
-
- private func selectTask(_ taskID: UUID) {
- selectedTaskID = taskID
- }
-
- func deleteTask(_ task: TaskItem) {
- let taskID = task.id
- print("🔴 deleteTask() called for task \(taskID)")
-
- // Clear selection if this task was selected
- if selectedTaskID == taskID {
- print("🔴 deleteTask() clearing selectedTaskID")
- selectedTaskID = nil
- }
-
- store.delete(taskID: taskID)
- print("🔴 deleteTask() completed")
- }
-
- func clearCompletedTasks() {
- // Delete all completed tasks (in reverse to avoid index issues)
- for task in completedTasks.reversed() {
- store.delete(taskID: task.id)
- }
- }
-
- private func navigateUp() -> KeyPress.Result {
- print("⬆️ navigateUp() called, focusedField: \(String(describing: focusedField))")
- guard focusedField == .scrollView else {
- print("⬆️ navigateUp() IGNORED - focus is not .scrollView")
- return .ignored
- }
-
- guard let currentID = selectedTaskID else {
- selectedTaskID = activeTasks.last?.id
- return .handled
- }
-
- let displayOrder = allTasksInDisplayOrder
- guard let currentIndex = displayOrder.firstIndex(where: { $0.id == currentID }) else {
- return .handled
- }
-
- if currentIndex > 0 {
- selectedTaskID = displayOrder[currentIndex - 1].id
- }
- return .handled
- }
-
- private func navigateDown() -> KeyPress.Result {
- print("⬇️ navigateDown() called, focusedField: \(String(describing: focusedField))")
- guard focusedField == .scrollView else {
- print("⬇️ navigateDown() IGNORED - focus is not .scrollView")
- return .ignored
- }
-
- guard let currentID = selectedTaskID else {
- selectedTaskID = activeTasks.first?.id ?? completedTasks.first?.id
- return .handled
- }
-
- let displayOrder = allTasksInDisplayOrder
- guard let currentIndex = displayOrder.firstIndex(where: { $0.id == currentID }) else {
- return .handled
- }
-
- if currentIndex < displayOrder.count - 1 {
- selectedTaskID = displayOrder[currentIndex + 1].id
- }
- return .handled
- }
-
- private func toggleSelectedTask() -> KeyPress.Result {
- guard focusedField == .scrollView else { return .ignored }
- guard let currentID = selectedTaskID else { return .handled }
- guard let task = allTasksInDisplayOrder.first(where: { $0.id == currentID }) else {
- return .handled
- }
- toggleCompletion(task)
- return .handled
- }
-
- private func focusSelectedTask() -> KeyPress.Result {
- guard focusedField == .scrollView else { return .ignored }
- guard let currentID = selectedTaskID else { return .handled }
- guard let task = allTasksInDisplayOrder.first(where: { $0.id == currentID }) else {
- return .handled
- }
- guard !task.isCompleted else { return .handled }
- startEditing(currentID)
- return .handled
- }
-
- private func deleteSelectedTask() -> KeyPress.Result {
- print("🗑️ deleteSelectedTask() called, focusedField: \(String(describing: focusedField))")
- guard focusedField == .scrollView else {
- print("🗑️ deleteSelectedTask() IGNORED - focus is not .scrollView")
- return .ignored
- }
- guard let currentID = selectedTaskID else {
- print("🗑️ deleteSelectedTask() no task selected")
- return .handled
- }
- guard let task = allTasksInDisplayOrder.first(where: { $0.id == currentID }) else {
- print("🗑️ deleteSelectedTask() task not found")
- return .handled
- }
- print("🗑️ deleteSelectedTask() deleting task \(currentID)")
- deleteTask(task)
- return .handled
- }
-
- // MARK: - Focus Management
-
- private func focusTextField(_ taskID: UUID) {
- focusedField = .task(taskID)
- }
-
- private func startEditing(_ taskID: UUID) {
- print("🟢 startEditing called for task \(taskID)")
- selectedTaskID = taskID
- focusedField = .task(taskID)
- pendingFocus = nil // Consume pendingFocus once the field is live
- print("🟢 startEditing set focusedField = .task(\(taskID))")
- }
-
- private func endEditing(_ taskID: UUID, shouldCreateNewTask: Bool) {
- print(
- "🟢 endEditing() called for task \(taskID), shouldCreateNewTask: \(shouldCreateNewTask)")
- // Save any pending changes
- store.save()
-
- // Check conditions BEFORE deleting the task
- let wasLastActiveTask = isLastActiveTask(taskID)
- let willBeDeleted = shouldDeleteIfEmpty(taskID: taskID)
- print(
- "🟢 endEditing() wasLastActiveTask: \(wasLastActiveTask), willBeDeleted: \(willBeDeleted)"
- )
-
- if willBeDeleted {
- print("🟢 endEditing() deleting task - focus will be repaired automatically by onChange")
- selectedTaskID = nil
- deleteIfEmpty(taskID: taskID)
- // No explicit focus management - onChange will repair to .scrollView
- } else if wasLastActiveTask && shouldCreateNewTask {
- print("🟢 endEditing() creating new task")
- createNewTask()
- } else if shouldCreateNewTask {
- print("🟢 endEditing() Return on non-last task — dismiss keyboard, enter navigation mode")
- // TappableTextField returns false from textFieldShouldReturn, so the old field
- // stays first responder. Setting focusedField to .scrollView causes SwiftUI's
- // .focused() to detect the mismatch and call resignFirstResponder(), dismissing
- // the keyboard cleanly.
- focusedField = .scrollView
- } else {
- print("🟢 endEditing() done, selection unchanged")
- // Do NOT restore selectedTaskID = taskID here.
- //
- // On macOS: selectedTaskID is already taskID (set by startEditing when editing
- // began), and AppKit fires controlTextDidEndEditing synchronously before any
- // SwiftUI tap gesture handler runs, so nothing has changed it yet. The line
- // would be a no-op.
- //
- // On iOS: everything flows through onChange(of: focusedField), so onStartEdit
- // on the new row may have already updated selectedTaskID before this fires.
- // Restoring it here would overwrite the new selection.
- }
-
- print("🟢 endEditing() completed, final focus: \(String(describing: focusedField))")
- }
-
- private func shouldDeleteIfEmpty(taskID: UUID) -> Bool {
- guard let task = tasks.first(where: { $0.id == taskID }) else {
- return false
- }
- let trimmedTitle = task.title.trimmingCharacters(in: .whitespacesAndNewlines)
- return trimmedTitle.isEmpty
- }
-
- // MARK: - Drag and Drop
-
- private func startDrag(taskID: UUID) {
- guard draggedTaskID == nil else { return }
- draggedTaskID = taskID
- visualOrder = activeTasks.map(\.id)
- #if os(iOS)
- isDragging = true
- let generator = UIImpactFeedbackGenerator(style: .medium)
- generator.impactOccurred()
- #endif
- }
-
- private func updateVisualOrder(insertBefore targetID: UUID) {
- guard let draggedID = draggedTaskID,
- let order = visualOrder
- else { return }
-
- var newOrder = order.filter { $0 != draggedID }
- if let targetIndex = newOrder.firstIndex(of: targetID) {
- newOrder.insert(draggedID, at: targetIndex)
- }
-
- if newOrder != visualOrder {
- withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
- visualOrder = newOrder
- }
- }
- }
-
- private func updateVisualOrder(insertAfter targetID: UUID) {
- guard let draggedID = draggedTaskID,
- let order = visualOrder
- else { return }
-
- var newOrder = order.filter { $0 != draggedID }
- if let targetIndex = newOrder.firstIndex(of: targetID) {
- newOrder.insert(draggedID, at: targetIndex + 1)
- }
-
- if newOrder != visualOrder {
- withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
- visualOrder = newOrder
- }
- }
- }
-
- private func updateVisualOrderSmart(relativeTo targetID: UUID) {
- guard let draggedID = draggedTaskID,
- let order = visualOrder
- else { return }
-
- // Determine if dragged item is currently above or below target
- guard let draggedIndex = order.firstIndex(of: draggedID),
- let targetIndex = order.firstIndex(of: targetID)
- else { return }
-
- if draggedIndex < targetIndex {
- // Dragging from above → insert after target
- updateVisualOrder(insertAfter: targetID)
- } else {
- // Dragging from below → insert before target
- updateVisualOrder(insertBefore: targetID)
- }
- }
-
- private func updateVisualOrder(insertAtEnd: Bool) {
- guard let draggedID = draggedTaskID,
- let order = visualOrder
- else { return }
-
- var newOrder = order.filter { $0 != draggedID }
- newOrder.append(draggedID)
-
- if newOrder != visualOrder {
- withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
- visualOrder = newOrder
- }
- }
- }
-
- private func handleDrop(items: [String]) -> Bool {
- guard let droppedUUIDString = items.first,
- let droppedUUID = UUID(uuidString: droppedUUIDString),
- let order = visualOrder,
- let finalIndex = order.firstIndex(of: droppedUUID)
- else {
- draggedTaskID = nil
- visualOrder = nil
- return false
- }
-
- // Commit the reorder
- store.moveTask(taskID: droppedUUID, toIndex: finalIndex)
-
- // Clear drag state
- draggedTaskID = nil
- visualOrder = nil
-
- return true
- }
-
- // MARK: - iOS Drag Helpers
-
- #if os(iOS)
- private func handleIOSDragChanged(taskID: UUID, point: CGPoint) {
- guard let draggedID = draggedTaskID,
- var order = visualOrder,
- let currentIndex = order.firstIndex(of: draggedID),
- let draggedFrame = rowFrames[draggedID] else { return }
-
- let threshold = draggedFrame.height * 0.2
-
- // Swap down: finger moved past the bottom edge of the dragged row
- if currentIndex < order.count - 1 && point.y > draggedFrame.maxY + threshold {
- order.swapAt(currentIndex, currentIndex + 1)
- withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
- visualOrder = order
- }
- return
- }
-
- // Swap up: finger moved past the top edge of the dragged row
- if currentIndex > 0 && point.y < draggedFrame.minY - threshold {
- order.swapAt(currentIndex, currentIndex - 1)
- withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
- visualOrder = order
- }
- }
- }
-
- private func commitIOSDrag() {
- guard let draggedID = draggedTaskID,
- let order = visualOrder,
- let finalIndex = order.firstIndex(of: draggedID) else {
- draggedTaskID = nil
- visualOrder = nil
- isDragging = false
- return
- }
- store.moveTask(taskID: draggedID, toIndex: finalIndex)
- draggedTaskID = nil
- visualOrder = nil
- isDragging = false
- }
-
- #endif
-}
diff --git a/ListlessMac/Extensions/TaskListView+NavigationHeader.swift b/ListlessMac/Extensions/TaskListView+NavigationHeader.swift
@@ -1,7 +0,0 @@
-import SwiftUI
-
-extension TaskListView {
- var navigationHeader: some View {
- EmptyView()
- }
-}
diff --git a/ListlessMac/Extensions/TaskListView+PullToCreate.swift b/ListlessMac/Extensions/TaskListView+PullToCreate.swift
@@ -1,7 +0,0 @@
-import SwiftUI
-
-extension TaskListView {
- var pullToCreateIndicatorRow: some View {
- EmptyView()
- }
-}
diff --git a/ListlessMac/Views/TaskListView.swift b/ListlessMac/Views/TaskListView.swift
@@ -0,0 +1,178 @@
+import SwiftUI
+
+struct TaskListView: View {
+ enum FocusField: Hashable {
+ case task(UUID)
+ case scrollView
+ }
+
+ @Environment(\.undoManager) var undoManager
+ @Environment(\.managedObjectContext) var managedObjectContext
+
+ @State var store: TaskStore
+ @FetchRequest(
+ sortDescriptors: [
+ NSSortDescriptor(keyPath: \TaskItem.isCompleted, ascending: true),
+ NSSortDescriptor(keyPath: \TaskItem.createdAt, ascending: true),
+ ],
+ animation: .default
+ )
+ var tasks: FetchedResults<TaskItem>
+ @FocusState var focusedField: FocusField?
+ @State var selectedTaskID: UUID?
+ @State private var refreshID = UUID()
+ @State var draggedTaskID: UUID?
+ @State var visualOrder: [UUID]?
+ @State var pendingFocus: FocusField?
+ @State var pullOffset: CGFloat = 0
+
+ var vStackSpacing: CGFloat { 0 }
+
+ init(store: TaskStore = TaskStore()) {
+ _store = State(wrappedValue: store)
+ }
+
+ func didStartDrag() {}
+
+ var body: some View {
+ ScrollView {
+ VStack(alignment: .leading, spacing: vStackSpacing) {
+ ForEach(Array(displayActiveTasks.enumerated()), id: \.element.id) { index, task in
+ let taskID = task.id
+ 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,
+ taskID: task.id,
+ onDragStart: { startDrag(taskID: task.id) }
+ )
+ .overlay {
+ if draggedTaskID != nil && draggedTaskID != task.id {
+ VStack(spacing: 0) {
+ // Top 1/6 - insert BEFORE
+ Color.clear
+ .frame(maxHeight: .infinity)
+ .layoutPriority(1)
+ .dropDestination(
+ for: String.self, action: { _, _ in false },
+ isTargeted: { isTargeted in
+ if isTargeted {
+ updateVisualOrder(insertBefore: task.id)
+ }
+ })
+
+ // Middle 2/3 - insert based on direction
+ Color.clear
+ .frame(maxHeight: .infinity)
+ .layoutPriority(4)
+ .dropDestination(
+ for: String.self, action: { _, _ in false },
+ isTargeted: { isTargeted in
+ if isTargeted {
+ updateVisualOrderSmart(relativeTo: task.id)
+ }
+ })
+
+ // Bottom 1/6 - insert AFTER
+ Color.clear
+ .frame(maxHeight: .infinity)
+ .layoutPriority(1)
+ .dropDestination(
+ for: String.self, action: { _, _ in false },
+ isTargeted: { isTargeted in
+ if isTargeted {
+ updateVisualOrder(insertAfter: task.id)
+ }
+ })
+ }
+ }
+ }
+ }
+
+ // Drop zone at the end
+ if !activeTasks.isEmpty && draggedTaskID != nil {
+ Color.clear
+ .frame(height: 44)
+ .dropDestination(
+ for: String.self, action: { _, _ in false },
+ isTargeted: { isTargeted in
+ if isTargeted {
+ updateVisualOrder(insertAtEnd: true)
+ }
+ })
+ }
+
+ ForEach(completedTasks) { task in
+ let taskID = task.id
+ TaskRowView(
+ task: task,
+ taskID: taskID,
+ isSelected: selectedTaskID == taskID,
+ focusedField: $focusedField,
+ onToggle: { toggleCompletion($0) },
+ onTitleChange: { updateTitle($0, $1) },
+ onDelete: { deleteTask($0) },
+ onSelect: { selectTask($0) }
+ )
+ }
+ }
+ .frame(maxWidth: .infinity, alignment: .topLeading)
+ .offset(y: -pullOffset)
+ .dropDestination(for: String.self) { items, location in
+ handleDrop(items: items)
+ }
+ }
+ .contentShape(Rectangle())
+ .onTapGesture {
+ handleBackgroundTap()
+ }
+ .focusable()
+ .focused($focusedField, equals: .scrollView)
+ .focusEffectDisabled()
+ .accessibilityIdentifier("task-list-scrollview")
+ .keyboardNavigation([
+ ShortcutKey(key: .upArrow): navigateUp,
+ ShortcutKey(key: .downArrow): navigateDown,
+ ShortcutKey(key: .space): toggleSelectedTask,
+ ShortcutKey(key: .return): focusSelectedTask,
+ ShortcutKey(key: .delete): deleteSelectedTask,
+ ])
+ .onAppear {
+ if focusedField == nil {
+ focusedField = .scrollView
+ }
+ }
+ .onChange(of: focusedField) { oldValue, newValue in
+ handleFocusChange(from: oldValue, to: newValue)
+
+ if newValue == nil {
+ if let pending = pendingFocus {
+ print("🟣 onChange resolving pendingFocus: \(pending)")
+ focusedField = pending
+ pendingFocus = nil
+ } else {
+ print("🟣 onChange repairing nil focus to .scrollView")
+ focusedField = .scrollView
+ }
+ }
+ }
+ .onChange(of: undoManager, initial: true) { _, newValue in
+ managedObjectContext.undoManager = newValue
+ }
+ .toolbar {
+ platformToolbar
+ }
+ }
+}
diff --git a/ListlessiOS/Extensions/TaskListView+Drag.swift b/ListlessiOS/Extensions/TaskListView+Drag.swift
@@ -0,0 +1,44 @@
+import SwiftUI
+
+extension TaskListView {
+ func handleIOSDragChanged(taskID: UUID, point: CGPoint) {
+ guard let draggedID = draggedTaskID,
+ var order = visualOrder,
+ let currentIndex = order.firstIndex(of: draggedID),
+ let draggedFrame = rowFrames[draggedID] else { return }
+
+ let threshold = draggedFrame.height * 0.2
+
+ // Swap down: finger moved past the bottom edge of the dragged row
+ if currentIndex < order.count - 1 && point.y > draggedFrame.maxY + threshold {
+ order.swapAt(currentIndex, currentIndex + 1)
+ withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
+ visualOrder = order
+ }
+ return
+ }
+
+ // Swap up: finger moved past the top edge of the dragged row
+ if currentIndex > 0 && point.y < draggedFrame.minY - threshold {
+ order.swapAt(currentIndex, currentIndex - 1)
+ withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
+ visualOrder = order
+ }
+ }
+ }
+
+ func commitIOSDrag() {
+ guard let draggedID = draggedTaskID,
+ let order = visualOrder,
+ let finalIndex = order.firstIndex(of: draggedID) else {
+ draggedTaskID = nil
+ visualOrder = nil
+ isDragging = false
+ return
+ }
+ store.moveTask(taskID: draggedID, toIndex: finalIndex)
+ draggedTaskID = nil
+ visualOrder = nil
+ isDragging = false
+ }
+}
diff --git a/ListlessiOS/Views/TaskListView.swift b/ListlessiOS/Views/TaskListView.swift
@@ -0,0 +1,163 @@
+import SwiftUI
+
+struct TaskListView: View {
+ enum FocusField: Hashable {
+ case task(UUID)
+ case scrollView
+ }
+
+ @Environment(\.undoManager) var undoManager
+ @Environment(\.managedObjectContext) var managedObjectContext
+
+ @State var store: TaskStore
+ @FetchRequest(
+ sortDescriptors: [
+ NSSortDescriptor(keyPath: \TaskItem.isCompleted, ascending: true),
+ NSSortDescriptor(keyPath: \TaskItem.createdAt, ascending: true),
+ ],
+ animation: .default
+ )
+ var tasks: FetchedResults<TaskItem>
+ @FocusState var focusedField: FocusField?
+ @State var selectedTaskID: UUID?
+ @State private var refreshID = UUID()
+ @State var draggedTaskID: UUID?
+ @State var visualOrder: [UUID]?
+ @State var pendingFocus: FocusField?
+ @State var pullOffset: CGFloat = 0
+ @State var isDragging: Bool = false
+ @State var rowFrames: [UUID: CGRect] = [:]
+
+ var vStackSpacing: CGFloat { 12 }
+
+ init(store: TaskStore = TaskStore()) {
+ _store = State(wrappedValue: store)
+ }
+
+ func didStartDrag() {
+ isDragging = true
+ let generator = UIImpactFeedbackGenerator(style: .medium)
+ generator.impactOccurred()
+ }
+
+ 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
+ TaskRowView(
+ task: task,
+ taskID: taskID,
+ index: index,
+ totalTasks: displayActiveTasks.count,
+ isSelected: selectedTaskID == taskID,
+ isDragging: $isDragging,
+ focusedField: $focusedField,
+ onToggle: { toggleCompletion($0) },
+ onTitleChange: { updateTitle($0, $1) },
+ onDelete: { deleteTask($0) },
+ onSelect: { selectTask($0) },
+ onStartEdit: { startEditing($0) },
+ onEndEdit: { endEditing($0, shouldCreateNewTask: $1) }
+ )
+ .scaleEffect(draggedTaskID == taskID ? 1.05 : 1.0)
+ .shadow(
+ color: draggedTaskID == taskID ? .black.opacity(0.3) : .clear,
+ radius: 12, y: 4
+ )
+ .zIndex(draggedTaskID == taskID ? 1 : 0)
+ .taskDragGesture(
+ isActive: !task.isCompleted,
+ 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
+ }
+ }
+
+ ForEach(completedTasks) { task in
+ let taskID = task.id
+ TaskRowView(
+ task: task,
+ taskID: taskID,
+ isSelected: selectedTaskID == taskID,
+ focusedField: $focusedField,
+ onToggle: { toggleCompletion($0) },
+ onTitleChange: { updateTitle($0, $1) },
+ onDelete: { deleteTask($0) },
+ onSelect: { selectTask($0) }
+ )
+ }
+ }
+ .frame(maxWidth: .infinity, alignment: .topLeading)
+ .padding(.trailing, 16)
+ .padding(.vertical, 12)
+ .offset(y: -pullOffset)
+ }
+ .scrollDisabled(draggedTaskID != nil)
+ .background {
+ Color.outerBackground.ignoresSafeArea()
+ }
+ .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
+ }
+ .contentShape(Rectangle())
+ .onTapGesture {
+ handleBackgroundTap()
+ }
+ .focusable()
+ .focused($focusedField, equals: .scrollView)
+ .focusEffectDisabled()
+ .accessibilityIdentifier("task-list-scrollview")
+ .keyboardNavigation([
+ ShortcutKey(key: .upArrow): navigateUp,
+ ShortcutKey(key: .downArrow): navigateDown,
+ ShortcutKey(key: .space): toggleSelectedTask,
+ ShortcutKey(key: .return): focusSelectedTask,
+ ShortcutKey(key: .delete): deleteSelectedTask,
+ ])
+ .onAppear {
+ if focusedField == nil {
+ focusedField = .scrollView
+ }
+ }
+ .onChange(of: focusedField) { oldValue, newValue in
+ handleFocusChange(from: oldValue, to: newValue)
+
+ if newValue == nil {
+ if let pending = pendingFocus {
+ print("🟣 onChange resolving pendingFocus: \(pending)")
+ focusedField = pending
+ pendingFocus = nil
+ } else {
+ print("🟣 onChange repairing nil focus to .scrollView")
+ focusedField = .scrollView
+ }
+ }
+ }
+ .onChange(of: undoManager, initial: true) { _, newValue in
+ managedObjectContext.undoManager = newValue
+ }
+ .toolbar {
+ platformToolbar
+ }
+ }
+}