← All repositories

listless

A simple list app for Apple platforms

git clone https://code.inqk.net/listless.git


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

MListless.xcodeproj/project.pbxproj | 8++++++++
MListless/Views/TaskListView.swift | 45++++++++++++++++++++++++++++++---------------
MListlessiOS/ListlessiOSApp.swift | 2+-
AListlessiOS/Views/AppColors.swift | 16++++++++++++++++
AListlessiOS/Views/TappableTextField.swift | 102+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MListlessiOS/Views/TaskRowView.swift | 59+++++++++++++++++++++--------------------------------------

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
         }
     }
 }