commit 22d14a88e25a2f69ffc59fda81c7c5b4d8532fa7 parent 432762286b16522f3b498337b30b34b76d89a3ed Author: Michael Camilleri <[email protected]> Date: Tue, 17 Feb 2026 15:22:27 +0900 Add pull to create Co-Authored-By: Codex GPT 5.3 <[email protected]> Diffstat:
13 files changed, 485 insertions(+), 86 deletions(-)
diff --git a/Listless.xcodeproj/project.pbxproj b/Listless.xcodeproj/project.pbxproj @@ -14,6 +14,7 @@ 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 */; }; + 3FCEFB586D9A9085A201AE7D /* TaskListView+PullToCreate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 95BF73E1B8FE337B76D3E757 /* TaskListView+PullToCreate.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 */; }; @@ -24,11 +25,14 @@ 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 */; }; + 907478D07E5CCD4966579306 /* TaskListView+NavigationHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0C28F7AF2AFAF677F678087 /* TaskListView+NavigationHeader.swift */; }; 91EDF52C7C5C0B35E9D8B51E /* TaskStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC3DEE364304587D280C5672 /* TaskStore.swift */; }; + 92932AEC64B65AD6F95A1E42 /* TaskListView+PullToCreate.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF51ACA55E28AA1A11D3962C /* TaskListView+PullToCreate.swift */; }; 96617677059FABDBB80D642B /* Listless.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = C093494053E6C348F245D4EC /* Listless.xcdatamodeld */; }; 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 */; }; + AB5D681B98FC1D2FD7BF2FAA /* TaskListView+NavigationHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7AF738EEB6ABDF86B0B781F /* TaskListView+NavigationHeader.swift */; }; C1BD61D6DFA687E9CB9ACA60 /* TaskRowDragGesture.swift in Sources */ = {isa = PBXBuildFile; fileRef = 138612F350ECE29526F689B9 /* TaskRowDragGesture.swift */; }; C1FE091454864C4BBBBEB077 /* TaskItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBB8A3BEB346267B30B4675F /* TaskItem.swift */; }; D6B3DBDD6A3F0A6E166CFFD5 /* TaskRowDragGesture.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B3D0B4710C7A0EE4F227851 /* TaskRowDragGesture.swift */; }; @@ -38,6 +42,7 @@ 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 */; }; + E47136CA7428927395D8C7C7 /* PullToCreate.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFF7D84B54AE70036D205CA4 /* PullToCreate.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,11 +86,15 @@ 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>"; }; + 95BF73E1B8FE337B76D3E757 /* TaskListView+PullToCreate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TaskListView+PullToCreate.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>"; }; + A0C28F7AF2AFAF677F678087 /* TaskListView+NavigationHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TaskListView+NavigationHeader.swift"; sourceTree = "<group>"; }; + A7AF738EEB6ABDF86B0B781F /* TaskListView+NavigationHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TaskListView+NavigationHeader.swift"; 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>"; }; + BFF7D84B54AE70036D205CA4 /* PullToCreate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PullToCreate.swift; sourceTree = "<group>"; }; C14858BDFD1FD5119F1F24A6 /* PersistenceController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceController.swift; 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>"; }; @@ -93,6 +102,7 @@ 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>"; }; + DF51ACA55E28AA1A11D3962C /* TaskListView+PullToCreate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TaskListView+PullToCreate.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>"; }; @@ -138,7 +148,10 @@ 82A3509AD32A54434BCC8017 /* HoverCursorModifier.swift */, 48AE1AE43296C1692FA6F755 /* PlatformScrollIndicatorsModifier.swift */, 81880970CCFC2CB00B65047E /* PlatformTextFieldWidthModifier.swift */, + BFF7D84B54AE70036D205CA4 /* PullToCreate.swift */, 95568F6A132636D04B8CF593 /* TappableTextField.swift */, + A0C28F7AF2AFAF677F678087 /* TaskListView+NavigationHeader.swift */, + DF51ACA55E28AA1A11D3962C /* TaskListView+PullToCreate.swift */, 93FF5B9F5B979D54D5DEE192 /* TaskListView+Toolbar.swift */, 1B3D0B4710C7A0EE4F227851 /* TaskRowDragGesture.swift */, 44C16D36971A140364159FB9 /* TaskRowSwipeGesture.swift */, @@ -184,6 +197,8 @@ D41D6E0CED14D79F31C45062 /* HoverCursorModifier.swift */, B4D74AE2501F35974F57D21F /* PlatformScrollIndicatorsModifier.swift */, EF6B0195EA0176B72DE9B092 /* PlatformTextFieldWidthModifier.swift */, + A7AF738EEB6ABDF86B0B781F /* TaskListView+NavigationHeader.swift */, + 95BF73E1B8FE337B76D3E757 /* TaskListView+PullToCreate.swift */, 431B0EA72D590AF0CC868515 /* TaskListView+Toolbar.swift */, 138612F350ECE29526F689B9 /* TaskRowDragGesture.swift */, E06485DBE35B60868E14202A /* TaskRowView.swift */, @@ -359,6 +374,8 @@ A8D13DD6196772ECA146CF2A /* PlatformScrollIndicatorsModifier.swift in Sources */, D878CD3A552C6A9685A30AA8 /* PlatformTextFieldWidthModifier.swift in Sources */, C1FE091454864C4BBBBEB077 /* TaskItem.swift in Sources */, + AB5D681B98FC1D2FD7BF2FAA /* TaskListView+NavigationHeader.swift in Sources */, + 3FCEFB586D9A9085A201AE7D /* TaskListView+PullToCreate.swift in Sources */, 074A81D9DAF58E8E088CBC89 /* TaskListView+Toolbar.swift in Sources */, 8E08F4C82D6F9E67667CB20A /* TaskListView.swift in Sources */, C1BD61D6DFA687E9CB9ACA60 /* TaskRowDragGesture.swift in Sources */, @@ -379,8 +396,11 @@ DC73A39A269AB495BCE1AC48 /* PersistenceController.swift in Sources */, 8E680EC18B0339931E785F0C /* PlatformScrollIndicatorsModifier.swift in Sources */, DC71C45D92524C3893BF9FDB /* PlatformTextFieldWidthModifier.swift in Sources */, + E47136CA7428927395D8C7C7 /* PullToCreate.swift in Sources */, E3C6D4AE5E729D34086F132D /* TappableTextField.swift in Sources */, 0ACA67F6578EFF181EE5C9A7 /* TaskItem.swift in Sources */, + 907478D07E5CCD4966579306 /* TaskListView+NavigationHeader.swift in Sources */, + 92932AEC64B65AD6F95A1E42 /* TaskListView+PullToCreate.swift in Sources */, D9DA553485AD0CA28D5BE0C8 /* TaskListView+Toolbar.swift in Sources */, 42E4CDE1D17463554CC4F41F /* TaskListView.swift in Sources */, D6B3DBDD6A3F0A6E166CFFD5 /* TaskRowDragGesture.swift in Sources */, diff --git a/Listless/Models/TaskStore.swift b/Listless/Models/TaskStore.swift @@ -36,15 +36,19 @@ final class TaskStore { } } - func createTask(title: String = "") -> TaskItem { + func createTask(title: String = "", atBeginning: Bool = false) -> TaskItem { let task = TaskItem(context: context) task.title = title - // Set sortOrder to end of active tasks context.processPendingChanges() let activeTasks = fetchTasks().filter { !$0.isCompleted } - let maxOrder = activeTasks.map(\.sortOrder).max() ?? -1000 - task.sortOrder = maxOrder + 1000 + if atBeginning { + let minOrder = activeTasks.map(\.sortOrder).min() ?? 0 + task.sortOrder = minOrder - 1000 + } else { + let maxOrder = activeTasks.map(\.sortOrder).max() ?? -1000 + task.sortOrder = maxOrder + 1000 + } save() return task diff --git a/Listless/Views/TaskListView.swift b/Listless/Views/TaskListView.swift @@ -25,6 +25,16 @@ struct TaskListView: View { @State private var swipingTaskID: UUID? @State private var visualOrder: [UUID]? @State private var pendingFocus: FocusField? + @State var pullOffset: CGFloat = 0 + #if os(iOS) + private struct IOSDragState { + let taskID: UUID + var fingerPosition: CGPoint + } + @State private var iosDragState: IOSDragState? = nil + @State private var rowFrames: [UUID: CGRect] = [:] + @State private var scrollViewMinY: CGFloat = 0 + #endif init(store: TaskStore = TaskStore()) { _store = State(wrappedValue: store) @@ -33,8 +43,50 @@ struct TaskListView: View { var body: some View { ScrollView { VStack(alignment: .leading, spacing: vStackSpacing) { + navigationHeader + pullToCreateIndicatorRow ForEach(Array(displayActiveTasks.enumerated()), id: \.element.id) { index, task in let taskID = task.id + #if os(iOS) + Group { + if iosDragState?.taskID == taskID { + // Ghost spacer: placeholder silhouette while card is floating + UnevenRoundedRectangle( + topLeadingRadius: 0, bottomLeadingRadius: 0, + bottomTrailingRadius: 14, topTrailingRadius: 14 + ) + .fill(Color.taskCard.opacity(0.4)) + .frame(height: rowFrames[taskID]?.height ?? 56) + } else { + TaskRowView( + task: task, + taskID: taskID, + index: index, + totalTasks: displayActiveTasks.count, + isSelected: selectedTaskID == taskID, + focusedField: $focusedField, + onToggle: { toggleCompletion($0) }, + onTitleChange: { updateTitle($0, $1) }, + onDelete: { deleteTask($0) }, + onSelect: { selectTask($0) }, + onStartEdit: { startEditing($0) }, + onEndEdit: { endEditing($0, shouldCreateNewTask: $1) } + ) + .taskDragGesture( + isActive: !task.isCompleted && swipingTaskID == nil, + taskID: taskID, + onDragStart: { startDrag(taskID: taskID) }, + onDragChanged: { point in handleIOSDragChanged(taskID: taskID, point: point) }, + onDragEnded: { commitIOSDrag() } + ) + } + } + .onGeometryChange(for: CGRect.self) { proxy in + proxy.frame(in: .global) + } action: { frame in + rowFrames[taskID] = frame + } + #else TaskRowView( task: task, taskID: taskID, @@ -95,8 +147,10 @@ struct TaskListView: View { } } } + #endif } + #if os(macOS) // Drop zone at the end if !activeTasks.isEmpty && draggedTaskID != nil { Color.clear @@ -109,6 +163,7 @@ struct TaskListView: View { } }) } + #endif ForEach(completedTasks) { task in let taskID = task.id @@ -129,14 +184,72 @@ struct TaskListView: View { .padding(.trailing, 16) .padding(.vertical, 12) #endif + .offset(y: -pullOffset) + #if os(macOS) .dropDestination(for: String.self) { items, location in handleDrop(items: items) } + #endif } #if os(iOS) .background { Color.outerBackground.ignoresSafeArea() } + .overlay(alignment: .topLeading) { + if let state = iosDragState, + let task = activeTasks.first(where: { $0.id == state.taskID }), + let frame = rowFrames[state.taskID] { + HStack(spacing: 0) { + Rectangle() + .fill( + accentColor( + forIndex: visualOrder?.firstIndex(of: state.taskID) ?? 0, + total: displayActiveTasks.count + ) + ) + .frame(width: 8) + Text(task.title.isEmpty ? "New task" : task.title) + .font(.body) + .frame(maxWidth: .infinity, alignment: .leading) + .padding() + .background(Color.taskCard) + } + .clipShape( + UnevenRoundedRectangle( + topLeadingRadius: 0, bottomLeadingRadius: 0, + bottomTrailingRadius: 14, topTrailingRadius: 14 + ) + ) + .shadow(color: .black.opacity(0.18), radius: 10, y: 4) + .frame(width: frame.width, height: frame.height) + .position( + x: UIScreen.main.bounds.midX, + y: state.fingerPosition.y - scrollViewMinY + ) + .ignoresSafeArea() + .allowsHitTesting(false) + .zIndex(100) + } + } + .onGeometryChange(for: CGFloat.self) { proxy in + proxy.frame(in: .global).minY + } action: { minY in + scrollViewMinY = minY + } + .onScrollGeometryChange(for: CGFloat.self) { geo in + max(0, -(geo.contentOffset.y + geo.contentInsets.top)) + } action: { _, pullDistance in + pullOffset = pullDistance + } + .onScrollPhaseChange { oldPhase, newPhase in + if oldPhase == .interacting && newPhase != .interacting { + if pullOffset >= pullCreateThreshold { createNewTaskAtTop() } + pullOffset = 0 + } + } + .sensoryFeedback(.impact(weight: .medium), trigger: pullOffset >= pullCreateThreshold) { old, new in + !old && new + } #endif .contentShape(Rectangle()) .onTapGesture { @@ -227,6 +340,15 @@ struct TaskListView: View { return lastTask.id == taskID } + private func createNewTaskAtTop() { + draggedTaskID = nil + visualOrder = nil + let task = store.createTask(title: "", atBeginning: true) + pendingFocus = .task(task.id) + focusedField = .task(task.id) + selectedTaskID = task.id + } + func createNewTask() { // Clear any lingering drag state draggedTaskID = nil @@ -243,6 +365,7 @@ struct TaskListView: View { selectedTaskID = task.id } + private func handleBackgroundTap() { // Check if a task is focused (not just scrollView) let isTaskFocused = if case .task = focusedField { true } else { false } @@ -510,6 +633,9 @@ struct TaskListView: View { private func startDrag(taskID: UUID) { draggedTaskID = taskID visualOrder = activeTasks.map(\.id) + #if os(iOS) + iosDragState = IOSDragState(taskID: taskID, fingerPosition: .zero) + #endif } private func updateVisualOrder(insertBefore targetID: UUID) { @@ -600,4 +726,72 @@ struct TaskListView: View { return true } + + // MARK: - iOS Drag Helpers + + #if os(iOS) + private func dropIndexForY(_ y: CGFloat) -> Int { + let midpoints = displayActiveTasks.enumerated().compactMap { i, task -> (Int, CGFloat)? in + guard let frame = rowFrames[task.id] else { return nil } + return (i, frame.midY) + } + return midpoints.first(where: { $0.1 > y })?.0 ?? displayActiveTasks.count + } + + private func updateVisualOrderToIndex(_ index: Int) { + guard let draggedID = draggedTaskID, let order = visualOrder else { return } + var newOrder = order.filter { $0 != draggedID } + let clamped = min(max(0, index), newOrder.count) + newOrder.insert(draggedID, at: clamped) + if newOrder != visualOrder { + withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { + visualOrder = newOrder + } + } + } + + private func handleIOSDragChanged(taskID: UUID, point: CGPoint) { + iosDragState?.fingerPosition = point + let index = dropIndexForY(point.y) + updateVisualOrderToIndex(index) + } + + private func commitIOSDrag() { + guard let state = iosDragState, + let order = visualOrder, + let finalIndex = order.firstIndex(of: state.taskID) else { + iosDragState = nil + draggedTaskID = nil + visualOrder = nil + return + } + store.moveTask(taskID: state.taskID, toIndex: finalIndex) + iosDragState = nil + draggedTaskID = nil + visualOrder = nil + } + + private func accentColor(forIndex index: Int, total: Int) -> Color { + guard total > 1 else { return Color(hue: 0.98, saturation: 0.85, brightness: 1.0) } + let progress = Double(index) / Double(total - 1) + let top = (h: 0.98, s: 0.85, b: 1.00) + let mid = (h: 0.88, s: 0.75, b: 0.95) + let bottom = (h: 0.72, s: 0.65, b: 0.85) + if progress < 0.5 { + let t = progress * 2.0 + return Color( + hue: top.h + (mid.h - top.h) * t, + saturation: top.s + (mid.s - top.s) * t, + brightness: top.b + (mid.b - top.b) * t + ) + } else { + let t = (progress - 0.5) * 2.0 + return Color( + hue: mid.h + (bottom.h - mid.h) * t, + saturation: mid.s + (bottom.s - mid.s) * t, + brightness: mid.b + (bottom.b - mid.b) * t + ) + } + } + #endif } diff --git a/ListlessMac/Views/ClickableTextField.swift b/ListlessMac/Views/ClickableTextField.swift @@ -27,6 +27,7 @@ struct ClickableTextField: NSViewRepresentable { textField.drawsBackground = false textField.focusRingType = .none textField.font = .systemFont(ofSize: NSFont.systemFontSize) + textField.placeholderString = "Enter task" textField.lineBreakMode = .byWordWrapping textField.maximumNumberOfLines = 5 textField.usesSingleLineMode = false @@ -97,7 +98,7 @@ struct ClickableTextField: NSViewRepresentable { // Calculate text width private func calculateWidth(for text: String, font: NSFont) -> CGFloat { let attributedString = NSAttributedString( - string: text.isEmpty ? "New task" : text, + string: text.isEmpty ? "Enter task" : text, attributes: [.font: font] ) let size = attributedString.size() @@ -107,7 +108,7 @@ struct ClickableTextField: NSViewRepresentable { // Calculate text height with wrapping private func calculateHeight(for text: String, width: CGFloat, font: NSFont) -> CGFloat { let attributedString = NSAttributedString( - string: text.isEmpty ? "New task" : text, + string: text.isEmpty ? "Enter task" : text, attributes: [.font: font] ) let textStorage = NSTextStorage(attributedString: attributedString) @@ -140,17 +141,18 @@ struct ClickableTextField: NSViewRepresentable { @MainActor func applyStyle(to textField: NSTextField, text: String, isCompleted: Bool) { - let displayText = text.isEmpty ? "New task" : text + guard !text.isEmpty else { + textField.stringValue = "" + return + } let attributes: [NSAttributedString.Key: Any] = [ .font: NSFont.systemFont(ofSize: NSFont.systemFontSize), - .foregroundColor: text.isEmpty - ? NSColor.secondaryLabelColor - : (isCompleted ? NSColor.secondaryLabelColor : NSColor.labelColor), + .foregroundColor: isCompleted ? NSColor.secondaryLabelColor : NSColor.labelColor, .strikethroughStyle: isCompleted ? NSUnderlineStyle.single.rawValue : 0, .strikethroughColor: NSColor.secondaryLabelColor, ] textField.attributedStringValue = NSAttributedString( - string: displayText, attributes: attributes) + string: text, attributes: attributes) } func handleBecomeFirstResponder() { diff --git a/ListlessMac/Views/TaskListView+NavigationHeader.swift b/ListlessMac/Views/TaskListView+NavigationHeader.swift @@ -0,0 +1,7 @@ +import SwiftUI + +extension TaskListView { + var navigationHeader: some View { + EmptyView() + } +} diff --git a/ListlessMac/Views/TaskListView+PullToCreate.swift b/ListlessMac/Views/TaskListView+PullToCreate.swift @@ -0,0 +1,7 @@ +import SwiftUI + +extension TaskListView { + var pullToCreateIndicatorRow: some View { + EmptyView() + } +} diff --git a/ListlessMac/Views/TaskRowDragGesture.swift b/ListlessMac/Views/TaskRowDragGesture.swift @@ -5,7 +5,9 @@ extension View { func taskDragGesture( isActive: Bool, taskID: UUID, - onDragStart: @escaping () -> Void + onDragStart: @escaping () -> Void, + onDragChanged: @escaping (CGPoint) -> Void = { _ in }, + onDragEnded: @escaping () -> Void = { } ) -> some View { self.modifier( TaskRowDragGesture( diff --git a/ListlessiOS/ListlessiOSApp.swift b/ListlessiOS/ListlessiOSApp.swift @@ -6,15 +6,17 @@ struct ListlessiOSApp: App { var body: some Scene { WindowGroup { - NavigationStack { - TaskListView(store: TaskStore(persistenceController: persistenceController)) - .navigationTitle("Listless") - .navigationBarTitleDisplayMode(.large) - .safeAreaInset(edge: .top) { - Color.clear.frame(height: 8) - } - .environment(\.managedObjectContext, persistenceController.viewContext) - } + TaskListView(store: TaskStore(persistenceController: persistenceController)) + .safeAreaInset(edge: .top) { + Color.clear.frame(height: 8) + } + .environment(\.managedObjectContext, persistenceController.viewContext) + .overlay(alignment: .top) { + Color.outerBackground + .opacity(0.9) + .ignoresSafeArea(edges: .top) + .frame(height: 0) + } } } } diff --git a/ListlessiOS/Views/PullToCreate.swift b/ListlessiOS/Views/PullToCreate.swift @@ -0,0 +1,49 @@ +import SwiftUI + +/// Pull distance at which the indicator signals readiness and task creation triggers. +let pullCreateThreshold: CGFloat = 70 + +struct PullToCreateIndicator: View { + let pullOffset: CGFloat + + private var progress: CGFloat { min(1, pullOffset / pullCreateThreshold) } + private var isReady: Bool { pullOffset >= pullCreateThreshold } + + // Matches the "top" gradient stop used for the first active task row + private let accentColor = Color(hue: 0.98, saturation: 0.85, brightness: 1.0) + + var body: some View { + HStack(spacing: 0) { + Rectangle() + .fill(accentColor) + .frame(width: 8) + HStack(spacing: 6) { + Image(systemName: isReady ? "checkmark" : "plus") + .foregroundStyle(.secondary) + .fontWeight(.semibold) + .animation(.easeInOut(duration: 0.15), value: isReady) + Text(isReady ? "Release to add" : "New task") + .foregroundStyle(.secondary) + .font(.body) + .animation(.easeInOut(duration: 0.15), value: isReady) + Spacer() + } + .padding(.horizontal, 16) + .frame(maxHeight: .infinity) + .background(Color.taskCard) + } + .frame(height: 56) + .clipShape( + UnevenRoundedRectangle( + topLeadingRadius: 0, bottomLeadingRadius: 0, + bottomTrailingRadius: 14, topTrailingRadius: 14 + ) + ) + .padding(.trailing, 16) + // Reveal from the top downward as the user pulls + .frame(height: min(pullOffset, 56), alignment: .top) + .clipped() + .opacity(Double(progress)) + .allowsHitTesting(false) + } +} diff --git a/ListlessiOS/Views/TappableTextField.swift b/ListlessiOS/Views/TappableTextField.swift @@ -1,66 +1,74 @@ 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. +/// UITextView that's always present, manages its own editing state, and expands +/// vertically to fit its content. 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 makeUIView(context: Context) -> UITextView { + let textView = UITextView() + textView.delegate = context.coordinator + textView.font = .systemFont(ofSize: 18) + textView.backgroundColor = .clear + textView.textContainerInset = .zero + textView.textContainer.lineFragmentPadding = 0 + textView.isScrollEnabled = false + textView.autocorrectionType = .default + textView.autocapitalizationType = .sentences + textView.returnKeyType = .done + + let placeholder = UILabel() + placeholder.text = "Enter task" + placeholder.font = .systemFont(ofSize: 18) + placeholder.textColor = .placeholderText + placeholder.tag = 100 + placeholder.translatesAutoresizingMaskIntoConstraints = false + textView.addSubview(placeholder) + NSLayoutConstraint.activate([ + placeholder.leadingAnchor.constraint(equalTo: textView.leadingAnchor), + placeholder.topAnchor.constraint(equalTo: textView.topAnchor), + ]) + + return textView } - 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) + func updateUIView(_ textView: UITextView, context: Context) { + if !textView.isFirstResponder { + applyStyle(to: textView, text: text, isCompleted: isCompleted) + } + textView.isEditable = !isCompleted + textView.isSelectable = !isCompleted + if let placeholder = textView.viewWithTag(100) as? UILabel { + placeholder.isHidden = !text.isEmpty } - textField.isEnabled = !isCompleted + } + + func sizeThatFits(_ proposal: ProposedViewSize, uiView: UITextView, context: Context) -> CGSize? { + let width = proposal.width ?? UIScreen.main.bounds.width + return uiView.sizeThatFits(CGSize(width: width, height: .infinity)) } 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) + private func applyStyle(to textView: UITextView, text: String, isCompleted: Bool) { + 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 } + textView.attributedText = NSAttributedString(string: text, attributes: attributes) } - final class Coordinator: NSObject, UITextFieldDelegate { + final class Coordinator: NSObject, UITextViewDelegate { @Binding var text: String let onEditingChanged: (Bool, _ shouldCreateNewTask: Bool) -> Void var returnKeyPressed: Bool = false @@ -73,29 +81,37 @@ struct TappableTextField: UIViewRepresentable { self.onEditingChanged = onEditingChanged } - @objc func textChanged(_ textField: UITextField) { - text = textField.text ?? "" + func textViewDidChange(_ textView: UITextView) { + text = textView.text + if let placeholder = textView.viewWithTag(100) as? UILabel { + placeholder.isHidden = !textView.text.isEmpty + } } - func textFieldDidBeginEditing(_ textField: UITextField) { + func textViewDidBeginEditing(_ textView: UITextView) { onEditingChanged(true, false) } - func textFieldDidEndEditing(_ textField: UITextField) { + func textViewDidEndEditing(_ textView: UITextView) { 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 { + func textView( + _ textView: UITextView, + shouldChangeTextIn range: NSRange, + replacementText text: String + ) -> Bool { + guard text == "\n" else { return true } + // Intercept Return: trigger new-task creation without inserting a newline. + // Return false keeps the text view as first responder, matching the UITextField + // behaviour where textFieldShouldReturn returned false — SwiftUI then + // transfers first responder atomically in the same render pass. 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/TaskListView+NavigationHeader.swift b/ListlessiOS/Views/TaskListView+NavigationHeader.swift @@ -0,0 +1,16 @@ +import SwiftUI + +extension TaskListView { + var navigationHeader: some View { + Text("Tasks") + .font(.largeTitle) + .fontWeight(.bold) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 16) + .padding(.bottom, 8) + .onTapGesture { + selectedTaskID = nil + focusedField = .scrollView + } + } +} diff --git a/ListlessiOS/Views/TaskListView+PullToCreate.swift b/ListlessiOS/Views/TaskListView+PullToCreate.swift @@ -0,0 +1,9 @@ +import SwiftUI + +extension TaskListView { + @ViewBuilder var pullToCreateIndicatorRow: some View { + if pullOffset > 0 { + PullToCreateIndicator(pullOffset: pullOffset) + } + } +} diff --git a/ListlessiOS/Views/TaskRowDragGesture.swift b/ListlessiOS/Views/TaskRowDragGesture.swift @@ -1,17 +1,21 @@ import SwiftUI -import UniformTypeIdentifiers +import UIKit extension View { func taskDragGesture( isActive: Bool, taskID: UUID, - onDragStart: @escaping () -> Void + onDragStart: @escaping () -> Void, + onDragChanged: @escaping (CGPoint) -> Void, + onDragEnded: @escaping () -> Void ) -> some View { self.modifier( TaskRowDragGesture( isActive: isActive, taskID: taskID, - onDragStart: onDragStart + onDragStart: onDragStart, + onDragChanged: onDragChanged, + onDragEnded: onDragEnded )) } } @@ -20,18 +24,85 @@ struct TaskRowDragGesture: ViewModifier { let isActive: Bool let taskID: UUID let onDragStart: () -> Void + let onDragChanged: (CGPoint) -> Void + let onDragEnded: () -> Void func body(content: Content) -> some View { - if isActive { - content - .onDrag { - onDragStart() - return NSItemProvider(object: taskID.uuidString as NSString) - } preview: { - Color.clear.frame(width: 1, height: 1) - } - } else { - content + content + .gesture( + LongPressDragGesture( + isActive: isActive, + onDragStart: onDragStart, + onDragChanged: onDragChanged, + onDragEnded: onDragEnded + ) + ) + } +} + +// MARK: - UIKit Long Press + Drag via UIGestureRecognizerRepresentable + +/// A UILongPressGestureRecognizer bridged into SwiftUI. Fires onDragStart after the +/// minimum hold duration, then tracks finger movement via onDragChanged (window coords), +/// and calls onDragEnded when the touch ends or is cancelled. +private struct LongPressDragGesture: UIGestureRecognizerRepresentable { + let isActive: Bool + let onDragStart: () -> Void + let onDragChanged: (CGPoint) -> Void + let onDragEnded: () -> Void + + func makeUIGestureRecognizer(context: Context) -> UILongPressGestureRecognizer { + let recognizer = UILongPressGestureRecognizer() + recognizer.minimumPressDuration = 0.4 + recognizer.allowableMovement = .infinity + recognizer.cancelsTouchesInView = false + recognizer.delegate = context.coordinator + return recognizer + } + + func updateUIGestureRecognizer(_ recognizer: UILongPressGestureRecognizer, context: Context) { + recognizer.isEnabled = isActive + } + + func handleUIGestureRecognizerAction( + _ recognizer: UILongPressGestureRecognizer, context: Context + ) { + switch recognizer.state { + case .began: + onDragStart() + if let window = recognizer.view?.window { + onDragChanged(recognizer.location(in: window)) + } + case .changed: + if let window = recognizer.view?.window { + onDragChanged(recognizer.location(in: window)) + } + case .ended, .cancelled, .failed: + onDragEnded() + default: + break + } + } + + func makeCoordinator(converter: CoordinateSpaceConverter) -> Coordinator { + Coordinator() + } + + final class Coordinator: NSObject, UIGestureRecognizerDelegate { + func gestureRecognizer( + _ gestureRecognizer: UIGestureRecognizer, + shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer + ) -> Bool { + // Allow simultaneous recognition with the scroll view's pan gesture + if let pan = otherGestureRecognizer as? UIPanGestureRecognizer, + pan.view is UIScrollView { + return true + } + // Prevent simultaneous recognition with the swipe UIPanGestureRecognizer + if otherGestureRecognizer is UIPanGestureRecognizer { + return false + } + return true } } }