listless

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

commit 554b423b2765660f82247aa6d88f7ec9fecb9b81
parent 82da65eebdf0472f3a296bd4356b40ede4575f92
Author: Michael Camilleri <[email protected]>
Date:   Mon,  9 Feb 2026 17:47:15 +0900

Fix row alignment issues on iOS

Co-Authored-By: Claude 4.5 Sonnet <[email protected]>

Diffstat:
MListless.xcodeproj/project.pbxproj | 57+++++++++++++++++++++++++--------------------------------
MListless.xcodeproj/xcshareddata/xcschemes/Listless iOS.xcscheme | 16++++++++++++----
MListless.xcodeproj/xcshareddata/xcschemes/Listless macOS.xcscheme | 14++++++++++----
DListless/Views/TaskRowView.swift | 232-------------------------------------------------------------------------------
AListlessMac/Views/TaskRowView.swift | 222+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MListlessiOS/Info.plist | 4++++
MListlessiOS/ListlessiOSApp.swift | 11+++++++++--
DListlessiOS/Views/ClickableTextField.swift | 35-----------------------------------
AListlessiOS/Views/TaskRowView.swift | 181+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
9 files changed, 463 insertions(+), 309 deletions(-)

diff --git a/Listless.xcodeproj/project.pbxproj b/Listless.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 63; + objectVersion = 77; objects = { /* Begin PBXBuildFile section */ @@ -14,24 +14,23 @@ 1AA328A921EF8A7FDD03119A /* TestHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75B048B19C5219862BBED2E7 /* TestHelpers.swift */; }; 269B93D5543770B464DFB37A /* TaskStoreOrderingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D3A2DDCE24E54ABCCFBBD4C /* TaskStoreOrderingTests.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 */; }; 5B60B409CE4BA668DB30A65D /* Listless.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = C093494053E6C348F245D4EC /* Listless.xcdatamodeld */; }; 614FCCA450EC0BFFD8B40640 /* ListlessMacApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DA467DF2E59BDBE6EEF6A7D /* ListlessMacApp.swift */; }; 69D1909CB6D2368CF90065C7 /* ColorExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBDF292392702661DBB94D06 /* ColorExtensions.swift */; }; 6C252050E62AED3A0A684EBF /* KeyboardNavigationModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E7B37EF6A2389656D105FF8 /* KeyboardNavigationModifier.swift */; }; + 7E366008FF9335A77FE1636D /* TaskRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 199CC2F58DD7CBA3F2229366 /* TaskRowView.swift */; }; 83378A0575640417ED41FC25 /* ClickableTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F2BB7ABCABA4EB67180A200 /* ClickableTextField.swift */; }; 8E08F4C82D6F9E67667CB20A /* TaskListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 537A913AC421BAEF60D26D9C /* TaskListView.swift */; }; 8E680EC18B0339931E785F0C /* PlatformScrollIndicatorsModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48AE1AE43296C1692FA6F755 /* PlatformScrollIndicatorsModifier.swift */; }; - 9041B7CED5298439BF7DC2C1 /* TaskRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 007D500D2EFF3E66B780ADE0 /* TaskRowView.swift */; }; 91EDF52C7C5C0B35E9D8B51E /* TaskStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC3DEE364304587D280C5672 /* TaskStore.swift */; }; 96617677059FABDBB80D642B /* Listless.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = C093494053E6C348F245D4EC /* Listless.xcdatamodeld */; }; - 978DABFA617B6EBD6FA179CD /* ClickableTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97A08C589AB65ED2CB26A092 /* ClickableTextField.swift */; }; 99977BFA37FBAAA49AF6B71E /* TaskStoreEdgeCaseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B2BBE01D99CDA278BCB9F49 /* TaskStoreEdgeCaseTests.swift */; }; A0AA8FD4C542E9AEB2437BC2 /* PersistenceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C14858BDFD1FD5119F1F24A6 /* PersistenceController.swift */; }; A8D13DD6196772ECA146CF2A /* PlatformScrollIndicatorsModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4D74AE2501F35974F57D21F /* PlatformScrollIndicatorsModifier.swift */; }; C1BD61D6DFA687E9CB9ACA60 /* TaskRowDragGesture.swift in Sources */ = {isa = PBXBuildFile; fileRef = 138612F350ECE29526F689B9 /* TaskRowDragGesture.swift */; }; C1FE091454864C4BBBBEB077 /* TaskItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBB8A3BEB346267B30B4675F /* TaskItem.swift */; }; - C2400278D5F6F79C85A68897 /* TaskRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 007D500D2EFF3E66B780ADE0 /* TaskRowView.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 */; }; @@ -55,23 +54,23 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ - 007D500D2EFF3E66B780ADE0 /* TaskRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskRowView.swift; sourceTree = "<group>"; }; 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>"; }; - 126108860D7878DDC3BECC4B /* Listless iOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Listless iOS.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 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>"; }; + 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>"; }; 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>"; }; 48AE1AE43296C1692FA6F755 /* PlatformScrollIndicatorsModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlatformScrollIndicatorsModifier.swift; sourceTree = "<group>"; }; - 4FC64B9F9370041BEDBD1E14 /* .gitkeep */ = {isa = PBXFileReference; lastKnownFileType = text; path = .gitkeep; 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>"; }; - 74255E6B6C40899E9B17D927 /* .gitkeep */ = {isa = PBXFileReference; lastKnownFileType = text; path = .gitkeep; 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>"; }; 81880970CCFC2CB00B65047E /* PlatformTextFieldWidthModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlatformTextFieldWidthModifier.swift; sourceTree = "<group>"; }; @@ -79,21 +78,21 @@ 917597025B3D5D18E33982D3 /* ColorExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorExtensions.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; lastKnownFileType = text; path = .gitkeep; sourceTree = "<group>"; }; + 944BAE054AAC1B9C4FC954F9 /* .gitkeep */ = {isa = PBXFileReference; path = .gitkeep; sourceTree = "<group>"; }; 967F7ECEB3915CEDCE584872 /* TaskStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskStoreTests.swift; sourceTree = "<group>"; }; - 97A08C589AB65ED2CB26A092 /* ClickableTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClickableTextField.swift; sourceTree = "<group>"; }; 9B2BBE01D99CDA278BCB9F49 /* TaskStoreEdgeCaseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskStoreEdgeCaseTests.swift; sourceTree = "<group>"; }; 9FBEB58DD41817F09B0EB9F0 /* Listless.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = Listless.xcdatamodel; 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>"; }; BBDF292392702661DBB94D06 /* ColorExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorExtensions.swift; sourceTree = "<group>"; }; C14858BDFD1FD5119F1F24A6 /* PersistenceController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceController.swift; sourceTree = "<group>"; }; - C71466C5CD1A5BA984352F8D /* Listless iOS Unit Tests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "Listless iOS Unit Tests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; - C9B14DC786A336008AAB78EE /* .gitkeep */ = {isa = PBXFileReference; lastKnownFileType = text; path = .gitkeep; sourceTree = "<group>"; }; - D10B5491A53E77C80F8F75CD /* .gitkeep */ = {isa = PBXFileReference; lastKnownFileType = text; path = .gitkeep; sourceTree = "<group>"; }; - D123BB181208FC825777B0A7 /* .gitkeep */ = {isa = PBXFileReference; lastKnownFileType = text; path = .gitkeep; 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>"; }; + 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>"; }; 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>"; }; EF6B0195EA0176B72DE9B092 /* PlatformTextFieldWidthModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlatformTextFieldWidthModifier.swift; sourceTree = "<group>"; }; FBB8A3BEB346267B30B4675F /* TaskItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskItem.swift; sourceTree = "<group>"; }; /* End PBXFileReference section */ @@ -105,7 +104,6 @@ D123BB181208FC825777B0A7 /* .gitkeep */, 0E7B37EF6A2389656D105FF8 /* KeyboardNavigationModifier.swift */, 537A913AC421BAEF60D26D9C /* TaskListView.swift */, - 007D500D2EFF3E66B780ADE0 /* TaskRowView.swift */, ); path = Views; sourceTree = "<group>"; @@ -134,12 +132,12 @@ 58F917D865E0BDF4EF282306 /* Views */ = { isa = PBXGroup; children = ( - 97A08C589AB65ED2CB26A092 /* ClickableTextField.swift */, 82A3509AD32A54434BCC8017 /* HoverCursorModifier.swift */, 48AE1AE43296C1692FA6F755 /* PlatformScrollIndicatorsModifier.swift */, 81880970CCFC2CB00B65047E /* PlatformTextFieldWidthModifier.swift */, 93FF5B9F5B979D54D5DEE192 /* TaskListView+Toolbar.swift */, 1B3D0B4710C7A0EE4F227851 /* TaskRowDragGesture.swift */, + 199CC2F58DD7CBA3F2229366 /* TaskRowView.swift */, ); path = Views; sourceTree = "<group>"; @@ -184,6 +182,7 @@ EF6B0195EA0176B72DE9B092 /* PlatformTextFieldWidthModifier.swift */, 431B0EA72D590AF0CC868515 /* TaskListView+Toolbar.swift */, 138612F350ECE29526F689B9 /* TaskRowDragGesture.swift */, + E06485DBE35B60868E14202A /* TaskRowView.swift */, ); path = Views; sourceTree = "<group>"; @@ -306,7 +305,7 @@ isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = YES; - LastUpgradeCheck = 2620; + LastUpgradeCheck = 1430; TargetAttributes = { 0FB4F07A37999BBC6DFE4DBB = { DevelopmentTeam = 7TD7PZBNXP; @@ -332,6 +331,7 @@ ); mainGroup = ED4862258A8A70025EE14416; minimizedProjectReferenceProxies = 1; + preferredProjectObjectVersion = 77; projectDirPath = ""; projectRoot = ""; targets = ( @@ -360,7 +360,7 @@ 074A81D9DAF58E8E088CBC89 /* TaskListView+Toolbar.swift in Sources */, 8E08F4C82D6F9E67667CB20A /* TaskListView.swift in Sources */, C1BD61D6DFA687E9CB9ACA60 /* TaskRowDragGesture.swift in Sources */, - C2400278D5F6F79C85A68897 /* TaskRowView.swift in Sources */, + 3D1F551A03B97ECF4E3DC8B0 /* TaskRowView.swift in Sources */, 3ABE52A15C2059D8D5570528 /* TaskStore.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -369,7 +369,6 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 978DABFA617B6EBD6FA179CD /* ClickableTextField.swift in Sources */, 69D1909CB6D2368CF90065C7 /* ColorExtensions.swift in Sources */, FEC96DAA2BF8BB5D6D8504EB /* HoverCursorModifier.swift in Sources */, 6C252050E62AED3A0A684EBF /* KeyboardNavigationModifier.swift in Sources */, @@ -382,7 +381,7 @@ D9DA553485AD0CA28D5BE0C8 /* TaskListView+Toolbar.swift in Sources */, 42E4CDE1D17463554CC4F41F /* TaskListView.swift in Sources */, D6B3DBDD6A3F0A6E166CFFD5 /* TaskRowDragGesture.swift in Sources */, - 9041B7CED5298439BF7DC2C1 /* TaskRowView.swift in Sources */, + 7E366008FF9335A77FE1636D /* TaskRowView.swift in Sources */, 91EDF52C7C5C0B35E9D8B51E /* TaskStore.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -417,7 +416,7 @@ CODE_SIGN_ENTITLEMENTS = ListlessMac/Listless.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - DEAD_CODE_STRIPPING = YES; + DEVELOPMENT_TEAM = 7TD7PZBNXP; INFOPLIST_FILE = ListlessMac/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -435,7 +434,7 @@ CODE_SIGN_ENTITLEMENTS = ListlessMac/Listless.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - DEAD_CODE_STRIPPING = YES; + DEVELOPMENT_TEAM = 7TD7PZBNXP; INFOPLIST_FILE = ListlessMac/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -450,7 +449,6 @@ isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; @@ -481,12 +479,9 @@ CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; - DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - DEVELOPMENT_TEAM = 7TD7PZBNXP; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_NO_COMMON_BLOCKS = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; @@ -500,7 +495,6 @@ MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; PRODUCT_NAME = "$(TARGET_NAME)"; - STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_OPTIMIZATION_LEVEL = "-O"; SWIFT_VERSION = 6; @@ -512,6 +506,7 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = 7TD7PZBNXP; GENERATE_INFOPLIST_FILE = YES; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -529,7 +524,6 @@ isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; @@ -560,12 +554,9 @@ CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; - DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; - DEVELOPMENT_TEAM = 7TD7PZBNXP; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; - ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_DYNAMIC_NO_PIC = NO; GCC_NO_COMMON_BLOCKS = YES; @@ -586,7 +577,6 @@ MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; PRODUCT_NAME = "$(TARGET_NAME)"; - STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 6; @@ -600,6 +590,7 @@ CODE_SIGN_ENTITLEMENTS = ListlessiOS/Listless.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = 7TD7PZBNXP; INFOPLIST_FILE = ListlessiOS/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -616,6 +607,7 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = 7TD7PZBNXP; GENERATE_INFOPLIST_FILE = YES; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -636,6 +628,7 @@ CODE_SIGN_ENTITLEMENTS = ListlessiOS/Listless.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = 7TD7PZBNXP; INFOPLIST_FILE = ListlessiOS/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", diff --git a/Listless.xcodeproj/xcshareddata/xcschemes/Listless iOS.xcscheme b/Listless.xcodeproj/xcshareddata/xcschemes/Listless iOS.xcscheme @@ -1,10 +1,11 @@ <?xml version="1.0" encoding="UTF-8"?> <Scheme - LastUpgradeVersion = "2620" - version = "1.3"> + LastUpgradeVersion = "1430" + version = "1.7"> <BuildAction parallelizeBuildables = "YES" - buildImplicitDependencies = "YES"> + buildImplicitDependencies = "YES" + runPostActionsOnFailure = "NO"> <BuildActionEntries> <BuildActionEntry buildForTesting = "YES" @@ -26,7 +27,8 @@ buildConfiguration = "Debug" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" - shouldUseLaunchSchemeArgsEnv = "YES"> + shouldUseLaunchSchemeArgsEnv = "YES" + onlyGenerateCoverageForSpecifiedTargets = "NO"> <MacroExpansion> <BuildableReference BuildableIdentifier = "primary" @@ -50,6 +52,8 @@ </BuildableReference> </TestableReference> </Testables> + <CommandLineArguments> + </CommandLineArguments> </TestAction> <LaunchAction buildConfiguration = "Debug" @@ -71,6 +75,8 @@ ReferencedContainer = "container:Listless.xcodeproj"> </BuildableReference> </BuildableProductRunnable> + <CommandLineArguments> + </CommandLineArguments> </LaunchAction> <ProfileAction buildConfiguration = "Release" @@ -88,6 +94,8 @@ ReferencedContainer = "container:Listless.xcodeproj"> </BuildableReference> </BuildableProductRunnable> + <CommandLineArguments> + </CommandLineArguments> </ProfileAction> <AnalyzeAction buildConfiguration = "Debug"> diff --git a/Listless.xcodeproj/xcshareddata/xcschemes/Listless macOS.xcscheme b/Listless.xcodeproj/xcshareddata/xcschemes/Listless macOS.xcscheme @@ -1,10 +1,11 @@ <?xml version="1.0" encoding="UTF-8"?> <Scheme - LastUpgradeVersion = "2620" - version = "1.3"> + LastUpgradeVersion = "1430" + version = "1.7"> <BuildAction parallelizeBuildables = "YES" - buildImplicitDependencies = "YES"> + buildImplicitDependencies = "YES" + runPostActionsOnFailure = "NO"> <BuildActionEntries> <BuildActionEntry buildForTesting = "YES" @@ -26,7 +27,8 @@ buildConfiguration = "Debug" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" - shouldUseLaunchSchemeArgsEnv = "YES"> + shouldUseLaunchSchemeArgsEnv = "YES" + onlyGenerateCoverageForSpecifiedTargets = "NO"> <MacroExpansion> <BuildableReference BuildableIdentifier = "primary" @@ -59,6 +61,8 @@ ReferencedContainer = "container:Listless.xcodeproj"> </BuildableReference> </BuildableProductRunnable> + <CommandLineArguments> + </CommandLineArguments> </LaunchAction> <ProfileAction buildConfiguration = "Release" @@ -76,6 +80,8 @@ ReferencedContainer = "container:Listless.xcodeproj"> </BuildableReference> </BuildableProductRunnable> + <CommandLineArguments> + </CommandLineArguments> </ProfileAction> <AnalyzeAction buildConfiguration = "Debug"> diff --git a/Listless/Views/TaskRowView.swift b/Listless/Views/TaskRowView.swift @@ -1,232 +0,0 @@ -import SwiftUI - -struct TaskRowView: View { - let task: TaskItem - let taskID: UUID - let index: Int - let totalTasks: Int - let isSelected: Bool - let isEditing: Bool - let onToggle: (TaskItem) -> Void - let onTitleChange: (TaskItem, String) -> Void - let onDelete: (TaskItem) -> Void - let onSelect: (UUID) -> Void - let onStartEdit: (UUID) -> Void - let onEndEdit: (UUID, _ shouldCreateNewTask: Bool) -> Void - @FocusState.Binding var focusedField: TaskListView.FocusField? - - @State private var editingTitle: String = "" - @State private var isCurrentlyEditing: Bool = false - - private let horizontalPadding: CGFloat = 16 - private let checkboxTextSpacing: CGFloat = 12 - @ScaledMetric private var checkboxSize: CGFloat = 20 - - private var dividerInset: CGFloat { - horizontalPadding + checkboxSize + checkboxTextSpacing - } - - private var accentColor: Color { - guard !task.isCompleted else { return .clear } - guard totalTasks > 1 else { return Color(hue: 0.98, saturation: 0.85, brightness: 1.0) } - - // Gradient matches gradient.png: coral/red → pink/magenta → purple/blue - let progress = Double(index) / Double(totalTasks - 1) - - // Define color stops based on the gradient image - let topColor = Color(hue: 0.98, saturation: 0.85, brightness: 1.0) // Coral/red - let midColor = Color(hue: 0.88, saturation: 0.75, brightness: 0.95) // Pink/magenta - let bottomColor = Color(hue: 0.72, saturation: 0.65, brightness: 0.85) // Purple/blue - - // Interpolate between colors - if progress < 0.5 { - // Top half: coral → magenta - let localProgress = progress * 2.0 - return interpolateColor(from: topColor, to: midColor, progress: localProgress) - } else { - // Bottom half: magenta → purple/blue - let localProgress = (progress - 0.5) * 2.0 - return interpolateColor(from: midColor, to: bottomColor, progress: localProgress) - } - } - - private func interpolateColor(from: Color, to: Color, progress: Double) -> Color { - // Extract HSB components and interpolate - let fromHSB = PlatformColor(from).hsba - let toHSB = PlatformColor(to).hsba - - let hue = fromHSB.hue + (toHSB.hue - fromHSB.hue) * progress - let saturation = fromHSB.saturation + (toHSB.saturation - fromHSB.saturation) * progress - let brightness = fromHSB.brightness + (toHSB.brightness - fromHSB.brightness) * progress - - return Color(hue: hue, saturation: saturation, brightness: brightness) - } - - init( - task: TaskItem, - taskID: UUID, - index: Int = 0, - totalTasks: Int = 1, - isSelected: Bool, - isEditing: Bool = false, - focusedField: FocusState<TaskListView.FocusField?>.Binding, - onToggle: @escaping (TaskItem) -> Void, - onTitleChange: @escaping (TaskItem, String) -> Void, - onDelete: @escaping (TaskItem) -> Void, - onSelect: @escaping (UUID) -> Void, - onStartEdit: @escaping (UUID) -> Void = { _ in }, - onEndEdit: @escaping (UUID, _ shouldCreateNewTask: Bool) -> Void = { _, _ in } - ) { - self.task = task - self.taskID = taskID - self.index = index - self.totalTasks = totalTasks - self.isSelected = isSelected - self.isEditing = isEditing - self.onToggle = onToggle - self.onTitleChange = onTitleChange - self.onDelete = onDelete - self.onSelect = onSelect - self.onStartEdit = onStartEdit - self.onEndEdit = onEndEdit - _focusedField = focusedField - } - - var body: some View { - HStack(alignment: .firstTextBaseline, spacing: 12) { - Button { - onToggle(task) - } label: { - Image(systemName: task.isCompleted ? "checkmark.circle.fill" : "circle") - .foregroundStyle(task.isCompleted ? .secondary : .primary) - .font(.system(size: 17)) - .fontWeight(.thin) - } - .buttonStyle(.borderless) - .alignmentGuide(.firstTextBaseline) { d in - d[VerticalAlignment.center] + 5 - } - .accessibilityIdentifier("task-checkbox") - .accessibilityValue(task.isCompleted ? "checkmark.circle.fill" : "circle") - - ClickableTextField( - text: $editingTitle, - isCompleted: task.isCompleted, - onEditingChanged: { editing, shouldCreateNewTask in - isCurrentlyEditing = editing - if editing { - onStartEdit(taskID) - } else { - onEndEdit(taskID, shouldCreateNewTask) - } - } - ) - .focused($focusedField, equals: .task(taskID)) - .frame(maxWidth: .infinity, alignment: .leading) - .accessibilityIdentifier( - isCurrentlyEditing ? "task-textfield" : "task-text-\(task.title)") - } - .padding(.vertical, 8) - .padding(.horizontal, 16) - .frame(maxWidth: .infinity, alignment: .leading) - .contentShape(Rectangle()) - .onTapGesture { - onSelect(taskID) - } - .background(selectionBackground) - .overlay(alignment: .leading) { - // Colored accent bar on the left edge - Rectangle() - .fill(accentColor) - .frame(width: 4) - .padding(.vertical, 1) - } - .overlay(alignment: .bottom) { - // Hairline border between rows, inset to align with text - // Only show for active (non-completed) tasks - if !task.isCompleted { - Rectangle() - .fill(.separator) - .frame(height: 0.5) - .padding(.leading, dividerInset) - } - } - .contextMenu { - Button(task.isCompleted ? "Mark as Incomplete" : "Mark as Complete") { - onToggle(task) - } - Divider() - Button("Cut") { - cutToPasteboard() - } - Button("Copy") { - copyToPasteboard() - } - Button("Paste") { - pasteFromPasteboard() - } - Divider() - Button("Delete", role: .destructive) { - onDelete(task) - } - } - .onChange(of: editingTitle) { - guard !task.isCompleted else { return } - onTitleChange(task, editingTitle) - } - .onChange(of: task.title) { _, newValue in - // Keep editingTitle in sync with task.title when not editing - if !isCurrentlyEditing { - editingTitle = newValue - } - } - .onAppear { - // Initialize editingTitle - editingTitle = task.title - } - } - - private var selectionBackground: some View { - Group { - if isSelected { - RoundedRectangle(cornerRadius: 6, style: .continuous) - .fill(selectionFill) - } else { - Color.clear - } - } - } - - private var selectionFill: Color { - Color.accentColor.opacity(0.2) - } - - private func cutToPasteboard() { - copyToPasteboard() - onDelete(task) - } - - private func copyToPasteboard() { - let text = isEditing ? editingTitle : task.title - guard !text.isEmpty else { return } - #if os(macOS) - let pasteboard = NSPasteboard.general - pasteboard.clearContents() - pasteboard.setString(text, forType: .string) - #else - UIPasteboard.general.string = text - #endif - } - - private func pasteFromPasteboard() { - #if os(macOS) - guard let string = NSPasteboard.general.string(forType: .string) else { return } - #else - guard let string = UIPasteboard.general.string else { return } - #endif - if isEditing { - editingTitle = string - } - onTitleChange(task, string) - } -} diff --git a/ListlessMac/Views/TaskRowView.swift b/ListlessMac/Views/TaskRowView.swift @@ -0,0 +1,222 @@ +import SwiftUI + +struct TaskRowView: View { + let task: TaskItem + let taskID: UUID + let index: Int + let totalTasks: Int + let isSelected: Bool + let isEditing: Bool + let onToggle: (TaskItem) -> Void + let onTitleChange: (TaskItem, String) -> Void + let onDelete: (TaskItem) -> Void + let onSelect: (UUID) -> Void + let onStartEdit: (UUID) -> Void + let onEndEdit: (UUID, _ shouldCreateNewTask: Bool) -> Void + @FocusState.Binding var focusedField: TaskListView.FocusField? + + @State private var editingTitle: String = "" + @State private var isCurrentlyEditing: Bool = false + @State private var cachedAccentColor: Color = .clear + + private let horizontalPadding: CGFloat = 16 + private let checkboxTextSpacing: CGFloat = 12 + @ScaledMetric private var checkboxSize: CGFloat = 20 + + private var dividerInset: CGFloat { + horizontalPadding + checkboxSize + checkboxTextSpacing + } + + private func computeAccentColor() -> Color { + guard !task.isCompleted else { return .clear } + guard totalTasks > 1 else { return Color(hue: 0.98, saturation: 0.85, brightness: 1.0) } + + // Gradient matches gradient.png: coral/red → pink/magenta → purple/blue + let progress = Double(index) / Double(totalTasks - 1) + + // Define color stops based on the gradient image + let topColor = Color(hue: 0.98, saturation: 0.85, brightness: 1.0) // Coral/red + let midColor = Color(hue: 0.88, saturation: 0.75, brightness: 0.95) // Pink/magenta + let bottomColor = Color(hue: 0.72, saturation: 0.65, brightness: 0.85) // Purple/blue + + // Interpolate between colors + if progress < 0.5 { + // Top half: coral → magenta + let localProgress = progress * 2.0 + return interpolateColor(from: topColor, to: midColor, progress: localProgress) + } else { + // Bottom half: magenta → purple/blue + let localProgress = (progress - 0.5) * 2.0 + return interpolateColor(from: midColor, to: bottomColor, progress: localProgress) + } + } + + private func interpolateColor(from: Color, to: Color, progress: Double) -> Color { + // Extract HSB components and interpolate + let fromHSB = PlatformColor(from).hsba + let toHSB = PlatformColor(to).hsba + + let hue = fromHSB.hue + (toHSB.hue - fromHSB.hue) * progress + let saturation = fromHSB.saturation + (toHSB.saturation - fromHSB.saturation) * progress + let brightness = fromHSB.brightness + (toHSB.brightness - fromHSB.brightness) * progress + + return Color(hue: hue, saturation: saturation, brightness: brightness) + } + + init( + task: TaskItem, + taskID: UUID, + index: Int = 0, + totalTasks: Int = 1, + isSelected: Bool, + isEditing: Bool = false, + focusedField: FocusState<TaskListView.FocusField?>.Binding, + onToggle: @escaping (TaskItem) -> Void, + onTitleChange: @escaping (TaskItem, String) -> Void, + onDelete: @escaping (TaskItem) -> Void, + onSelect: @escaping (UUID) -> Void, + onStartEdit: @escaping (UUID) -> Void = { _ in }, + onEndEdit: @escaping (UUID, _ shouldCreateNewTask: Bool) -> Void = { _, _ in } + ) { + self.task = task + self.taskID = taskID + self.index = index + self.totalTasks = totalTasks + self.isSelected = isSelected + self.isEditing = isEditing + self.onToggle = onToggle + self.onTitleChange = onTitleChange + self.onDelete = onDelete + self.onSelect = onSelect + self.onStartEdit = onStartEdit + self.onEndEdit = onEndEdit + _focusedField = focusedField + } + + var body: some View { + HStack(alignment: .firstTextBaseline, spacing: 12) { + Button { + onToggle(task) + } label: { + Image(systemName: task.isCompleted ? "checkmark.circle.fill" : "circle") + .foregroundStyle(task.isCompleted ? .secondary : .primary) + .font(.system(size: 17)) + .fontWeight(.thin) + } + .buttonStyle(.borderless) + .alignmentGuide(.firstTextBaseline) { d in + d[VerticalAlignment.center] + 5 + } + .accessibilityIdentifier("task-checkbox") + .accessibilityValue(task.isCompleted ? "checkmark.circle.fill" : "circle") + + ClickableTextField( + text: $editingTitle, + isCompleted: task.isCompleted, + onEditingChanged: { editing, shouldCreateNewTask in + isCurrentlyEditing = editing + if editing { + onStartEdit(taskID) + } else { + onEndEdit(taskID, shouldCreateNewTask) + } + } + ) + .focused($focusedField, equals: .task(taskID)) + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityIdentifier( + isCurrentlyEditing ? "task-textfield" : "task-text-\(task.title)") + } + .padding(.vertical, 8) + .padding(.horizontal, 16) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + .onTapGesture { + onSelect(taskID) + } + .background(selectionBackground) + .overlay(alignment: .leading) { + // Colored accent bar on the left edge + Rectangle() + .fill(cachedAccentColor) + .frame(width: 4) + .padding(.vertical, 1) + } + .overlay(alignment: .bottom) { + // Hairline border between rows, inset to align with text + // Only show for active (non-completed) tasks + if !task.isCompleted { + Rectangle() + .fill(.separator) + .frame(height: 0.5) + .padding(.leading, dividerInset) + } + } + .contextMenu { + Button(task.isCompleted ? "Mark as Incomplete" : "Mark as Complete") { + onToggle(task) + } + Divider() + Button("Cut") { + cutToPasteboard() + } + Button("Copy") { + copyToPasteboard() + } + Button("Paste") { + pasteFromPasteboard() + } + Divider() + Button("Delete", role: .destructive) { + onDelete(task) + } + } + .onChange(of: editingTitle) { + guard !task.isCompleted else { return } + onTitleChange(task, editingTitle) + } + .onChange(of: task.title) { _, newValue in + // Keep editingTitle in sync with task.title when not editing + if !isCurrentlyEditing { + editingTitle = newValue + } + } + .onChange(of: "\(index)-\(totalTasks)") { _, _ in + cachedAccentColor = computeAccentColor() + } + .onAppear { + // Initialize editingTitle and cache accent color (computed once) + editingTitle = task.title + cachedAccentColor = computeAccentColor() + } + } + + @ViewBuilder + private var selectionBackground: some View { + if isSelected { + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(Color.accentColor.opacity(0.2)) + } + } + + private func cutToPasteboard() { + copyToPasteboard() + onDelete(task) + } + + private func copyToPasteboard() { + let text = isEditing ? editingTitle : task.title + guard !text.isEmpty else { return } + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.setString(text, forType: .string) + } + + private func pasteFromPasteboard() { + guard let string = NSPasteboard.general.string(forType: .string) else { return } + if isEditing { + editingTitle = string + } + onTitleChange(task, string) + } +} diff --git a/ListlessiOS/Info.plist b/ListlessiOS/Info.plist @@ -22,6 +22,10 @@ <string>1</string> <key>LSRequiresIPhoneOS</key> <true/> + <key>UIBackgroundModes</key> + <array> + <string>remote-notification</string> + </array> <key>UILaunchScreen</key> <dict/> <key>UISupportedInterfaceOrientations</key> diff --git a/ListlessiOS/ListlessiOSApp.swift b/ListlessiOS/ListlessiOSApp.swift @@ -6,8 +6,15 @@ struct ListlessiOSApp: App { var body: some Scene { WindowGroup { - TaskListView(store: TaskStore(persistenceController: persistenceController)) - .environment(\.managedObjectContext, persistenceController.viewContext) + NavigationStack { + TaskListView(store: TaskStore(persistenceController: persistenceController)) + .navigationTitle("Tasks") + .navigationBarTitleDisplayMode(.large) + .safeAreaInset(edge: .top) { + Color.clear.frame(height: 8) + } + .environment(\.managedObjectContext, persistenceController.viewContext) + } } } } diff --git a/ListlessiOS/Views/ClickableTextField.swift b/ListlessiOS/Views/ClickableTextField.swift @@ -1,35 +0,0 @@ -import SwiftUI - -/// iOS version - simple TextField wrapper -struct ClickableTextField: View { - @Binding var text: String - let isCompleted: Bool - let onEditingChanged: (Bool, _ shouldCreateNewTask: Bool) -> Void - - @FocusState private var isFocused: Bool - @State private var submittedViaReturn = false - - var body: some View { - TextField("New task", text: $text, axis: .vertical) - .textFieldStyle(.plain) - .font(.body) - .lineLimit(1...5) - .focused($isFocused) - .onSubmit { - // Return key pressed - mark for new task creation - submittedViaReturn = true - isFocused = false - } - .disabled(isCompleted) - .onChange(of: isFocused) { _, newValue in - if newValue { - // Focus gained - onEditingChanged(true, false) - } else { - // Focus lost - check if it was via Return key - onEditingChanged(false, submittedViaReturn) - submittedViaReturn = false - } - } - } -} diff --git a/ListlessiOS/Views/TaskRowView.swift b/ListlessiOS/Views/TaskRowView.swift @@ -0,0 +1,181 @@ +import SwiftUI + +struct TaskRowView: View { + let task: TaskItem + let taskID: UUID + let index: Int + let totalTasks: Int + let isSelected: Bool + let isEditing: Bool + let onToggle: (TaskItem) -> Void + let onTitleChange: (TaskItem, String) -> Void + let onDelete: (TaskItem) -> Void + let onSelect: (UUID) -> Void + let onStartEdit: (UUID) -> Void + let onEndEdit: (UUID, _ shouldCreateNewTask: Bool) -> Void + @FocusState.Binding var focusedField: TaskListView.FocusField? + + @State private var editingTitle: String = "" + @State private var isCurrentlyEditing: Bool = false + @State private var submittedViaReturn = false + + private let horizontalPadding: CGFloat = 16 + private let checkboxTextSpacing: CGFloat = 12 + @ScaledMetric private var checkboxSize: CGFloat = 20 + + private var dividerInset: CGFloat { + horizontalPadding + checkboxSize + checkboxTextSpacing + } + + init( + task: TaskItem, + taskID: UUID, + index: Int = 0, + totalTasks: Int = 1, + isSelected: Bool, + isEditing: Bool = false, + focusedField: FocusState<TaskListView.FocusField?>.Binding, + onToggle: @escaping (TaskItem) -> Void, + onTitleChange: @escaping (TaskItem, String) -> Void, + onDelete: @escaping (TaskItem) -> Void, + onSelect: @escaping (UUID) -> Void, + onStartEdit: @escaping (UUID) -> Void = { _ in }, + onEndEdit: @escaping (UUID, _ shouldCreateNewTask: Bool) -> Void = { _, _ in } + ) { + self.task = task + self.taskID = taskID + self.index = index + self.totalTasks = totalTasks + self.isSelected = isSelected + self.isEditing = isEditing + self.onToggle = onToggle + self.onTitleChange = onTitleChange + self.onDelete = onDelete + self.onSelect = onSelect + self.onStartEdit = onStartEdit + self.onEndEdit = onEndEdit + _focusedField = focusedField + } + + var body: some View { + HStack(alignment: .center, spacing: 12) { + Button { + onToggle(task) + } label: { + Image(systemName: task.isCompleted ? "checkmark.circle.fill" : "circle") + .foregroundStyle(task.isCompleted ? .secondary : .primary) + .font(.system(size: 17)) + .fontWeight(.thin) + } + .buttonStyle(.borderless) + .accessibilityIdentifier("task-checkbox") + .accessibilityValue(task.isCompleted ? "checkmark.circle.fill" : "circle") + + TextField("New task", text: $editingTitle, axis: .vertical) + .textFieldStyle(.plain) + .font(.body) + .lineLimit(1...5) + .focused($focusedField, equals: .task(taskID)) + .onSubmit { + // Return key pressed - mark for new task creation + submittedViaReturn = true + focusedField = nil + } + .disabled(task.isCompleted) + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityIdentifier( + isCurrentlyEditing ? "task-textfield" : "task-text-\(task.title)") + } + .padding(.vertical, 8) + .padding(.horizontal, 16) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + .onTapGesture { + onSelect(taskID) + } + .background(selectionBackground) + .overlay(alignment: .bottom) { + // Hairline border between rows, inset to align with text + // Only show for active (non-completed) tasks + if !task.isCompleted { + Rectangle() + .fill(.separator) + .frame(height: 0.5) + .padding(.leading, dividerInset) + } + } + .contextMenu { + Button(task.isCompleted ? "Mark as Incomplete" : "Mark as Complete") { + onToggle(task) + } + Divider() + Button("Cut") { + cutToPasteboard() + } + Button("Copy") { + copyToPasteboard() + } + Button("Paste") { + pasteFromPasteboard() + } + Divider() + Button("Delete", role: .destructive) { + onDelete(task) + } + } + .onChange(of: editingTitle) { + guard !task.isCompleted else { return } + onTitleChange(task, editingTitle) + } + .onChange(of: task.title) { _, newValue in + // Keep editingTitle in sync with task.title when not editing + if !isCurrentlyEditing { + editingTitle = newValue + } + } + .onChange(of: focusedField) { _, newValue in + let isFocused = newValue == .task(taskID) + if isFocused { + // Focus gained + isCurrentlyEditing = true + onStartEdit(taskID) + } else if isCurrentlyEditing { + // Focus lost - check if it was via Return key + isCurrentlyEditing = false + onEndEdit(taskID, submittedViaReturn) + submittedViaReturn = false + } + } + .onAppear { + // Initialize editingTitle + editingTitle = task.title + } + } + + @ViewBuilder + private var selectionBackground: some View { + if isSelected { + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(Color.accentColor.opacity(0.2)) + } + } + + private func cutToPasteboard() { + copyToPasteboard() + onDelete(task) + } + + private func copyToPasteboard() { + let text = isEditing ? editingTitle : task.title + guard !text.isEmpty else { return } + UIPasteboard.general.string = text + } + + private func pasteFromPasteboard() { + guard let string = UIPasteboard.general.string else { return } + if isEditing { + editingTitle = string + } + onTitleChange(task, string) + } +}