commit 22a8bebf4768a804fd312c5459db4607435997d3
parent e675e13bb78191e69153cc1c3d06112ee831f818
Author: Michael Camilleri <[email protected]>
Date: Fri, 20 Mar 2026 12:56:54 +0900
Factor views builders and modifiers in iOS version
This commit is an attempt to factor out some of the duplicative code in
the iOS version into view builders and modifiers.
Co-Authored-By: Claude 4.6 Opus <[email protected]>
Diffstat:
5 files changed, 132 insertions(+), 137 deletions(-)
diff --git a/Listless.xcodeproj/project.pbxproj b/Listless.xcodeproj/project.pbxproj
@@ -13,6 +13,7 @@
07E2BB2FF9E75A922C3756AB /* TaskListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56DEECB5DC31DE5F34FB55A3 /* TaskListView.swift */; };
0ACA67F6578EFF181EE5C9A7 /* TaskItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBB8A3BEB346267B30B4675F /* TaskItem.swift */; };
0F12D56A528FCBF8A67864CB /* TappableTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6ECE0E961F87BA32FA87BF90 /* TappableTextField.swift */; };
+ 0F2E6817C315B947033DA2BE /* DraftRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 67CB2A88C7F19F8305EBED43 /* DraftRowView.swift */; };
11AA75BE98CFBE44AEAB7100 /* Media.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 9404C09EE1A4D91DFF338464 /* Media.xcassets */; };
12E43D0CD9124037022E3C38 /* KeyCommandBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8B5E429B253183F887C5FD6 /* KeyCommandBridge.swift */; };
1477B460E3DEFA3FDB7DA65B /* TaskListTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F9754B32B2FD5AF8552BC85 /* TaskListTypes.swift */; };
@@ -28,6 +29,7 @@
2C53717A0EAFA39240615C9A /* TaskRowDragGesture.swift in Sources */ = {isa = PBXBuildFile; fileRef = D57C3CC81C4380EFAE4DB910 /* TaskRowDragGesture.swift */; };
2CCB5FE0084742D018E52A3D /* KeyValueSyncBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAE264D30C7692457B92E518 /* KeyValueSyncBridge.swift */; };
322EE74CBED492693B92AD59 /* TaskListView+Toolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A440253D045A0896D94ADD0 /* TaskListView+Toolbar.swift */; };
+ 354687FCBBCC2432340BD5EB /* TaskCardModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2143744C421C67E53A2674CF /* TaskCardModifier.swift */; };
365FDEE6823D7A114F3FB12A /* TaskListViewProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2C018476BD91B73870244B9 /* TaskListViewProtocol.swift */; };
37AEF10712B3325BF9BC72E4 /* BackgroundClickMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = F15BF645DEDE8D9E94DB508B /* BackgroundClickMonitor.swift */; };
3ABE52A15C2059D8D5570528 /* TaskStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC3DEE364304587D280C5672 /* TaskStore.swift */; };
@@ -164,6 +166,7 @@
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>"; };
1DA467DF2E59BDBE6EEF6A7D /* ListlessMacApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListlessMacApp.swift; sourceTree = "<group>"; };
+ 2143744C421C67E53A2674CF /* TaskCardModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskCardModifier.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>"; };
@@ -182,6 +185,7 @@
5E88DDD119EEEECCD45F36D2 /* TaskStoreCompletionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskStoreCompletionTests.swift; sourceTree = "<group>"; };
632DA39B24C4CF1528A1A24D /* TaskListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskListView.swift; sourceTree = "<group>"; };
658295C1386BFF48CE3C2419 /* UndoToast.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UndoToast.swift; sourceTree = "<group>"; };
+ 67CB2A88C7F19F8305EBED43 /* DraftRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DraftRowView.swift; sourceTree = "<group>"; };
68A677CC11ACE0BF743AFCE5 /* CloudKitErrorClassifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudKitErrorClassifier.swift; sourceTree = "<group>"; };
6BE7F4637B4F4C1FF4BE160B /* ListlessWatchApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListlessWatchApp.swift; sourceTree = "<group>"; };
6E3E82F6093EEFC94A41FED9 /* CloudKitSyncMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudKitSyncMonitor.swift; sourceTree = "<group>"; };
@@ -253,6 +257,7 @@
FA5D58EC1FBAA96E83A79445 /* PlatformScrollIndicatorsModifier.swift */,
E2174C43654733E9D4023157 /* PlatformTextFieldWidthModifier.swift */,
6ECE0E961F87BA32FA87BF90 /* TappableTextField.swift */,
+ 2143744C421C67E53A2674CF /* TaskCardModifier.swift */,
D57C3CC81C4380EFAE4DB910 /* TaskRowDragGesture.swift */,
A2EF927F09D67532F44BB80D /* TaskRowMetrics.swift */,
067D666DF0AF1C53404ECF7C /* TaskRowSwipeGesture.swift */,
@@ -333,6 +338,7 @@
isa = PBXGroup;
children = (
D3E995954787F0A14CCFF348 /* AboutView.swift */,
+ 67CB2A88C7F19F8305EBED43 /* DraftRowView.swift */,
567DBAC2A39FA2760D006AAB /* PullToClear.swift */,
BFF7D84B54AE70036D205CA4 /* PullToCreate.swift */,
C611E04943F1D82D6F975592 /* SettingsView.swift */,
@@ -803,6 +809,7 @@
3EDB6A9A30B4226C15E7F44D /* AppCommands.swift in Sources */,
CAB42FAA253E2B347AB0594B /* CloudKitErrorClassifier.swift in Sources */,
889DCB2BB3C01DDA281EA81A /* CloudKitSyncMonitor.swift in Sources */,
+ 0F2E6817C315B947033DA2BE /* DraftRowView.swift in Sources */,
E60F81C7B930AACE67746759 /* HoverCursorModifier.swift in Sources */,
12E43D0CD9124037022E3C38 /* KeyCommandBridge.swift in Sources */,
19699EC4FF57EF0D636B65E3 /* KeyValueSyncBridge.swift in Sources */,
@@ -817,6 +824,7 @@
072594B24D88AD6D1DF7AFE5 /* SettingsView.swift in Sources */,
4DD2030E321567BD25661760 /* SyncDiagnosticsView.swift in Sources */,
0F12D56A528FCBF8A67864CB /* TappableTextField.swift in Sources */,
+ 354687FCBBCC2432340BD5EB /* TaskCardModifier.swift in Sources */,
0ACA67F6578EFF181EE5C9A7 /* TaskItem.swift in Sources */,
1477B460E3DEFA3FDB7DA65B /* TaskListTypes.swift in Sources */,
82E75475E13FD847DDDC69D8 /* TaskListView+Drag.swift in Sources */,
diff --git a/ListlessiOS/Helpers/TaskCardModifier.swift b/ListlessiOS/Helpers/TaskCardModifier.swift
@@ -0,0 +1,34 @@
+import SwiftUI
+
+struct TaskCardModifier: ViewModifier {
+ var accentColor: Color
+ var isSelected: Bool
+
+ static let shape = UnevenRoundedRectangle(
+ topLeadingRadius: 0, bottomLeadingRadius: 0,
+ bottomTrailingRadius: TaskRowMetrics.trailingCornerRadius,
+ topTrailingRadius: TaskRowMetrics.trailingCornerRadius
+ )
+
+ func body(content: Content) -> some View {
+ content
+ .clipShape(Self.shape)
+ .overlay(alignment: .leading) {
+ Rectangle()
+ .fill(accentColor)
+ .frame(width: TaskRowMetrics.accentBarWidth)
+ }
+ .overlay(
+ isSelected
+ ? Self.shape
+ .stroke(accentColor.opacity(0.40), lineWidth: 2)
+ : nil
+ )
+ }
+}
+
+extension View {
+ func taskCard(accentColor: Color, isSelected: Bool) -> some View {
+ modifier(TaskCardModifier(accentColor: accentColor, isSelected: isSelected))
+ }
+}
diff --git a/ListlessiOS/Views/DraftRowView.swift b/ListlessiOS/Views/DraftRowView.swift
@@ -0,0 +1,41 @@
+import SwiftUI
+
+struct DraftRowView: View {
+ let accentColor: Color
+ let isSelected: Bool
+ let draftID: UUID
+ @Binding var title: String
+ var onEditingChanged: (Bool, Bool) -> Void
+ var returnKeyType: UIReturnKeyType
+ var accessibilityIdentifier: String
+ var focusedField: FocusState<FocusField?>.Binding
+
+ var body: some View {
+ HStack(alignment: .center, spacing: TaskRowMetrics.contentSpacing) {
+ Image(systemName: "circle")
+ .frame(width: 22, height: 22)
+ .foregroundStyle(Color.secondary)
+ .font(.system(size: 17))
+
+ TappableTextField(
+ text: $title,
+ isCompleted: false,
+ isDragging: false,
+ onEditingChanged: onEditingChanged,
+ returnKeyType: returnKeyType,
+ uiAccessibilityIdentifier: accessibilityIdentifier
+ )
+ .focused(focusedField, equals: .task(draftID))
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+ .padding(.vertical, TaskRowMetrics.contentVerticalPadding)
+ .padding(.trailing, TaskRowMetrics.contentHorizontalPadding)
+ .padding(.leading, TaskRowMetrics.activeLeadingPadding)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .contentShape(Rectangle())
+ .background {
+ Color.taskCard.overlay(accentColor.opacity(0.15))
+ }
+ .taskCard(accentColor: accentColor, isSelected: isSelected)
+ }
+}
diff --git a/ListlessiOS/Views/TaskListView.swift b/ListlessiOS/Views/TaskListView.swift
@@ -260,7 +260,7 @@ struct TaskListView: View, TaskListViewProtocol {
)
.opacity(showPhantom ? 0 : 1)
- phantomEntryRowContent
+ draftPrependRow
.frame(height: showPhantom ? nil : 0)
.opacity(showPhantom ? 1 : 0)
// Instant swap — no animation on height or opacity.
@@ -274,136 +274,58 @@ struct TaskListView: View, TaskListViewProtocol {
}
}
- /// The phantom row content styled to match a task row. Controlled by the
+ /// The draft row content styled to match a task row. Controlled by the
/// ZStack in ``pullToCreateIndicatorRow`` rather than its own visibility.
- @ViewBuilder private var phantomEntryRowContent: some View {
- let accentColor = taskColor(
- forIndex: 0, total: max(1, displayActiveTasks.count + 1), theme: colorTheme
+ @ViewBuilder private var draftPrependRow: some View {
+ DraftRowView(
+ accentColor: taskColor(
+ forIndex: 0, total: max(1, displayActiveTasks.count + 1), theme: colorTheme
+ ),
+ isSelected: fState.selectedTaskID == draftPrependRowID,
+ draftID: draftPrependRowID,
+ title: draftTitleBinding,
+ onEditingChanged: { editing, _ in
+ DispatchQueue.main.async {
+ if editing {
+ beginDraftTaskEditing(.prepend)
+ } else {
+ commitDraftTask()
+ }
+ }
+ },
+ returnKeyType: .done,
+ accessibilityIdentifier: "draft-row-prepend",
+ focusedField: $focusedFieldBinding
)
- let isSelected = fState.selectedTaskID == draftPrependRowID
- HStack(alignment: .center, spacing: TaskRowMetrics.contentSpacing) {
- Image(systemName: "circle")
- .frame(width: 22, height: 22)
- .foregroundStyle(Color.secondary)
- .font(.system(size: 17))
-
- TappableTextField(
- text: draftTitleBinding,
- isCompleted: false,
- isDragging: false,
-
- onEditingChanged: { editing, _ in
+ }
+
+ @ViewBuilder private var draftAppendRow: some View {
+ if isAppendDraftVisible {
+ DraftRowView(
+ accentColor: taskColor(
+ forIndex: displayActiveTasks.count,
+ total: max(1, displayActiveTasks.count + 1),
+ theme: colorTheme
+ ),
+ isSelected: fState.selectedTaskID == draftAppendRowID,
+ draftID: draftAppendRowID,
+ title: draftTitleBinding,
+ onEditingChanged: { editing, shouldCreateNewTask in
DispatchQueue.main.async {
if editing {
- beginDraftTaskEditing(.prepend)
+ beginDraftTaskEditing(.append)
} else {
- commitDraftTask()
+ commitDraftTask(
+ shouldCreateNewTask: shouldCreateNewTask
+ )
}
}
},
- returnKeyType: .done,
- uiAccessibilityIdentifier: "draft-row-prepend"
- )
- .focused($focusedFieldBinding, equals: .task(draftPrependRowID))
- .frame(maxWidth: .infinity, alignment: .leading)
- }
- .padding(.vertical, TaskRowMetrics.contentVerticalPadding)
- .padding(.trailing, TaskRowMetrics.contentHorizontalPadding)
- .padding(.leading, TaskRowMetrics.activeLeadingPadding)
- .frame(maxWidth: .infinity, alignment: .leading)
- .contentShape(Rectangle())
- .background {
- Color.taskCard.overlay(accentColor.opacity(0.15))
- }
- .clipShape(
- UnevenRoundedRectangle(
- topLeadingRadius: 0, bottomLeadingRadius: 0,
- bottomTrailingRadius: TaskRowMetrics.trailingCornerRadius,
- topTrailingRadius: TaskRowMetrics.trailingCornerRadius
- )
- )
- .overlay(alignment: .leading) {
- Rectangle()
- .fill(accentColor)
- .frame(width: TaskRowMetrics.accentBarWidth)
- }
- .overlay(
- isSelected
- ? UnevenRoundedRectangle(
- topLeadingRadius: 0, bottomLeadingRadius: 0,
- bottomTrailingRadius: TaskRowMetrics.trailingCornerRadius,
- topTrailingRadius: TaskRowMetrics.trailingCornerRadius
- )
- .stroke(accentColor.opacity(0.40), lineWidth: 2)
- : nil
- )
- }
-
- @ViewBuilder private var phantomAppendRowContent: some View {
- if isAppendDraftVisible {
- let total = max(1, displayActiveTasks.count + 1)
- let index = displayActiveTasks.count
- let accentColor = taskColor(forIndex: index, total: total, theme: colorTheme)
- let isSelected = fState.selectedTaskID == draftAppendRowID
- HStack(alignment: .center, spacing: TaskRowMetrics.contentSpacing) {
- Image(systemName: "circle")
- .frame(width: 22, height: 22)
- .foregroundStyle(Color.secondary)
- .font(.system(size: 17))
-
- TappableTextField(
- text: draftTitleBinding,
- isCompleted: false,
- isDragging: false,
-
- onEditingChanged: { editing, shouldCreateNewTask in
- DispatchQueue.main.async {
- if editing {
- beginDraftTaskEditing(.append)
- } else {
- commitDraftTask(
- shouldCreateNewTask: shouldCreateNewTask
- )
- }
- }
- },
- returnKeyType: draftTitle.trimmingCharacters(
- in: .whitespacesAndNewlines
- ).isEmpty ? .done : .next,
- uiAccessibilityIdentifier: "draft-row-append"
- )
- .focused($focusedFieldBinding, equals: .task(draftAppendRowID))
- .frame(maxWidth: .infinity, alignment: .leading)
- }
- .padding(.vertical, TaskRowMetrics.contentVerticalPadding)
- .padding(.trailing, TaskRowMetrics.contentHorizontalPadding)
- .padding(.leading, TaskRowMetrics.activeLeadingPadding)
- .frame(maxWidth: .infinity, alignment: .leading)
- .contentShape(Rectangle())
- .background {
- Color.taskCard.overlay(accentColor.opacity(0.15))
- }
- .clipShape(
- UnevenRoundedRectangle(
- topLeadingRadius: 0, bottomLeadingRadius: 0,
- bottomTrailingRadius: TaskRowMetrics.trailingCornerRadius,
- topTrailingRadius: TaskRowMetrics.trailingCornerRadius
- )
- )
- .overlay(alignment: .leading) {
- Rectangle()
- .fill(accentColor)
- .frame(width: TaskRowMetrics.accentBarWidth)
- }
- .overlay(
- isSelected
- ? UnevenRoundedRectangle(
- topLeadingRadius: 0, bottomLeadingRadius: 0,
- bottomTrailingRadius: TaskRowMetrics.trailingCornerRadius,
- topTrailingRadius: TaskRowMetrics.trailingCornerRadius
- )
- .stroke(accentColor.opacity(0.40), lineWidth: 2)
- : nil
+ returnKeyType: draftTitle.trimmingCharacters(
+ in: .whitespacesAndNewlines
+ ).isEmpty ? .done : .next,
+ accessibilityIdentifier: "draft-row-append",
+ focusedField: $focusedFieldBinding
)
.id(draftAppendRowID)
}
@@ -458,7 +380,7 @@ struct TaskListView: View, TaskListViewProtocol {
.id(taskID)
}
- phantomAppendRowContent
+ draftAppendRow
ForEach(completedTasks) { task in
let taskID = task.id
diff --git a/ListlessiOS/Views/TaskRowView.swift b/ListlessiOS/Views/TaskRowView.swift
@@ -170,21 +170,11 @@ struct TaskRowView: View {
isSwipeTriggered = false
}
}
- .clipShape(
- UnevenRoundedRectangle(
- topLeadingRadius: 0, bottomLeadingRadius: 0,
- bottomTrailingRadius: TaskRowMetrics.trailingCornerRadius,
- topTrailingRadius: TaskRowMetrics.trailingCornerRadius
- )
- )
+ .clipShape(TaskCardModifier.shape)
.overlay(
isSelected && !task.isCompleted
- ? UnevenRoundedRectangle(
- topLeadingRadius: 0, bottomLeadingRadius: 0,
- bottomTrailingRadius: TaskRowMetrics.trailingCornerRadius,
- topTrailingRadius: TaskRowMetrics.trailingCornerRadius
- )
- .stroke(cachedAccentColor.opacity(0.40), lineWidth: 2)
+ ? TaskCardModifier.shape
+ .stroke(cachedAccentColor.opacity(0.40), lineWidth: 2)
: nil
)
}