listless

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

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