commit 31a6c4911c5d2ec1011a29d0a050ea174b76255c
parent 88cad792b010172e156c691364d738bd8f11d47b
Author: Michael Camilleri <[email protected]>
Date: Sun, 15 Feb 2026 05:15:07 +0900
Adopt card style
Co-Authored-By: Claude 4.5 Sonnet <[email protected]>
Diffstat:
6 files changed, 178 insertions(+), 54 deletions(-)
diff --git a/Listless.xcodeproj/project.pbxproj b/Listless.xcodeproj/project.pbxproj
@@ -20,6 +20,7 @@
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 */; };
+ 731477635D3F4DFF1F78D673 /* AppColors.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD1FE1858BC9E8915A091D33 /* AppColors.swift */; };
7B2CD636BA3B63A586F93E31 /* TaskRowSwipeGesture.swift in Sources */ = {isa = PBXBuildFile; fileRef = 44C16D36971A140364159FB9 /* TaskRowSwipeGesture.swift */; };
7E366008FF9335A77FE1636D /* TaskRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 199CC2F58DD7CBA3F2229366 /* TaskRowView.swift */; };
83378A0575640417ED41FC25 /* ClickableTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F2BB7ABCABA4EB67180A200 /* ClickableTextField.swift */; };
@@ -38,6 +39,7 @@
D9DA553485AD0CA28D5BE0C8 /* TaskListView+Toolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93FF5B9F5B979D54D5DEE192 /* TaskListView+Toolbar.swift */; };
DC71C45D92524C3893BF9FDB /* PlatformTextFieldWidthModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81880970CCFC2CB00B65047E /* PlatformTextFieldWidthModifier.swift */; };
DC73A39A269AB495BCE1AC48 /* PersistenceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C14858BDFD1FD5119F1F24A6 /* PersistenceController.swift */; };
+ E3C6D4AE5E729D34086F132D /* TappableTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 95568F6A132636D04B8CF593 /* TappableTextField.swift */; };
ECD5E7EA05AE1C00B38C939E /* TaskStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 967F7ECEB3915CEDCE584872 /* TaskStoreTests.swift */; };
F0B2B806BD84A4F2FDF8E038 /* ListlessiOSApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC245331D715EA85887C0BA0 /* ListlessiOSApp.swift */; };
F50E8B8F73F64D8E641DC74C /* TaskStoreCompletionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E88DDD119EEEECCD45F36D2 /* TaskStoreCompletionTests.swift */; };
@@ -81,6 +83,7 @@
9262207DAC21619BD9EDEE15 /* Listless.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Listless.entitlements; sourceTree = "<group>"; };
93FF5B9F5B979D54D5DEE192 /* TaskListView+Toolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TaskListView+Toolbar.swift"; sourceTree = "<group>"; };
944BAE054AAC1B9C4FC954F9 /* .gitkeep */ = {isa = PBXFileReference; path = .gitkeep; sourceTree = "<group>"; };
+ 95568F6A132636D04B8CF593 /* TappableTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TappableTextField.swift; sourceTree = "<group>"; };
967F7ECEB3915CEDCE584872 /* TaskStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskStoreTests.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>"; };
@@ -97,6 +100,7 @@
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>"; };
+ FD1FE1858BC9E8915A091D33 /* AppColors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppColors.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXGroup section */
@@ -134,9 +138,11 @@
58F917D865E0BDF4EF282306 /* Views */ = {
isa = PBXGroup;
children = (
+ FD1FE1858BC9E8915A091D33 /* AppColors.swift */,
82A3509AD32A54434BCC8017 /* HoverCursorModifier.swift */,
48AE1AE43296C1692FA6F755 /* PlatformScrollIndicatorsModifier.swift */,
81880970CCFC2CB00B65047E /* PlatformTextFieldWidthModifier.swift */,
+ 95568F6A132636D04B8CF593 /* TappableTextField.swift */,
93FF5B9F5B979D54D5DEE192 /* TaskListView+Toolbar.swift */,
1B3D0B4710C7A0EE4F227851 /* TaskRowDragGesture.swift */,
44C16D36971A140364159FB9 /* TaskRowSwipeGesture.swift */,
@@ -372,6 +378,7 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
+ 731477635D3F4DFF1F78D673 /* AppColors.swift in Sources */,
69D1909CB6D2368CF90065C7 /* ColorExtensions.swift in Sources */,
FEC96DAA2BF8BB5D6D8504EB /* HoverCursorModifier.swift in Sources */,
6C252050E62AED3A0A684EBF /* KeyboardNavigationModifier.swift in Sources */,
@@ -380,6 +387,7 @@
DC73A39A269AB495BCE1AC48 /* PersistenceController.swift in Sources */,
8E680EC18B0339931E785F0C /* PlatformScrollIndicatorsModifier.swift in Sources */,
DC71C45D92524C3893BF9FDB /* PlatformTextFieldWidthModifier.swift in Sources */,
+ E3C6D4AE5E729D34086F132D /* TappableTextField.swift in Sources */,
0ACA67F6578EFF181EE5C9A7 /* TaskItem.swift in Sources */,
D9DA553485AD0CA28D5BE0C8 /* TaskListView+Toolbar.swift in Sources */,
42E4CDE1D17463554CC4F41F /* TaskListView.swift in Sources */,
diff --git a/Listless/Views/TaskListView.swift b/Listless/Views/TaskListView.swift
@@ -32,7 +32,7 @@ struct TaskListView: View {
var body: some View {
ScrollView {
- VStack(alignment: .leading, spacing: 0) {
+ VStack(alignment: .leading, spacing: vStackSpacing) {
ForEach(Array(displayActiveTasks.enumerated()), id: \.element.id) { index, task in
let taskID = task.id
TaskRowView(
@@ -125,22 +125,19 @@ struct TaskListView: View {
}
}
.frame(maxWidth: .infinity, alignment: .topLeading)
+ #if os(iOS)
+ .padding(.horizontal, 16)
+ .padding(.vertical, 12)
+ #endif
.dropDestination(for: String.self) { items, location in
handleDrop(items: items)
}
}
- // .background(
- // LinearGradient(
- // colors: [
- // Color(hue: 0.98, saturation: 0.85, brightness: 1.0),
- // Color(hue: 0.88, saturation: 0.75, brightness: 0.95),
- // Color(hue: 0.72, saturation: 0.65, brightness: 0.85),
- // ],
- // startPoint: .top,
- // endPoint: .bottom
- // )
- // .ignoresSafeArea()
- // )
+ #if os(iOS)
+ .background {
+ Color.outerBackground.ignoresSafeArea()
+ }
+ #endif
.contentShape(Rectangle())
.onTapGesture {
handleBackgroundTap()
@@ -186,6 +183,14 @@ struct TaskListView: View {
}
}
+ private var vStackSpacing: CGFloat {
+ #if os(iOS)
+ 12
+ #else
+ 0
+ #endif
+ }
+
private var activeTasks: [TaskItem] {
Array(tasks.filter { !$0.isCompleted })
.sorted { $0.sortOrder < $1.sortOrder }
@@ -230,9 +235,11 @@ struct TaskListView: View {
// Create Core Data task (Core Data assigns the ID)
let task = store.createTask(title: "")
- // Record intent to focus the new task
- // This will be resolved in onChange(of: focusedField) when focus becomes nil
+ // Record intent to focus the new task.
+ // pendingFocus is retained for the background-tap flow (focusedField → nil there).
+ // focusedField is also set directly for the TappableTextField Return flow (stays non-nil).
pendingFocus = .task(task.id)
+ focusedField = .task(task.id)
selectedTaskID = task.id
}
@@ -441,6 +448,7 @@ struct TaskListView: View {
print("🟢 startEditing called for task \(taskID)")
selectedTaskID = taskID
focusedField = .task(taskID)
+ pendingFocus = nil // Consume pendingFocus once the field is live
print("🟢 startEditing set focusedField = .task(\(taskID))")
}
@@ -465,6 +473,13 @@ struct TaskListView: View {
} else if wasLastActiveTask && shouldCreateNewTask {
print("🟢 endEditing() creating new task")
createNewTask()
+ } else if shouldCreateNewTask {
+ print("🟢 endEditing() Return on non-last task — dismiss keyboard, enter navigation mode")
+ // TappableTextField returns false from textFieldShouldReturn, so the old field
+ // stays first responder. Setting focusedField to .scrollView causes SwiftUI's
+ // .focused() to detect the mismatch and call resignFirstResponder(), dismissing
+ // the keyboard cleanly.
+ focusedField = .scrollView
} else {
print("🟢 endEditing() done, selection unchanged")
// Do NOT restore selectedTaskID = taskID here.
diff --git a/ListlessiOS/ListlessiOSApp.swift b/ListlessiOS/ListlessiOSApp.swift
@@ -8,7 +8,7 @@ struct ListlessiOSApp: App {
WindowGroup {
NavigationStack {
TaskListView(store: TaskStore(persistenceController: persistenceController))
- .navigationTitle("Tasks")
+ .navigationTitle("Listless")
.navigationBarTitleDisplayMode(.large)
.safeAreaInset(edge: .top) {
Color.clear.frame(height: 8)
diff --git a/ListlessiOS/Views/AppColors.swift b/ListlessiOS/Views/AppColors.swift
@@ -0,0 +1,16 @@
+import SwiftUI
+import UIKit
+
+extension Color {
+ /// Warm gray canvas shown behind task cards and beneath completed-task text.
+ static let outerBackground = Color(uiColor: UIColor { traits in
+ traits.userInterfaceStyle == .dark
+ ? UIColor(red: 0.173, green: 0.165, blue: 0.153, alpha: 1) // #2C2A27
+ : UIColor(red: 0.922, green: 0.906, blue: 0.886, alpha: 1) // #EBE7E2
+ })
+
+ /// Stark card background: white in light mode, black in dark mode.
+ static let taskCard = Color(uiColor: UIColor { traits in
+ traits.userInterfaceStyle == .dark ? .black : .white
+ })
+}
diff --git a/ListlessiOS/Views/TappableTextField.swift b/ListlessiOS/Views/TappableTextField.swift
@@ -0,0 +1,102 @@
+import SwiftUI
+import UIKit
+
+/// UITextField that's always present, manages its own editing state.
+/// Mirrors the interface of ClickableTextField (macOS) so TaskListView
+/// can drive both platforms through the same focusedField binding.
+struct TappableTextField: UIViewRepresentable {
+ @Binding var text: String
+ let isCompleted: Bool
+ let onEditingChanged: (Bool, _ shouldCreateNewTask: Bool) -> Void
+
+ func makeUIView(context: Context) -> UITextField {
+ let textField = UITextField()
+ textField.delegate = context.coordinator
+ textField.borderStyle = .none
+ textField.font = .systemFont(ofSize: 18)
+ textField.returnKeyType = .done
+ textField.autocorrectionType = .default
+ textField.autocapitalizationType = .sentences
+ textField.attributedPlaceholder = NSAttributedString(
+ string: "Task",
+ attributes: [
+ .foregroundColor: UIColor.placeholderText,
+ .font: UIFont.systemFont(ofSize: 18),
+ ]
+ )
+ textField.addTarget(
+ context.coordinator,
+ action: #selector(Coordinator.textChanged(_:)),
+ for: .editingChanged
+ )
+ return textField
+ }
+
+ func updateUIView(_ textField: UITextField, context: Context) {
+ // Only update content when NOT editing to avoid interfering with active input
+ if !textField.isFirstResponder {
+ applyStyle(to: textField, text: text, isCompleted: isCompleted)
+ }
+ textField.isEnabled = !isCompleted
+ }
+
+ func makeCoordinator() -> Coordinator {
+ Coordinator(text: $text, onEditingChanged: onEditingChanged)
+ }
+
+ private func applyStyle(to textField: UITextField, text: String, isCompleted: Bool) {
+ if text.isEmpty {
+ textField.attributedText = NSAttributedString(string: "")
+ } else {
+ var attributes: [NSAttributedString.Key: Any] = [
+ .font: UIFont.systemFont(ofSize: 18),
+ .foregroundColor: isCompleted ? UIColor.secondaryLabel : UIColor.label,
+ ]
+ if isCompleted {
+ attributes[.strikethroughStyle] = NSUnderlineStyle.single.rawValue
+ attributes[.strikethroughColor] = UIColor.secondaryLabel
+ }
+ textField.attributedText = NSAttributedString(string: text, attributes: attributes)
+ }
+ }
+
+ final class Coordinator: NSObject, UITextFieldDelegate {
+ @Binding var text: String
+ let onEditingChanged: (Bool, _ shouldCreateNewTask: Bool) -> Void
+ var returnKeyPressed: Bool = false
+
+ init(
+ text: Binding<String>,
+ onEditingChanged: @escaping (Bool, _ shouldCreateNewTask: Bool) -> Void
+ ) {
+ _text = text
+ self.onEditingChanged = onEditingChanged
+ }
+
+ @objc func textChanged(_ textField: UITextField) {
+ text = textField.text ?? ""
+ }
+
+ func textFieldDidBeginEditing(_ textField: UITextField) {
+ onEditingChanged(true, false)
+ }
+
+ func textFieldDidEndEditing(_ textField: UITextField) {
+ if returnKeyPressed {
+ // onEditingChanged(false, true) already fired in textFieldShouldReturn;
+ // skip the duplicate call here.
+ returnKeyPressed = false
+ return
+ }
+ onEditingChanged(false, false)
+ }
+
+ func textFieldShouldReturn(_ textField: UITextField) -> Bool {
+ returnKeyPressed = true
+ onEditingChanged(false, true)
+ // Return false: UIKit does NOT auto-resign first responder, so the
+ // keyboard stays visible while SwiftUI focuses the next field.
+ return false
+ }
+ }
+}
diff --git a/ListlessiOS/Views/TaskRowView.swift b/ListlessiOS/Views/TaskRowView.swift
@@ -61,17 +61,17 @@ struct TaskRowView: View {
}
.buttonStyle(.borderless)
- TextField("Task", text: $editingTitle)
- .focused($focusedField, equals: .task(taskID))
- .font(.system(size: 18))
- .foregroundStyle(task.isCompleted ? Color.secondary : Color.primary)
- .strikethrough(task.isCompleted, color: .secondary)
- .disabled(task.isCompleted)
- .frame(maxWidth: .infinity, alignment: .leading)
- .onSubmit {
- isCurrentlyEditing = false
- onEndEdit(taskID, true)
+ TappableTextField(
+ 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)
}
.padding(.vertical, 14)
.padding(.horizontal, 16)
@@ -94,20 +94,12 @@ struct TaskRowView: View {
focusedField = .task(taskID)
}
}
- .background(selectionBackground)
- .overlay(alignment: .top) {
- if !task.isCompleted {
- VStack(spacing: 0) {
- LinearGradient(
- colors: [.black.opacity(0.10), .clear],
- startPoint: .bottom,
- endPoint: .top
- )
- .frame(height: 6)
- Rectangle()
- .fill(.black.opacity(0.4))
- .frame(height: 0.5)
- }
+ .background(cardBackground)
+ .clipShape(RoundedRectangle(cornerRadius: task.isCompleted ? 0 : 14))
+ .overlay {
+ if isSelected && !task.isCompleted {
+ RoundedRectangle(cornerRadius: 14)
+ .strokeBorder(Color.accentColor, lineWidth: 2)
}
}
.onAppear {
@@ -122,16 +114,6 @@ struct TaskRowView: View {
editingTitle = newValue
}
}
- .onChange(of: focusedField) { _, newValue in
- let isNowEditing = newValue == .task(taskID)
- if isNowEditing && !isCurrentlyEditing {
- isCurrentlyEditing = true
- onStartEdit(taskID)
- } else if !isNowEditing && isCurrentlyEditing {
- isCurrentlyEditing = false
- onEndEdit(taskID, false)
- }
- }
.taskSwipeGesture(
isActive: true,
isEditing: isCurrentlyEditing,
@@ -142,14 +124,15 @@ struct TaskRowView: View {
onComplete: { onToggle(task) },
onDelete: { onDelete(task) }
)
+ .clipShape(RoundedRectangle(cornerRadius: task.isCompleted ? 0 : 14))
}
@ViewBuilder
- private var selectionBackground: some View {
+ private var cardBackground: some View {
if task.isCompleted {
- Color(uiColor: .systemBackground)
- } else if isSelected {
- Color.accentColor.opacity(0.2)
+ Color.clear
+ } else {
+ Color.taskCard
}
}
}