listless

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

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:
MListless.xcodeproj/project.pbxproj | 50++++++++++++++++++++++++++------------------------
AListless/Extensions/TaskListView+Logic.swift | 391+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
DListless/Views/.gitkeep | 0
DListless/Views/TaskListView.swift | 723-------------------------------------------------------------------------------
DListlessMac/Extensions/TaskListView+NavigationHeader.swift | 7-------
DListlessMac/Extensions/TaskListView+PullToCreate.swift | 7-------
AListlessMac/Views/TaskListView.swift | 178+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
AListlessiOS/Extensions/TaskListView+Drag.swift | 44++++++++++++++++++++++++++++++++++++++++++++
AListlessiOS/Views/TaskListView.swift | 163+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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 + } + } +}