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:
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)
+ }
+}