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