listless

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

commit 61f90d7cd4c0681e709a2240e899e56614d773b9
parent 7d831d8ddffddb726ec25e3065572a83884564f4
Author: Michael Camilleri <[email protected]>
Date:   Wed, 18 Feb 2026 03:15:51 +0900

Restructure and add first version of drag-and-drop reordering on iOS

Co-Authored-By: Claude 4.6 Opus <[email protected]>

Diffstat:
MListless.xcodeproj/project.pbxproj | 208+++++++++++++++++++++++++++++++++++++++++++++++--------------------------------
RListless/Views/AccentColor.swift -> Listless/Helpers/AccentColor.swift | 0
RListless/Views/KeyboardNavigationModifier.swift -> Listless/Helpers/KeyboardNavigationModifier.swift | 0
MListless/Views/TaskListView.swift | 170++++++++++++++++++++++++++++---------------------------------------------------
RListlessMac/Views/TaskListView+NavigationHeader.swift -> ListlessMac/Extensions/TaskListView+NavigationHeader.swift | 0
RListlessMac/Views/TaskListView+PullToCreate.swift -> ListlessMac/Extensions/TaskListView+PullToCreate.swift | 0
RListlessMac/Views/TaskListView+Toolbar.swift -> ListlessMac/Extensions/TaskListView+Toolbar.swift | 0
RListlessMac/Views/ClickableTextField.swift -> ListlessMac/Helpers/ClickableTextField.swift | 0
RListlessMac/Views/HoverCursorModifier.swift -> ListlessMac/Helpers/HoverCursorModifier.swift | 0
RListlessMac/Views/PlatformScrollIndicatorsModifier.swift -> ListlessMac/Helpers/PlatformScrollIndicatorsModifier.swift | 0
RListlessMac/Views/PlatformTextFieldWidthModifier.swift -> ListlessMac/Helpers/PlatformTextFieldWidthModifier.swift | 0
RListlessMac/Views/TaskRowDragGesture.swift -> ListlessMac/Helpers/TaskRowDragGesture.swift | 0
MListlessMac/Views/TaskRowView.swift | 3+++
RListlessiOS/Views/TaskListView+NavigationHeader.swift -> ListlessiOS/Extensions/TaskListView+NavigationHeader.swift | 0
RListlessiOS/Views/TaskListView+PullToCreate.swift -> ListlessiOS/Extensions/TaskListView+PullToCreate.swift | 0
RListlessiOS/Views/TaskListView+Toolbar.swift -> ListlessiOS/Extensions/TaskListView+Toolbar.swift | 0
RListlessiOS/Views/AppColors.swift -> ListlessiOS/Helpers/AppColors.swift | 0
RListlessiOS/Views/HoverCursorModifier.swift -> ListlessiOS/Helpers/HoverCursorModifier.swift | 0
RListlessiOS/Views/PlatformScrollIndicatorsModifier.swift -> ListlessiOS/Helpers/PlatformScrollIndicatorsModifier.swift | 0
RListlessiOS/Views/PlatformTextFieldWidthModifier.swift -> ListlessiOS/Helpers/PlatformTextFieldWidthModifier.swift | 0
RListlessiOS/Views/TappableTextField.swift -> ListlessiOS/Helpers/TappableTextField.swift | 0
AListlessiOS/Helpers/TaskRowDragGesture.swift | 54++++++++++++++++++++++++++++++++++++++++++++++++++++++
AListlessiOS/Helpers/TaskRowSwipeGesture.swift | 238+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
DListlessiOS/Views/TaskRowDragGesture.swift | 108-------------------------------------------------------------------------------
DListlessiOS/Views/TaskRowSwipeGesture.swift | 231-------------------------------------------------------------------------------
MListlessiOS/Views/TaskRowView.swift | 12+++++++++++-
26 files changed, 489 insertions(+), 535 deletions(-)

diff --git a/Listless.xcodeproj/project.pbxproj b/Listless.xcodeproj/project.pbxproj @@ -7,48 +7,48 @@ objects = { /* Begin PBXBuildFile section */ - 074A81D9DAF58E8E088CBC89 /* TaskListView+Toolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 431B0EA72D590AF0CC868515 /* TaskListView+Toolbar.swift */; }; 0ACA67F6578EFF181EE5C9A7 /* TaskItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBB8A3BEB346267B30B4675F /* TaskItem.swift */; }; - 15B71073767FB4766A6BA2BE /* HoverCursorModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = D41D6E0CED14D79F31C45062 /* HoverCursorModifier.swift */; }; + 0F12D56A528FCBF8A67864CB /* TappableTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6ECE0E961F87BA32FA87BF90 /* TappableTextField.swift */; }; + 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 */; }; - 1E3857004DDD888BB4A6ED50 /* AccentColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F0630FFEEFF7662A414E9A8 /* AccentColor.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 */; }; - 3FCEFB586D9A9085A201AE7D /* TaskListView+PullToCreate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 95BF73E1B8FE337B76D3E757 /* TaskListView+PullToCreate.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 */; }; - 5CFFBC8A17E065640857540A /* AccentColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F0630FFEEFF7662A414E9A8 /* AccentColor.swift */; }; + 5D3EE9526DA269EE9EE3AB52 /* AppColors.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1E998119283F784B9ADEE28 /* AppColors.swift */; }; 614FCCA450EC0BFFD8B40640 /* ListlessMacApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DA467DF2E59BDBE6EEF6A7D /* ListlessMacApp.swift */; }; - 6C252050E62AED3A0A684EBF /* KeyboardNavigationModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E7B37EF6A2389656D105FF8 /* KeyboardNavigationModifier.swift */; }; - 731477635D3F4DFF1F78D673 /* AppColors.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD1FE1858BC9E8915A091D33 /* AppColors.swift */; }; - 7B2CD636BA3B63A586F93E31 /* TaskRowSwipeGesture.swift in Sources */ = {isa = PBXBuildFile; fileRef = 44C16D36971A140364159FB9 /* TaskRowSwipeGesture.swift */; }; + 763363F6F3C7D2D3C9A63977 /* PlatformScrollIndicatorsModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA5D58EC1FBAA96E83A79445 /* PlatformScrollIndicatorsModifier.swift */; }; + 77970A2323A598E830D95301 /* TaskListView+Toolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB43816B8E7F083A2AD07F28 /* TaskListView+Toolbar.swift */; }; + 77FE96F070B1F7FE31A9CE51 /* PlatformTextFieldWidthModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2174C43654733E9D4023157 /* PlatformTextFieldWidthModifier.swift */; }; 7E366008FF9335A77FE1636D /* TaskRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 199CC2F58DD7CBA3F2229366 /* TaskRowView.swift */; }; - 83378A0575640417ED41FC25 /* ClickableTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F2BB7ABCABA4EB67180A200 /* ClickableTextField.swift */; }; + 80BCA654FE694D8A77D9BF93 /* TaskListView+PullToCreate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AD8D4C29E09FEE78AE5AB79 /* TaskListView+PullToCreate.swift */; }; + 882695E7AE463C0F39ACFF3C /* HoverCursorModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F5D8B5866362D422A2A331C /* HoverCursorModifier.swift */; }; 8E08F4C82D6F9E67667CB20A /* TaskListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 537A913AC421BAEF60D26D9C /* TaskListView.swift */; }; - 8E680EC18B0339931E785F0C /* PlatformScrollIndicatorsModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48AE1AE43296C1692FA6F755 /* PlatformScrollIndicatorsModifier.swift */; }; - 907478D07E5CCD4966579306 /* TaskListView+NavigationHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0C28F7AF2AFAF677F678087 /* TaskListView+NavigationHeader.swift */; }; 91EDF52C7C5C0B35E9D8B51E /* TaskStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC3DEE364304587D280C5672 /* TaskStore.swift */; }; - 92932AEC64B65AD6F95A1E42 /* TaskListView+PullToCreate.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF51ACA55E28AA1A11D3962C /* TaskListView+PullToCreate.swift */; }; 96617677059FABDBB80D642B /* Listless.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = C093494053E6C348F245D4EC /* Listless.xcdatamodeld */; }; 99977BFA37FBAAA49AF6B71E /* TaskStoreEdgeCaseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B2BBE01D99CDA278BCB9F49 /* TaskStoreEdgeCaseTests.swift */; }; + 99D17075DA3F00F52A18BB4D /* AccentColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17DD7EDA74DAAFA27C84CA08 /* AccentColor.swift */; }; A0AA8FD4C542E9AEB2437BC2 /* PersistenceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C14858BDFD1FD5119F1F24A6 /* PersistenceController.swift */; }; - A8D13DD6196772ECA146CF2A /* PlatformScrollIndicatorsModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4D74AE2501F35974F57D21F /* PlatformScrollIndicatorsModifier.swift */; }; - AB5D681B98FC1D2FD7BF2FAA /* TaskListView+NavigationHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7AF738EEB6ABDF86B0B781F /* TaskListView+NavigationHeader.swift */; }; - C1BD61D6DFA687E9CB9ACA60 /* TaskRowDragGesture.swift in Sources */ = {isa = PBXBuildFile; fileRef = 138612F350ECE29526F689B9 /* TaskRowDragGesture.swift */; }; + BA6953E0EFE6F8255F05A3FD /* AccentColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17DD7EDA74DAAFA27C84CA08 /* AccentColor.swift */; }; C1FE091454864C4BBBBEB077 /* TaskItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBB8A3BEB346267B30B4675F /* TaskItem.swift */; }; - D6B3DBDD6A3F0A6E166CFFD5 /* TaskRowDragGesture.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B3D0B4710C7A0EE4F227851 /* TaskRowDragGesture.swift */; }; - D878CD3A552C6A9685A30AA8 /* PlatformTextFieldWidthModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF6B0195EA0176B72DE9B092 /* PlatformTextFieldWidthModifier.swift */; }; - D8EFF49E9156083D675D47F0 /* KeyboardNavigationModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E7B37EF6A2389656D105FF8 /* KeyboardNavigationModifier.swift */; }; - D9DA553485AD0CA28D5BE0C8 /* TaskListView+Toolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93FF5B9F5B979D54D5DEE192 /* TaskListView+Toolbar.swift */; }; - DC71C45D92524C3893BF9FDB /* PlatformTextFieldWidthModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81880970CCFC2CB00B65047E /* PlatformTextFieldWidthModifier.swift */; }; + CAC6A40C680611163775CDFE /* TaskListView+NavigationHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E67FE94ED8E9B248A0C3A97 /* TaskListView+NavigationHeader.swift */; }; + DB5FF6C1AA57D4C9BDDD50FD /* ClickableTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = D640E7D21735C62A30A26DA4 /* ClickableTextField.swift */; }; DC73A39A269AB495BCE1AC48 /* PersistenceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C14858BDFD1FD5119F1F24A6 /* PersistenceController.swift */; }; - E3C6D4AE5E729D34086F132D /* TappableTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 95568F6A132636D04B8CF593 /* TappableTextField.swift */; }; + DEE187A790A4058FE4AFDB2E /* PlatformTextFieldWidthModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8448C5778F75F52719114AF /* PlatformTextFieldWidthModifier.swift */; }; E47136CA7428927395D8C7C7 /* PullToCreate.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFF7D84B54AE70036D205CA4 /* PullToCreate.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 */; }; - FEC96DAA2BF8BB5D6D8504EB /* HoverCursorModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82A3509AD32A54434BCC8017 /* HoverCursorModifier.swift */; }; + F6587B84ECC6BFE92A5FB493 /* KeyboardNavigationModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4669414A460FC0758D5B49A8 /* KeyboardNavigationModifier.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -63,53 +63,53 @@ /* Begin PBXFileReference section */ 01E141436176F83594E2F26B /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; }; - 0E7B37EF6A2389656D105FF8 /* KeyboardNavigationModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardNavigationModifier.swift; sourceTree = "<group>"; }; - 0F2BB7ABCABA4EB67180A200 /* ClickableTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClickableTextField.swift; sourceTree = "<group>"; }; + 067D666DF0AF1C53404ECF7C /* TaskRowSwipeGesture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskRowSwipeGesture.swift; sourceTree = "<group>"; }; 126108860D7878DDC3BECC4B /* Listless iOS.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = "Listless iOS.app"; sourceTree = BUILT_PRODUCTS_DIR; }; - 138612F350ECE29526F689B9 /* TaskRowDragGesture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskRowDragGesture.swift; sourceTree = "<group>"; }; + 17DD7EDA74DAAFA27C84CA08 /* AccentColor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccentColor.swift; sourceTree = "<group>"; }; 199CC2F58DD7CBA3F2229366 /* TaskRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskRowView.swift; sourceTree = "<group>"; }; - 1B3D0B4710C7A0EE4F227851 /* TaskRowDragGesture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskRowDragGesture.swift; sourceTree = "<group>"; }; 1DA467DF2E59BDBE6EEF6A7D /* ListlessMacApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListlessMacApp.swift; sourceTree = "<group>"; }; + 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>"; }; - 431B0EA72D590AF0CC868515 /* TaskListView+Toolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TaskListView+Toolbar.swift"; sourceTree = "<group>"; }; - 44C16D36971A140364159FB9 /* TaskRowSwipeGesture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskRowSwipeGesture.swift; sourceTree = "<group>"; }; - 48AE1AE43296C1692FA6F755 /* PlatformScrollIndicatorsModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlatformScrollIndicatorsModifier.swift; sourceTree = "<group>"; }; + 3E67FE94ED8E9B248A0C3A97 /* TaskListView+NavigationHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TaskListView+NavigationHeader.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>"; }; + 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>"; }; 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>"; }; - 7F0630FFEEFF7662A414E9A8 /* AccentColor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccentColor.swift; sourceTree = "<group>"; }; - 81880970CCFC2CB00B65047E /* PlatformTextFieldWidthModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlatformTextFieldWidthModifier.swift; sourceTree = "<group>"; }; - 82A3509AD32A54434BCC8017 /* HoverCursorModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HoverCursorModifier.swift; sourceTree = "<group>"; }; 9262207DAC21619BD9EDEE15 /* Listless.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Listless.entitlements; sourceTree = "<group>"; }; - 93FF5B9F5B979D54D5DEE192 /* TaskListView+Toolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TaskListView+Toolbar.swift"; sourceTree = "<group>"; }; 944BAE054AAC1B9C4FC954F9 /* .gitkeep */ = {isa = PBXFileReference; path = .gitkeep; sourceTree = "<group>"; }; - 95568F6A132636D04B8CF593 /* TappableTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TappableTextField.swift; sourceTree = "<group>"; }; - 95BF73E1B8FE337B76D3E757 /* TaskListView+PullToCreate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TaskListView+PullToCreate.swift"; sourceTree = "<group>"; }; 967F7ECEB3915CEDCE584872 /* TaskStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskStoreTests.swift; sourceTree = "<group>"; }; + 9AD8D4C29E09FEE78AE5AB79 /* TaskListView+PullToCreate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TaskListView+PullToCreate.swift"; sourceTree = "<group>"; }; 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>"; }; - A0C28F7AF2AFAF677F678087 /* TaskListView+NavigationHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TaskListView+NavigationHeader.swift"; sourceTree = "<group>"; }; - A7AF738EEB6ABDF86B0B781F /* TaskListView+NavigationHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TaskListView+NavigationHeader.swift"; sourceTree = "<group>"; }; + 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>"; }; - B4D74AE2501F35974F57D21F /* PlatformScrollIndicatorsModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlatformScrollIndicatorsModifier.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>"; }; C14858BDFD1FD5119F1F24A6 /* PersistenceController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceController.swift; sourceTree = "<group>"; }; C71466C5CD1A5BA984352F8D /* Listless iOS Unit Tests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = "Listless iOS Unit Tests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; C9B14DC786A336008AAB78EE /* .gitkeep */ = {isa = PBXFileReference; path = .gitkeep; sourceTree = "<group>"; }; + 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>"; }; - D41D6E0CED14D79F31C45062 /* HoverCursorModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HoverCursorModifier.swift; 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>"; }; - DF51ACA55E28AA1A11D3962C /* TaskListView+PullToCreate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TaskListView+PullToCreate.swift"; sourceTree = "<group>"; }; E06485DBE35B60868E14202A /* TaskRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskRowView.swift; sourceTree = "<group>"; }; - EF6B0195EA0176B72DE9B092 /* PlatformTextFieldWidthModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlatformTextFieldWidthModifier.swift; sourceTree = "<group>"; }; + E2174C43654733E9D4023157 /* PlatformTextFieldWidthModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlatformTextFieldWidthModifier.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>"; }; + FA5D58EC1FBAA96E83A79445 /* PlatformScrollIndicatorsModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlatformScrollIndicatorsModifier.swift; sourceTree = "<group>"; }; FBB8A3BEB346267B30B4675F /* TaskItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskItem.swift; sourceTree = "<group>"; }; - FD1FE1858BC9E8915A091D33 /* AppColors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppColors.swift; sourceTree = "<group>"; }; /* End PBXFileReference section */ /* Begin PBXGroup section */ @@ -117,13 +117,25 @@ isa = PBXGroup; children = ( D123BB181208FC825777B0A7 /* .gitkeep */, - 7F0630FFEEFF7662A414E9A8 /* AccentColor.swift */, - 0E7B37EF6A2389656D105FF8 /* KeyboardNavigationModifier.swift */, 537A913AC421BAEF60D26D9C /* TaskListView.swift */, ); path = Views; sourceTree = "<group>"; }; + 19A2FAA566465414CA20E6D8 /* Helpers */ = { + isa = PBXGroup; + children = ( + F1E998119283F784B9ADEE28 /* AppColors.swift */, + B7588879D0FA1C2A8BCEF14F /* HoverCursorModifier.swift */, + FA5D58EC1FBAA96E83A79445 /* PlatformScrollIndicatorsModifier.swift */, + E2174C43654733E9D4023157 /* PlatformTextFieldWidthModifier.swift */, + 6ECE0E961F87BA32FA87BF90 /* TappableTextField.swift */, + D57C3CC81C4380EFAE4DB910 /* TaskRowDragGesture.swift */, + 067D666DF0AF1C53404ECF7C /* TaskRowSwipeGesture.swift */, + ); + path = Helpers; + sourceTree = "<group>"; + }; 3936BDEE64D16E6C4C85B3DD /* Products */ = { isa = PBXGroup; children = ( @@ -134,9 +146,22 @@ name = Products; sourceTree = "<group>"; }; + 42CC0393E2524624AEC54D03 /* Helpers */ = { + isa = PBXGroup; + children = ( + D640E7D21735C62A30A26DA4 /* ClickableTextField.swift */, + 9F5D8B5866362D422A2A331C /* HoverCursorModifier.swift */, + 466F9B0E407DF1F5B4789531 /* PlatformScrollIndicatorsModifier.swift */, + E8448C5778F75F52719114AF /* PlatformTextFieldWidthModifier.swift */, + F416DD868A4C044F0D64F8D0 /* TaskRowDragGesture.swift */, + ); + path = Helpers; + sourceTree = "<group>"; + }; 58051CBDE2390F9E13647235 /* Listless */ = { isa = PBXGroup; children = ( + 78E0184C210B140892690CD4 /* Helpers */, AA563E991F69ED14DCD9A1AB /* Infrastructure */, 8656EEF3161BE20196B8042E /* Models */, E98EF3B4638A9E8473EA62FA /* Sync */, @@ -148,22 +173,31 @@ 58F917D865E0BDF4EF282306 /* Views */ = { isa = PBXGroup; children = ( - FD1FE1858BC9E8915A091D33 /* AppColors.swift */, - 82A3509AD32A54434BCC8017 /* HoverCursorModifier.swift */, - 48AE1AE43296C1692FA6F755 /* PlatformScrollIndicatorsModifier.swift */, - 81880970CCFC2CB00B65047E /* PlatformTextFieldWidthModifier.swift */, BFF7D84B54AE70036D205CA4 /* PullToCreate.swift */, - 95568F6A132636D04B8CF593 /* TappableTextField.swift */, - A0C28F7AF2AFAF677F678087 /* TaskListView+NavigationHeader.swift */, - DF51ACA55E28AA1A11D3962C /* TaskListView+PullToCreate.swift */, - 93FF5B9F5B979D54D5DEE192 /* TaskListView+Toolbar.swift */, - 1B3D0B4710C7A0EE4F227851 /* TaskRowDragGesture.swift */, - 44C16D36971A140364159FB9 /* TaskRowSwipeGesture.swift */, 199CC2F58DD7CBA3F2229366 /* TaskRowView.swift */, ); path = Views; sourceTree = "<group>"; }; + 78E0184C210B140892690CD4 /* Helpers */ = { + isa = PBXGroup; + children = ( + 17DD7EDA74DAAFA27C84CA08 /* AccentColor.swift */, + 4669414A460FC0758D5B49A8 /* KeyboardNavigationModifier.swift */, + ); + path = Helpers; + sourceTree = "<group>"; + }; + 8629C1C94770B3B0D08B580D /* Extensions */ = { + isa = PBXGroup; + children = ( + 712ADAD0FE66175DAA9A6D50 /* TaskListView+NavigationHeader.swift */, + 9AD8D4C29E09FEE78AE5AB79 /* TaskListView+PullToCreate.swift */, + CB43816B8E7F083A2AD07F28 /* TaskListView+Toolbar.swift */, + ); + path = Extensions; + sourceTree = "<group>"; + }; 8656EEF3161BE20196B8042E /* Models */ = { isa = PBXGroup; children = ( @@ -181,6 +215,8 @@ 3313FEDB101EECA4B344EEF4 /* Info.plist */, 9262207DAC21619BD9EDEE15 /* Listless.entitlements */, AC245331D715EA85887C0BA0 /* ListlessiOSApp.swift */, + 8629C1C94770B3B0D08B580D /* Extensions */, + 19A2FAA566465414CA20E6D8 /* Helpers */, 58F917D865E0BDF4EF282306 /* Views */, ); path = ListlessiOS; @@ -197,14 +233,6 @@ D12ECC901ABED96B86CC85B5 /* Views */ = { isa = PBXGroup; children = ( - 0F2BB7ABCABA4EB67180A200 /* ClickableTextField.swift */, - D41D6E0CED14D79F31C45062 /* HoverCursorModifier.swift */, - B4D74AE2501F35974F57D21F /* PlatformScrollIndicatorsModifier.swift */, - EF6B0195EA0176B72DE9B092 /* PlatformTextFieldWidthModifier.swift */, - A7AF738EEB6ABDF86B0B781F /* TaskListView+NavigationHeader.swift */, - 95BF73E1B8FE337B76D3E757 /* TaskListView+PullToCreate.swift */, - 431B0EA72D590AF0CC868515 /* TaskListView+Toolbar.swift */, - 138612F350ECE29526F689B9 /* TaskRowDragGesture.swift */, E06485DBE35B60868E14202A /* TaskRowView.swift */, ); path = Views; @@ -216,6 +244,8 @@ 01E141436176F83594E2F26B /* Info.plist */, 7C73E9D4C42CCABBF0F33543 /* Listless.entitlements */, 1DA467DF2E59BDBE6EEF6A7D /* ListlessMacApp.swift */, + F7279CE4B2501F9F5111A3D8 /* Extensions */, + 42CC0393E2524624AEC54D03 /* Helpers */, D12ECC901ABED96B86CC85B5 /* Views */, ); path = ListlessMac; @@ -265,6 +295,16 @@ path = Tests/Unit; sourceTree = "<group>"; }; + F7279CE4B2501F9F5111A3D8 /* Extensions */ = { + isa = PBXGroup; + children = ( + 3E67FE94ED8E9B248A0C3A97 /* TaskListView+NavigationHeader.swift */, + A741111B4DEB3A7406A95C93 /* TaskListView+PullToCreate.swift */, + 2A440253D045A0896D94ADD0 /* TaskListView+Toolbar.swift */, + ); + path = Extensions; + sourceTree = "<group>"; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -369,21 +409,21 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 5CFFBC8A17E065640857540A /* AccentColor.swift in Sources */, - 83378A0575640417ED41FC25 /* ClickableTextField.swift in Sources */, - 15B71073767FB4766A6BA2BE /* HoverCursorModifier.swift in Sources */, - D8EFF49E9156083D675D47F0 /* KeyboardNavigationModifier.swift in Sources */, + 99D17075DA3F00F52A18BB4D /* AccentColor.swift in Sources */, + DB5FF6C1AA57D4C9BDDD50FD /* ClickableTextField.swift in Sources */, + 882695E7AE463C0F39ACFF3C /* HoverCursorModifier.swift in Sources */, + 172F2DD978988E207610055F /* KeyboardNavigationModifier.swift in Sources */, 96617677059FABDBB80D642B /* Listless.xcdatamodeld in Sources */, 614FCCA450EC0BFFD8B40640 /* ListlessMacApp.swift in Sources */, A0AA8FD4C542E9AEB2437BC2 /* PersistenceController.swift in Sources */, - A8D13DD6196772ECA146CF2A /* PlatformScrollIndicatorsModifier.swift in Sources */, - D878CD3A552C6A9685A30AA8 /* PlatformTextFieldWidthModifier.swift in Sources */, + 5761B201BF46FCA9C5C98CEF /* PlatformScrollIndicatorsModifier.swift in Sources */, + DEE187A790A4058FE4AFDB2E /* PlatformTextFieldWidthModifier.swift in Sources */, C1FE091454864C4BBBBEB077 /* TaskItem.swift in Sources */, - AB5D681B98FC1D2FD7BF2FAA /* TaskListView+NavigationHeader.swift in Sources */, - 3FCEFB586D9A9085A201AE7D /* TaskListView+PullToCreate.swift in Sources */, - 074A81D9DAF58E8E088CBC89 /* TaskListView+Toolbar.swift in Sources */, + CAC6A40C680611163775CDFE /* TaskListView+NavigationHeader.swift in Sources */, + 2374527E3629DD87AE3D2761 /* TaskListView+PullToCreate.swift in Sources */, + 322EE74CBED492693B92AD59 /* TaskListView+Toolbar.swift in Sources */, 8E08F4C82D6F9E67667CB20A /* TaskListView.swift in Sources */, - C1BD61D6DFA687E9CB9ACA60 /* TaskRowDragGesture.swift in Sources */, + 5035EC4C7518A5FF9AD454CA /* TaskRowDragGesture.swift in Sources */, 3D1F551A03B97ECF4E3DC8B0 /* TaskRowView.swift in Sources */, 3ABE52A15C2059D8D5570528 /* TaskStore.swift in Sources */, ); @@ -393,24 +433,24 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 1E3857004DDD888BB4A6ED50 /* AccentColor.swift in Sources */, - 731477635D3F4DFF1F78D673 /* AppColors.swift in Sources */, - FEC96DAA2BF8BB5D6D8504EB /* HoverCursorModifier.swift in Sources */, - 6C252050E62AED3A0A684EBF /* KeyboardNavigationModifier.swift in Sources */, + BA6953E0EFE6F8255F05A3FD /* AccentColor.swift in Sources */, + 5D3EE9526DA269EE9EE3AB52 /* AppColors.swift in Sources */, + E60F81C7B930AACE67746759 /* HoverCursorModifier.swift in Sources */, + F6587B84ECC6BFE92A5FB493 /* KeyboardNavigationModifier.swift in Sources */, 5B60B409CE4BA668DB30A65D /* Listless.xcdatamodeld in Sources */, F0B2B806BD84A4F2FDF8E038 /* ListlessiOSApp.swift in Sources */, DC73A39A269AB495BCE1AC48 /* PersistenceController.swift in Sources */, - 8E680EC18B0339931E785F0C /* PlatformScrollIndicatorsModifier.swift in Sources */, - DC71C45D92524C3893BF9FDB /* PlatformTextFieldWidthModifier.swift in Sources */, + 763363F6F3C7D2D3C9A63977 /* PlatformScrollIndicatorsModifier.swift in Sources */, + 77FE96F070B1F7FE31A9CE51 /* PlatformTextFieldWidthModifier.swift in Sources */, E47136CA7428927395D8C7C7 /* PullToCreate.swift in Sources */, - E3C6D4AE5E729D34086F132D /* TappableTextField.swift in Sources */, + 0F12D56A528FCBF8A67864CB /* TappableTextField.swift in Sources */, 0ACA67F6578EFF181EE5C9A7 /* TaskItem.swift in Sources */, - 907478D07E5CCD4966579306 /* TaskListView+NavigationHeader.swift in Sources */, - 92932AEC64B65AD6F95A1E42 /* TaskListView+PullToCreate.swift in Sources */, - D9DA553485AD0CA28D5BE0C8 /* TaskListView+Toolbar.swift in Sources */, + 26F609518DE1055DF09B0159 /* TaskListView+NavigationHeader.swift in Sources */, + 80BCA654FE694D8A77D9BF93 /* TaskListView+PullToCreate.swift in Sources */, + 77970A2323A598E830D95301 /* TaskListView+Toolbar.swift in Sources */, 42E4CDE1D17463554CC4F41F /* TaskListView.swift in Sources */, - D6B3DBDD6A3F0A6E166CFFD5 /* TaskRowDragGesture.swift in Sources */, - 7B2CD636BA3B63A586F93E31 /* TaskRowSwipeGesture.swift in Sources */, + 2C53717A0EAFA39240615C9A /* TaskRowDragGesture.swift in Sources */, + 182D9FB61E3A3650D7D83D2A /* TaskRowSwipeGesture.swift in Sources */, 7E366008FF9335A77FE1636D /* TaskRowView.swift in Sources */, 91EDF52C7C5C0B35E9D8B51E /* TaskStore.swift in Sources */, ); diff --git a/Listless/Views/AccentColor.swift b/Listless/Helpers/AccentColor.swift diff --git a/Listless/Views/KeyboardNavigationModifier.swift b/Listless/Helpers/KeyboardNavigationModifier.swift diff --git a/Listless/Views/TaskListView.swift b/Listless/Views/TaskListView.swift @@ -22,18 +22,12 @@ struct TaskListView: View { @State var selectedTaskID: UUID? @State private var refreshID = UUID() @State private var draggedTaskID: UUID? - @State private var swipingTaskID: UUID? @State private var visualOrder: [UUID]? @State private var pendingFocus: FocusField? @State var pullOffset: CGFloat = 0 #if os(iOS) - private struct IOSDragState { - let taskID: UUID - var fingerPosition: CGPoint - } - @State private var iosDragState: IOSDragState? = nil + @State private var isDragging: Bool = false @State private var rowFrames: [UUID: CGRect] = [:] - @State private var scrollViewMinY: CGFloat = 0 #endif init(store: TaskStore = TaskStore()) { @@ -48,39 +42,34 @@ struct TaskListView: View { ForEach(Array(displayActiveTasks.enumerated()), id: \.element.id) { index, task in let taskID = task.id #if os(iOS) - Group { - if iosDragState?.taskID == taskID { - // Ghost spacer: placeholder silhouette while card is floating - UnevenRoundedRectangle( - topLeadingRadius: 0, bottomLeadingRadius: 0, - bottomTrailingRadius: 14, topTrailingRadius: 14 - ) - .fill(Color.taskCard.opacity(0.4)) - .frame(height: rowFrames[taskID]?.height ?? 56) - } else { - TaskRowView( - task: task, - taskID: taskID, - index: index, - totalTasks: displayActiveTasks.count, - isSelected: selectedTaskID == taskID, - focusedField: $focusedField, - onToggle: { toggleCompletion($0) }, - onTitleChange: { updateTitle($0, $1) }, - onDelete: { deleteTask($0) }, - onSelect: { selectTask($0) }, - onStartEdit: { startEditing($0) }, - onEndEdit: { endEditing($0, shouldCreateNewTask: $1) } - ) - .taskDragGesture( - isActive: !task.isCompleted && swipingTaskID == nil, - taskID: taskID, - onDragStart: { startDrag(taskID: taskID) }, - onDragChanged: { point in handleIOSDragChanged(taskID: taskID, point: point) }, - onDragEnded: { commitIOSDrag() } - ) - } - } + 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.03 : 1.0) + .shadow( + color: draggedTaskID == taskID ? .black.opacity(0.15) : .clear, + radius: 8, y: 2 + ) + .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 @@ -102,7 +91,7 @@ struct TaskListView: View { onEndEdit: { endEditing($0, shouldCreateNewTask: $1) } ) .taskDragGesture( - isActive: !task.isCompleted && swipingTaskID == nil, + isActive: !task.isCompleted, taskID: task.id, onDragStart: { startDrag(taskID: task.id) } ) @@ -192,50 +181,10 @@ struct TaskListView: View { #endif } #if os(iOS) + .scrollDisabled(draggedTaskID != nil) .background { Color.outerBackground.ignoresSafeArea() } - .overlay(alignment: .topLeading) { - if let state = iosDragState, - let task = activeTasks.first(where: { $0.id == state.taskID }), - let frame = rowFrames[state.taskID] { - HStack(spacing: 0) { - Rectangle() - .fill( - taskColor( - forIndex: visualOrder?.firstIndex(of: state.taskID) ?? 0, - total: displayActiveTasks.count - ) - ) - .frame(width: 8) - Text(task.title.isEmpty ? "New task" : task.title) - .font(.body) - .frame(maxWidth: .infinity, alignment: .leading) - .padding() - .background(Color.taskCard) - } - .clipShape( - UnevenRoundedRectangle( - topLeadingRadius: 0, bottomLeadingRadius: 0, - bottomTrailingRadius: 14, topTrailingRadius: 14 - ) - ) - .shadow(color: .black.opacity(0.18), radius: 10, y: 4) - .frame(width: frame.width, height: frame.height) - .position( - x: UIScreen.main.bounds.midX, - y: state.fingerPosition.y - scrollViewMinY - ) - .ignoresSafeArea() - .allowsHitTesting(false) - .zIndex(100) - } - } - .onGeometryChange(for: CGFloat.self) { proxy in - proxy.frame(in: .global).minY - } action: { minY in - scrollViewMinY = minY - } .onScrollGeometryChange(for: CGFloat.self) { geo in max(0, -(geo.contentOffset.y + geo.contentInsets.top)) } action: { _, pullDistance in @@ -446,10 +395,6 @@ struct TaskListView: View { deleteTask(task) } - private func handleSwipeActiveChanged(_ taskID: UUID, _ isActive: Bool) { - swipingTaskID = isActive ? taskID : nil - } - private func selectTask(_ taskID: UUID) { selectedTaskID = taskID } @@ -631,10 +576,13 @@ struct TaskListView: View { // MARK: - Drag and Drop private func startDrag(taskID: UUID) { + guard draggedTaskID == nil else { return } draggedTaskID = taskID visualOrder = activeTasks.map(\.id) #if os(iOS) - iosDragState = IOSDragState(taskID: taskID, fingerPosition: .zero) + isDragging = true + let generator = UIImpactFeedbackGenerator(style: .medium) + generator.impactOccurred() #endif } @@ -730,45 +678,45 @@ struct TaskListView: View { // MARK: - iOS Drag Helpers #if os(iOS) - private func dropIndexForY(_ y: CGFloat) -> Int { - let midpoints = displayActiveTasks.enumerated().compactMap { i, task -> (Int, CGFloat)? in - guard let frame = rowFrames[task.id] else { return nil } - return (i, frame.midY) - } - return midpoints.first(where: { $0.1 > y })?.0 ?? displayActiveTasks.count - } + private func handleIOSDragChanged(taskID: UUID, point: CGPoint) { + guard let draggedID = draggedTaskID, + var order = visualOrder, + let currentIndex = order.firstIndex(of: draggedID), + let draggedFrame = rowFrames[draggedID] else { return } - private func updateVisualOrderToIndex(_ index: Int) { - guard let draggedID = draggedTaskID, let order = visualOrder else { return } - var newOrder = order.filter { $0 != draggedID } - let clamped = min(max(0, index), newOrder.count) - newOrder.insert(draggedID, at: clamped) - if newOrder != visualOrder { + 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 = newOrder + visualOrder = order } + return } - } - private func handleIOSDragChanged(taskID: UUID, point: CGPoint) { - iosDragState?.fingerPosition = point - let index = dropIndexForY(point.y) - updateVisualOrderToIndex(index) + // 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 state = iosDragState, + guard let draggedID = draggedTaskID, let order = visualOrder, - let finalIndex = order.firstIndex(of: state.taskID) else { - iosDragState = nil + let finalIndex = order.firstIndex(of: draggedID) else { draggedTaskID = nil visualOrder = nil + isDragging = false return } - store.moveTask(taskID: state.taskID, toIndex: finalIndex) - iosDragState = nil + store.moveTask(taskID: draggedID, toIndex: finalIndex) draggedTaskID = nil visualOrder = nil + isDragging = false } #endif diff --git a/ListlessMac/Views/TaskListView+NavigationHeader.swift b/ListlessMac/Extensions/TaskListView+NavigationHeader.swift diff --git a/ListlessMac/Views/TaskListView+PullToCreate.swift b/ListlessMac/Extensions/TaskListView+PullToCreate.swift diff --git a/ListlessMac/Views/TaskListView+Toolbar.swift b/ListlessMac/Extensions/TaskListView+Toolbar.swift diff --git a/ListlessMac/Views/ClickableTextField.swift b/ListlessMac/Helpers/ClickableTextField.swift diff --git a/ListlessMac/Views/HoverCursorModifier.swift b/ListlessMac/Helpers/HoverCursorModifier.swift diff --git a/ListlessMac/Views/PlatformScrollIndicatorsModifier.swift b/ListlessMac/Helpers/PlatformScrollIndicatorsModifier.swift diff --git a/ListlessMac/Views/PlatformTextFieldWidthModifier.swift b/ListlessMac/Helpers/PlatformTextFieldWidthModifier.swift diff --git a/ListlessMac/Views/TaskRowDragGesture.swift b/ListlessMac/Helpers/TaskRowDragGesture.swift diff --git a/ListlessMac/Views/TaskRowView.swift b/ListlessMac/Views/TaskRowView.swift @@ -6,6 +6,7 @@ struct TaskRowView: View { let index: Int let totalTasks: Int let isSelected: Bool + let isDragging: Bool let isEditing: Bool let onToggle: (TaskItem) -> Void let onTitleChange: (TaskItem, String) -> Void @@ -38,6 +39,7 @@ struct TaskRowView: View { index: Int = 0, totalTasks: Int = 1, isSelected: Bool, + isDragging: Bool = false, isEditing: Bool = false, focusedField: FocusState<TaskListView.FocusField?>.Binding, onToggle: @escaping (TaskItem) -> Void, @@ -52,6 +54,7 @@ struct TaskRowView: View { self.index = index self.totalTasks = totalTasks self.isSelected = isSelected + self.isDragging = isDragging self.isEditing = isEditing self.onToggle = onToggle self.onTitleChange = onTitleChange diff --git a/ListlessiOS/Views/TaskListView+NavigationHeader.swift b/ListlessiOS/Extensions/TaskListView+NavigationHeader.swift diff --git a/ListlessiOS/Views/TaskListView+PullToCreate.swift b/ListlessiOS/Extensions/TaskListView+PullToCreate.swift diff --git a/ListlessiOS/Views/TaskListView+Toolbar.swift b/ListlessiOS/Extensions/TaskListView+Toolbar.swift diff --git a/ListlessiOS/Views/AppColors.swift b/ListlessiOS/Helpers/AppColors.swift diff --git a/ListlessiOS/Views/HoverCursorModifier.swift b/ListlessiOS/Helpers/HoverCursorModifier.swift diff --git a/ListlessiOS/Views/PlatformScrollIndicatorsModifier.swift b/ListlessiOS/Helpers/PlatformScrollIndicatorsModifier.swift diff --git a/ListlessiOS/Views/PlatformTextFieldWidthModifier.swift b/ListlessiOS/Helpers/PlatformTextFieldWidthModifier.swift diff --git a/ListlessiOS/Views/TappableTextField.swift b/ListlessiOS/Helpers/TappableTextField.swift diff --git a/ListlessiOS/Helpers/TaskRowDragGesture.swift b/ListlessiOS/Helpers/TaskRowDragGesture.swift @@ -0,0 +1,54 @@ +import SwiftUI + +extension View { + func taskDragGesture( + isActive: Bool, + taskID: UUID, + onDragStart: @escaping () -> Void, + onDragChanged: @escaping (CGPoint) -> Void, + onDragEnded: @escaping () -> Void + ) -> some View { + self.modifier( + TaskRowDragGesture( + isActive: isActive, + taskID: taskID, + onDragStart: onDragStart, + onDragChanged: onDragChanged, + onDragEnded: onDragEnded + )) + } +} + +struct TaskRowDragGesture: ViewModifier { + let isActive: Bool + let taskID: UUID + let onDragStart: () -> Void + let onDragChanged: (CGPoint) -> Void + let onDragEnded: () -> Void + + func body(content: Content) -> some View { + content + .simultaneousGesture( + LongPressGesture(minimumDuration: 0.4) + .sequenced(before: DragGesture(minimumDistance: 0, coordinateSpace: .global)) + .onChanged { value in + switch value { + case .second(true, let drag): + if let drag { + // onDragStart is idempotent (guarded in TaskListView); + // wait for a real drag value so the overlay has a valid + // position from the very first frame. + onDragStart() + onDragChanged(drag.location) + } + default: + break + } + } + .onEnded { _ in + onDragEnded() + }, + including: isActive ? .all : .none + ) + } +} diff --git a/ListlessiOS/Helpers/TaskRowSwipeGesture.swift b/ListlessiOS/Helpers/TaskRowSwipeGesture.swift @@ -0,0 +1,238 @@ +import SwiftUI +import UIKit + +extension View { + func taskSwipeGesture( + isActive: Bool, + isEditing: Bool, + isDragging: Binding<Bool>, + swipeOffset: Binding<CGFloat>, + swipeDirection: Binding<TaskRowSwipeGesture.SwipeDirection>, + isTriggered: Binding<Bool>, + completeColor: Color = .green, + onComplete: @escaping () -> Void, + onDelete: @escaping () -> Void + ) -> some View { + self.modifier( + TaskRowSwipeGesture( + isActive: isActive, + isEditing: isEditing, + isDragging: isDragging, + swipeOffset: swipeOffset, + swipeDirection: swipeDirection, + isTriggered: isTriggered, + completeColor: completeColor, + onComplete: onComplete, + onDelete: onDelete + )) + } +} + +struct TaskRowSwipeGesture: ViewModifier { + let isActive: Bool + let isEditing: Bool + @Binding var isDragging: Bool + @Binding var swipeOffset: CGFloat + @Binding var swipeDirection: SwipeDirection + @Binding var isTriggered: Bool + let completeColor: Color + let onComplete: () -> Void + let onDelete: () -> Void + + enum SwipeDirection: Equatable { + case left + case right + case none + } + + private let completeThreshold: CGFloat = 40 // Pixels to swipe right before triggering complete + private let deleteThreshold: CGFloat = 80 // Pixels to swipe left before triggering delete + private let horizontalBufferPt: CGFloat = 10 // Horizontal movement must exceed vertical by this amount + private let offsetDamping: CGFloat = 0.9 // Damping factor for responsive feel + + func body(content: Content) -> some View { + ZStack(alignment: .leading) { + // Background stays in place + swipeBackground + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) + .allowsHitTesting(false) + + // Only the content moves + content + .offset(x: swipeOffset) + .animation(.spring(response: 0.3, dampingFraction: 0.8), value: swipeOffset) + .contentShape(Rectangle()) + } + .gesture( + SwipePanGesture( + isEnabled: isActive && !isDragging, + onChanged: { translation in + guard isActive, !isEditing, !isDragging else { return } + handleDragChanged( + horizontalTranslation: translation.x, + verticalTranslation: abs(translation.y) + ) + }, + onEnded: { + handleDragEnded() + } + ) + ) + .onDisappear { + resetSwipeState() + } + } + + @ViewBuilder + private var swipeBackground: some View { + if swipeDirection == .right { + // Complete action — accent color background + completeColor.opacity(backgroundOpacity(offset: swipeOffset)) + } else if swipeDirection == .left { + // Delete action (red background, trash icon) + Color.red.opacity(backgroundOpacity(offset: swipeOffset)) + .overlay { + HStack { + Spacer() + Image(systemName: "trash.fill") + .font(.system(size: 24)) + .foregroundStyle(isTriggered ? .black : .white) + .padding(.trailing, 20) + } + } + } + } + + private func handleDragChanged(horizontalTranslation: CGFloat, verticalTranslation: CGFloat) { + // Require horizontal > vertical + buffer to activate swipe + guard abs(horizontalTranslation) > verticalTranslation + horizontalBufferPt else { + return + } + + if horizontalTranslation > 0 { + swipeDirection = .right + } else if horizontalTranslation < 0 { + swipeDirection = .left + } + + // Update offset with damping + swipeOffset = horizontalTranslation * offsetDamping + + // Track whether threshold is currently crossed — reversible until release + if swipeDirection == .right { + isTriggered = swipeOffset >= completeThreshold + } else if swipeDirection == .left { + isTriggered = abs(swipeOffset) >= deleteThreshold + } + } + + private func handleDragEnded() { + guard !isDragging else { + // A drag-reorder started during or after this swipe — spring back, no action. + withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { + resetSwipeState() + } + return + } + if isTriggered { + if swipeDirection == .right { + // Complete: spring back and let SwiftUI animate the row to the completed section + triggerAction(action: onComplete) + withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { + resetSwipeState() + } + } else { + // Delete: slide off screen + triggerAction(action: onDelete) + withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { + swipeOffset = -400 + } + } + } else { + // Released below threshold — spring back with no action + withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { + resetSwipeState() + } + } + } + + private func triggerAction(action: @escaping () -> Void) { + isTriggered = true + + // Trigger haptic feedback + let generator = UIImpactFeedbackGenerator(style: .medium) + generator.impactOccurred() + + // Execute action after a brief delay to show visual feedback + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + action() + } + } + + private func resetSwipeState() { + swipeOffset = 0 + swipeDirection = .none + isTriggered = false + } + + private func backgroundOpacity(offset: CGFloat) -> CGFloat { + let threshold = offset >= 0 ? completeThreshold : deleteThreshold + return min(abs(offset) / threshold, 1.0) + } +} + +// MARK: - UIKit Pan Gesture via UIGestureRecognizerRepresentable + +/// A UIPanGestureRecognizer bridged into SwiftUI. Each row gets its own +/// recognizer; SwiftUI manages the lifecycle automatically — no manual +/// UIView host-finding or marker-based hit-testing needed. +private struct SwipePanGesture: UIGestureRecognizerRepresentable { + let isEnabled: Bool + let onChanged: (CGPoint) -> Void + let onEnded: () -> Void + + func makeUIGestureRecognizer(context: Context) -> UIPanGestureRecognizer { + let pan = UIPanGestureRecognizer() + pan.cancelsTouchesInView = false + pan.delaysTouchesBegan = false + pan.maximumNumberOfTouches = 1 + pan.delegate = context.coordinator + return pan + } + + func updateUIGestureRecognizer(_ recognizer: UIPanGestureRecognizer, context: Context) { + context.coordinator.isEnabled = isEnabled + } + + func handleUIGestureRecognizerAction( + _ recognizer: UIPanGestureRecognizer, context: Context + ) { + switch recognizer.state { + case .began, .changed: + onChanged(recognizer.translation(in: recognizer.view)) + case .ended, .cancelled, .failed: + onEnded() + default: + break + } + } + + func makeCoordinator(converter: CoordinateSpaceConverter) -> Coordinator { + Coordinator() + } + + final class Coordinator: NSObject, UIGestureRecognizerDelegate { + var isEnabled: Bool = true + + func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { + isEnabled + } + + func gestureRecognizer( + _ gestureRecognizer: UIGestureRecognizer, + shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer + ) -> Bool { + true + } + } +} diff --git a/ListlessiOS/Views/TaskRowDragGesture.swift b/ListlessiOS/Views/TaskRowDragGesture.swift @@ -1,108 +0,0 @@ -import SwiftUI -import UIKit - -extension View { - func taskDragGesture( - isActive: Bool, - taskID: UUID, - onDragStart: @escaping () -> Void, - onDragChanged: @escaping (CGPoint) -> Void, - onDragEnded: @escaping () -> Void - ) -> some View { - self.modifier( - TaskRowDragGesture( - isActive: isActive, - taskID: taskID, - onDragStart: onDragStart, - onDragChanged: onDragChanged, - onDragEnded: onDragEnded - )) - } -} - -struct TaskRowDragGesture: ViewModifier { - let isActive: Bool - let taskID: UUID - let onDragStart: () -> Void - let onDragChanged: (CGPoint) -> Void - let onDragEnded: () -> Void - - func body(content: Content) -> some View { - content - .gesture( - LongPressDragGesture( - isActive: isActive, - onDragStart: onDragStart, - onDragChanged: onDragChanged, - onDragEnded: onDragEnded - ) - ) - } -} - -// MARK: - UIKit Long Press + Drag via UIGestureRecognizerRepresentable - -/// A UILongPressGestureRecognizer bridged into SwiftUI. Fires onDragStart after the -/// minimum hold duration, then tracks finger movement via onDragChanged (window coords), -/// and calls onDragEnded when the touch ends or is cancelled. -private struct LongPressDragGesture: UIGestureRecognizerRepresentable { - let isActive: Bool - let onDragStart: () -> Void - let onDragChanged: (CGPoint) -> Void - let onDragEnded: () -> Void - - func makeUIGestureRecognizer(context: Context) -> UILongPressGestureRecognizer { - let recognizer = UILongPressGestureRecognizer() - recognizer.minimumPressDuration = 0.4 - recognizer.allowableMovement = .infinity - recognizer.cancelsTouchesInView = false - recognizer.delegate = context.coordinator - return recognizer - } - - func updateUIGestureRecognizer(_ recognizer: UILongPressGestureRecognizer, context: Context) { - recognizer.isEnabled = isActive - } - - func handleUIGestureRecognizerAction( - _ recognizer: UILongPressGestureRecognizer, context: Context - ) { - switch recognizer.state { - case .began: - onDragStart() - if let window = recognizer.view?.window { - onDragChanged(recognizer.location(in: window)) - } - case .changed: - if let window = recognizer.view?.window { - onDragChanged(recognizer.location(in: window)) - } - case .ended, .cancelled, .failed: - onDragEnded() - default: - break - } - } - - func makeCoordinator(converter: CoordinateSpaceConverter) -> Coordinator { - Coordinator() - } - - final class Coordinator: NSObject, UIGestureRecognizerDelegate { - func gestureRecognizer( - _ gestureRecognizer: UIGestureRecognizer, - shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer - ) -> Bool { - // Allow simultaneous recognition with the scroll view's pan gesture - if let pan = otherGestureRecognizer as? UIPanGestureRecognizer, - pan.view is UIScrollView { - return true - } - // Prevent simultaneous recognition with the swipe UIPanGestureRecognizer - if otherGestureRecognizer is UIPanGestureRecognizer { - return false - } - return true - } - } -} diff --git a/ListlessiOS/Views/TaskRowSwipeGesture.swift b/ListlessiOS/Views/TaskRowSwipeGesture.swift @@ -1,231 +0,0 @@ -import SwiftUI -import UIKit - -extension View { - func taskSwipeGesture( - isActive: Bool, - isEditing: Bool, - isDragging: Bool, - swipeOffset: Binding<CGFloat>, - swipeDirection: Binding<TaskRowSwipeGesture.SwipeDirection>, - isTriggered: Binding<Bool>, - completeColor: Color = .green, - onComplete: @escaping () -> Void, - onDelete: @escaping () -> Void, - onSwipeActiveChanged: @escaping (Bool) -> Void = { _ in } - ) -> some View { - self.modifier( - TaskRowSwipeGesture( - isActive: isActive, - isEditing: isEditing, - isDragging: isDragging, - swipeOffset: swipeOffset, - swipeDirection: swipeDirection, - isTriggered: isTriggered, - completeColor: completeColor, - onComplete: onComplete, - onDelete: onDelete, - onSwipeActiveChanged: onSwipeActiveChanged - )) - } -} - -struct TaskRowSwipeGesture: ViewModifier { - let isActive: Bool - let isEditing: Bool - let isDragging: Bool - @Binding var swipeOffset: CGFloat - @Binding var swipeDirection: SwipeDirection - @Binding var isTriggered: Bool - let completeColor: Color - let onComplete: () -> Void - let onDelete: () -> Void - let onSwipeActiveChanged: (Bool) -> Void - - enum SwipeDirection: Equatable { - case left - case right - case none - } - - private let completeThreshold: CGFloat = 40 // Pixels to swipe right before triggering complete - private let deleteThreshold: CGFloat = 80 // Pixels to swipe left before triggering delete - private let horizontalBufferPt: CGFloat = 10 // Horizontal movement must exceed vertical by this amount - private let offsetDamping: CGFloat = 0.9 // Damping factor for responsive feel - - func body(content: Content) -> some View { - ZStack(alignment: .leading) { - // Background stays in place - swipeBackground - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) - .allowsHitTesting(false) - - // Only the content moves - content - .offset(x: swipeOffset) - .animation(.spring(response: 0.3, dampingFraction: 0.8), value: swipeOffset) - .contentShape(Rectangle()) - } - .gesture( - SwipePanGesture( - onChanged: { translation in - guard isActive, !isEditing, !isDragging else { return } - handleDragChanged( - horizontalTranslation: translation.x, - verticalTranslation: abs(translation.y) - ) - }, - onEnded: { - handleDragEnded() - } - ) - ) - .onDisappear { - resetSwipeState() - } - } - - @ViewBuilder - private var swipeBackground: some View { - if swipeDirection == .right { - // Complete action — accent color background - completeColor.opacity(backgroundOpacity(offset: swipeOffset)) - } else if swipeDirection == .left { - // Delete action (red background, trash icon) - Color.red.opacity(backgroundOpacity(offset: swipeOffset)) - .overlay { - HStack { - Spacer() - Image(systemName: "trash.fill") - .font(.system(size: 24)) - .foregroundStyle(isTriggered ? .black : .white) - .padding(.trailing, 20) - } - } - } - } - - private func handleDragChanged(horizontalTranslation: CGFloat, verticalTranslation: CGFloat) { - // Require horizontal > vertical + buffer to activate swipe - guard abs(horizontalTranslation) > verticalTranslation + horizontalBufferPt else { - return - } - - // Determine direction and notify that swipe is active - if swipeDirection == .none { - onSwipeActiveChanged(true) - } - - if horizontalTranslation > 0 { - swipeDirection = .right - } else if horizontalTranslation < 0 { - swipeDirection = .left - } - - // Update offset with damping - swipeOffset = horizontalTranslation * offsetDamping - - // Track whether threshold is currently crossed — reversible until release - if swipeDirection == .right { - isTriggered = swipeOffset >= completeThreshold - } else if swipeDirection == .left { - isTriggered = abs(swipeOffset) >= deleteThreshold - } - } - - private func handleDragEnded() { - if isTriggered { - if swipeDirection == .right { - // Complete: spring back and let SwiftUI animate the row to the completed section - triggerAction(action: onComplete) - withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { - resetSwipeState() - } - } else { - // Delete: slide off screen - triggerAction(action: onDelete) - withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { - swipeOffset = -400 - } - } - } else { - // Released below threshold — spring back with no action - withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { - resetSwipeState() - } - } - } - - private func triggerAction(action: @escaping () -> Void) { - isTriggered = true - - // Trigger haptic feedback - let generator = UIImpactFeedbackGenerator(style: .medium) - generator.impactOccurred() - - // Execute action after a brief delay to show visual feedback - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - action() - } - } - - private func resetSwipeState() { - if swipeDirection != .none { - onSwipeActiveChanged(false) - } - swipeOffset = 0 - swipeDirection = .none - isTriggered = false - } - - private func backgroundOpacity(offset: CGFloat) -> CGFloat { - let threshold = offset >= 0 ? completeThreshold : deleteThreshold - return min(abs(offset) / threshold, 1.0) - } -} - -// MARK: - UIKit Pan Gesture via UIGestureRecognizerRepresentable - -/// A UIPanGestureRecognizer bridged into SwiftUI. Each row gets its own -/// recognizer; SwiftUI manages the lifecycle automatically — no manual -/// UIView host-finding or marker-based hit-testing needed. -private struct SwipePanGesture: UIGestureRecognizerRepresentable { - let onChanged: (CGPoint) -> Void - let onEnded: () -> Void - - func makeUIGestureRecognizer(context: Context) -> UIPanGestureRecognizer { - let pan = UIPanGestureRecognizer() - pan.cancelsTouchesInView = false - pan.delaysTouchesBegan = false - pan.maximumNumberOfTouches = 1 - pan.delegate = context.coordinator - return pan - } - - func handleUIGestureRecognizerAction( - _ recognizer: UIPanGestureRecognizer, context: Context - ) { - switch recognizer.state { - case .began, .changed: - onChanged(recognizer.translation(in: recognizer.view)) - case .ended, .cancelled, .failed: - onEnded() - default: - break - } - } - - func makeCoordinator(converter: CoordinateSpaceConverter) -> Coordinator { - Coordinator() - } - - final class Coordinator: NSObject, UIGestureRecognizerDelegate { - func gestureRecognizer( - _ gestureRecognizer: UIGestureRecognizer, - shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer - ) -> Bool { - true - } - } -} - diff --git a/ListlessiOS/Views/TaskRowView.swift b/ListlessiOS/Views/TaskRowView.swift @@ -6,6 +6,7 @@ struct TaskRowView: View { let index: Int let totalTasks: Int let isSelected: Bool + @Binding var isDragging: Bool let onToggle: (TaskItem) -> Void let onTitleChange: (TaskItem, String) -> Void let onDelete: (TaskItem) -> Void @@ -27,6 +28,7 @@ struct TaskRowView: View { index: Int = 0, totalTasks: Int = 1, isSelected: Bool, + isDragging: Binding<Bool> = .constant(false), isEditing: Bool = false, focusedField: FocusState<TaskListView.FocusField?>.Binding, onToggle: @escaping (TaskItem) -> Void, @@ -41,6 +43,7 @@ struct TaskRowView: View { self.index = index self.totalTasks = totalTasks self.isSelected = isSelected + _isDragging = isDragging self.onToggle = onToggle self.onTitleChange = onTitleChange self.onDelete = onDelete @@ -142,10 +145,17 @@ struct TaskRowView: View { .onChange(of: totalTasks) { _, _ in cachedAccentColor = computeAccentColor() } + .onChange(of: isDragging) { _, dragging in + if dragging { + swipeOffset = 0 + swipeDirection = .none + isSwipeTriggered = false + } + } .taskSwipeGesture( isActive: true, isEditing: isCurrentlyEditing, - isDragging: false, + isDragging: $isDragging, swipeOffset: $swipeOffset, swipeDirection: $swipeDirection, isTriggered: $isSwipeTriggered,