From 5ad6c96aa2bfb40e13338fab944e26418f8c55ae Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Wed, 11 Feb 2026 19:44:20 +0100 Subject: [PATCH 01/12] Moved the MockTodoRepository repository into the Helpers folder in. the unit tests target. --- UnitTests/{ => Helpers}/Mocks/MockTodoRepository.swift | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename UnitTests/{ => Helpers}/Mocks/MockTodoRepository.swift (100%) diff --git a/UnitTests/Mocks/MockTodoRepository.swift b/UnitTests/Helpers/Mocks/MockTodoRepository.swift similarity index 100% rename from UnitTests/Mocks/MockTodoRepository.swift rename to UnitTests/Helpers/Mocks/MockTodoRepository.swift -- 2.54.0 From 3d97558f8cdf794f4d27da396a15ee46ef794946 Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Wed, 11 Feb 2026 19:52:11 +0100 Subject: [PATCH 02/12] Fixed some naming and documentation for the TaskGroupListViewModel and TaskListViewModel view models in the app target. --- .../View Models/TaskGroupListViewModel.swift | 36 +++++++++---------- .../View Models/TaskListViewModel.swift | 22 ++++++------ 2 files changed, 29 insertions(+), 29 deletions(-) diff --git a/App/Sources/View Models/TaskGroupListViewModel.swift b/App/Sources/View Models/TaskGroupListViewModel.swift index e420f98..7391a1e 100644 --- a/App/Sources/View Models/TaskGroupListViewModel.swift +++ b/App/Sources/View Models/TaskGroupListViewModel.swift @@ -9,8 +9,8 @@ extension TaskGroupListView { /// It follows the MVVM pattern to separate presentation logic from the view layer. /// /// ## Responsibilities - /// - Providing access to all task groups for iteration - /// - Efficiently counting tasks per group using SwiftData fetch descriptors + /// - Providing access to all todo task groups for iteration + /// - Efficiently counting todo tasks per group using SwiftData fetch descriptors /// - Managing date calculations for group categorization @Observable final class ViewModel { @@ -38,46 +38,46 @@ extension TaskGroupListView { /// /// This computed property provides access to all task group cases for iteration in the view. /// - /// - Returns: An array of all `TodoGroup` cases in a predetermined order. + /// - Returns: An array of all the todo group cases in a predetermined order. var groups: [TodoGroup] { TodoGroup.allCases } // MARK: Functions - /// Calculates the number of tasks for a given task group from an in-memory collection. + /// Calculates the number of todo tasks for a given todo group from an in-memory collection. /// - /// This method filters the provided tasks array to count only those tasks that belong - /// to the specified group based on their due date and completion status. Tasks are + /// This method filters the provided todo tasks array to count only those todo tasks that belong + /// to the specified group based on their due date and completion status. Todo tasks are /// categorized as follows: /// - /// - **Completed**: Tasks with a non-nil `completed` date - /// - **Overdue**: Incomplete tasks due before today - /// - **Today**: Incomplete tasks due today (from start of day to end of day) - /// - **Upcoming**: Incomplete tasks due tomorrow or later + /// - **Completed**: Todo tasks with a non-nil `completed` date + /// - **Overdue**: Incomplete todo tasks due before today + /// - **Today**: Incomplete todo tasks due today (from start of day to end of day) + /// - **Upcoming**: Incomplete todo tasks due tomorrow or later /// /// - Parameters: - /// - tasks: The array of tasks to filter and count. + /// - todos: The array of todo tasks to filter and count. /// - group: The task group to count tasks for. /// - Returns: The number of tasks in the specified group. func count( - _ tasks: [Todo], + _ todos: [Todo], for group: TodoGroup ) -> Int { let today = calendar.startOfDay(for: .now) let tomorrow = calendar.date(byAdding: .day, value: 1, to: today) ?? .now - return tasks - .filter { task in + return todos + .filter { todo in switch group { case .completed: - task.completed != nil + todo.completed != nil case .overdue: - task.completed == nil && task.due < today + todo.completed == nil && todo.due < today case .today: - task.completed == nil && task.due >= today && task.due < tomorrow + todo.completed == nil && todo.due >= today && todo.due < tomorrow case .upcoming: - task.completed == nil && task.due >= tomorrow + todo.completed == nil && todo.due >= tomorrow } } .count diff --git a/App/Sources/View Models/TaskListViewModel.swift b/App/Sources/View Models/TaskListViewModel.swift index b4aae30..4514eff 100644 --- a/App/Sources/View Models/TaskListViewModel.swift +++ b/App/Sources/View Models/TaskListViewModel.swift @@ -85,52 +85,52 @@ extension TaskListView { // MARK: Methods - /// Deletes tasks at the specified indices. + /// Deletes todo tasks at the specified indices. /// /// This method removes tasks from the repository after a swipe-to-delete action. /// The deletion is performed through the repository, which handles /// persistence and triggers automatic UI updates. /// /// - Parameters: - /// - tasks: The current array of tasks. + /// - todos: The current array of todo tasks. /// - offsets: The index set of tasks to delete. /// - repository: The repository to perform the deletion. func delete( - _ tasks: [Todo], + _ todos: [Todo], at offsets: IndexSet, from repository: TodoRepository ) { for index in offsets { - repository.delete(tasks[index]) + repository.delete(todos[index]) } } - /// Reorders tasks after a drag and drop operation. + /// Reorders todo tasks after a drag and drop operation. /// /// This method is called when users drag and drop tasks to reorder them within a group. - /// It updates the `order` property of all tasks to reflect their new positions, ensuring + /// It updates the `order` property of all todo tasks to reflect their new positions, ensuring /// that the user's manual ordering preference is persisted. /// /// - Parameters: - /// - tasks: The current array of tasks before reordering. + /// - todos: The current array of todo tasks before reordering. /// - source: The index set of items being moved (typically a single index). /// - destination: The destination index where items should be inserted. /// - Note: This method should only be called for groups where `canReorder` is `true`. /// The view layer is responsible for only enabling drag and drop for applicable groups. /// - SeeAlso: ``TodoGroup/canReorder`` for determining which groups support reordering. func reorderTasks( - _ tasks: [Todo], + _ todos: [Todo], from source: IndexSet, to destination: Int ) { - var updatedTasks = tasks + var todosUpdated = todos - updatedTasks.move( + todosUpdated.move( fromOffsets: source, toOffset: destination ) - for (index, task) in updatedTasks.enumerated() { + for (index, task) in todosUpdated.enumerated() { task.order = index } } -- 2.54.0 From 127e3d92070e28dcd78b56b4cb6c0be8fd567b0a Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Wed, 11 Feb 2026 23:01:56 +0100 Subject: [PATCH 03/12] Updated the TaskListItem view in the app target to add text field focus support. --- App/Sources/Views/TaskList/TaskListItem.swift | 130 ++++++++++++++++-- 1 file changed, 121 insertions(+), 9 deletions(-) diff --git a/App/Sources/Views/TaskList/TaskListItem.swift b/App/Sources/Views/TaskList/TaskListItem.swift index 7a825ba..a2fbfdc 100644 --- a/App/Sources/Views/TaskList/TaskListItem.swift +++ b/App/Sources/Views/TaskList/TaskListItem.swift @@ -18,6 +18,10 @@ extension TaskListView { // MARK: Properties + /// Tracks which text field currently has keyboard focus. + @FocusState + private var focusOnField: FieldFocus? + /// The todo task to display. @Bindable private var item: Todo @@ -58,18 +62,20 @@ extension TaskListView { ) { TextField( "view.task-list-item.placeholder.title", - text: $item.title, + text: title, axis: .vertical ) .font(.headline) + .focused($focusOnField, equals: .title) - if showNote { + if isNoteShown { TextField( "view.task-list-item.placeholder.note", text: note, axis: .vertical ) .font(.subheadline) + .focused($focusOnField, equals: .note) } DatePicker(selection: $item.due, displayedComponents: .date) { @@ -80,7 +86,7 @@ extension TaskListView { } .autocapitalization(.sentences) .autocorrectionDisabled(false) - .foregroundStyle(foregroundColor) + .foregroundStyle(foregroundStyleView) .keyboardType(.asciiCapable) } .animation( @@ -92,12 +98,31 @@ extension TaskListView { } +// MARK: - Enumerations + +private extension TaskListView.Item { + + /// Identifies which text field should receive keyboard focus. + enum FieldFocus { + /// The note text field has focus. + case note + /// The title text field has focus. + case title + } + +} + // MARK: - Helpers private extension TaskListView.Item { // MARK: Bindings + /// A binding to the task's completion state. + /// + /// This computed binding converts between the optional `completed` date and a boolean + /// value for the completion toggle. When set to `true`, it marks the task as completed + /// with the current date; when set to `false`, it clears the completion date. var isCompleted: Binding { .init { item.completed != nil @@ -106,26 +131,107 @@ private extension TaskListView.Item { } } + /// A binding to the note text of a todo task. + /// + /// This computed binding provides a non-optional string binding for the note field, + /// converting `nil` values to empty strings for seamless text field interaction. var note: Binding { .init { item.note ?? "" } set: { - item.note = $0.isEmpty ? nil : $0 + setNote(with: $0) + } + } + + /// A binding to the title text of a todo task. + /// + /// This computed binding provides access to the title of a todo task with special handling + /// for newline characters. + var title: Binding { + .init { + item.title + } set: { + setTitle(with: $0) } } // MARK: Computed - var foregroundColor: Color { + /// The foreground color style for the components in this view. + /// + /// Returns `.primary` for incomplete tasks and `.secondary` for completed tasks, + /// providing visual feedback about the task's completion state by dimming + /// completed items. + var foregroundStyleView: Color { item.completed == nil ? .primary : .secondary } - var showNote: Bool { - guard let note = item.note else { - return false + /// Determines whether the note field should be visible. + /// + /// The note field is shown when: + /// - Any text field has focus (allowing users to add a note even if one doesn't exist yet) + /// - The todo task has a note with non-whitespace content + var isNoteShown: Bool { + guard focusOnField != nil else { + guard let note = item.note else { + return false + } + + return note.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false } - return note.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false + return true + } + + // MARK: Functions + + /// Updates the note of a todo task with special handling for newlines and empty content. + /// + /// This method provides intelligent note field behavior: + /// - Empty text immediately clears the note + /// - Newlines at the end of the text trigger special handling: + /// - If the text is only whitespace/newlines, the note is cleared and focus is removed + /// - Otherwise, the text (including newlines) is saved normally + /// - All other text is saved as-is + /// + /// This allows users to press return on an empty note field to dismiss it, + /// while still supporting multi-line notes. + /// + /// - Parameter text: The new note text to set. + func setNote(with text: String) { + guard text.isEmpty == false else { + item.note = text + return + } + + if text.last == .newLine { + guard text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == true else { + item.note = text + return + } + + item.note = nil + focusOnField = nil + } + else { + item.note = text + } + } + + /// Updates the title of a todo task with special handling for newline characters. + /// + /// When the user presses return in the title field (adding a newline), this function + /// prevents the newline from being added to the title and instead moves keyboard + /// focus to the note field. If no newline is present, the title is updated normally. + /// + /// - Parameter text: The new title text to set. + func setTitle(with text: String) { + guard text.lastIndex(of: .newLine) == nil else { + focusOnField = .note + return + } + + item.title = text } } @@ -146,3 +252,9 @@ private extension TaskListView.Item { } .listStyle(.grouped) } + +// MARK: - Character+Constants + +private extension Character { + static let newLine: Self = "\n" +} -- 2.54.0 From 1f6ec1abf0f3970f8dd3572564f8728708d9937d Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Wed, 11 Feb 2026 23:48:47 +0100 Subject: [PATCH 04/12] Added sensory feedback to the completion toggle of the TaskListItem view in the app target. --- App/Sources/Views/TaskList/TaskListItem.swift | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/App/Sources/Views/TaskList/TaskListItem.swift b/App/Sources/Views/TaskList/TaskListItem.swift index a2fbfdc..392b96a 100644 --- a/App/Sources/Views/TaskList/TaskListItem.swift +++ b/App/Sources/Views/TaskList/TaskListItem.swift @@ -54,6 +54,9 @@ extension TaskListView { "view.task-list-item.placeholder.completed", isOn: isCompleted ) + .sensoryFeedback(trigger: item.completed) { + item.completed != nil ? .increase : .decrease + } .toggleStyle(.completion) VStack( @@ -66,7 +69,10 @@ extension TaskListView { axis: .vertical ) .font(.headline) - .focused($focusOnField, equals: .title) + .focused( + $focusOnField, + equals: .title + ) if isNoteShown { TextField( @@ -75,10 +81,16 @@ extension TaskListView { axis: .vertical ) .font(.subheadline) - .focused($focusOnField, equals: .note) + .focused( + $focusOnField, + equals: .note + ) } - DatePicker(selection: $item.due, displayedComponents: .date) { + DatePicker( + selection: $item.due, + displayedComponents: .date + ) { Image(systemName: "calendar") } .frame(width: 160) -- 2.54.0 From 61dc7bc3f84dc0457d544b194d1145d947c8b56d Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Thu, 12 Feb 2026 00:01:11 +0100 Subject: [PATCH 05/12] Implemented the ResignFocusModifier view modifier in the app target. --- .../View Modifiers/ResignFocusModifier.swift | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 App/Sources/View Modifiers/ResignFocusModifier.swift diff --git a/App/Sources/View Modifiers/ResignFocusModifier.swift b/App/Sources/View Modifiers/ResignFocusModifier.swift new file mode 100644 index 0000000..e6ae001 --- /dev/null +++ b/App/Sources/View Modifiers/ResignFocusModifier.swift @@ -0,0 +1,43 @@ +import SwiftUI + +/// A view modifier that resigns the first responder status when the view is tapped. +/// +/// This modifier is useful for dismissing the keyboard or other input controls +/// when the user taps on an area of the view that doesn't have interactive content. +struct ResignFocusModifier: ViewModifier { + + // MARK: Body + + /// The content and behavior of the view modifier. + /// + /// Adds a tap gesture that sends the `resignFirstResponder` action + /// to dismiss any active first responder (such as a text field). + func body(content: Content) -> some View { + content + .onTapGesture { + UIApplication.shared.sendAction( + #selector(UIResponder.resignFirstResponder), + to: nil, + from: nil, + for: nil + ) + } + } + +} + +// MARK: - View Extension + +extension View { + + /// Adds a tap gesture that resigns the first responder when the view is tapped. + /// + /// This is useful for dismissing the keyboard or other input controls + /// when the user taps outside of an interactive element. + /// + /// - Returns: A view that resigns first responder status on tap. + func onResignFocus() -> some View { + modifier(ResignFocusModifier()) + } + +} -- 2.54.0 From 2ffd7522dc02e2f918d8fa69d48ec47e64931b86 Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Thu, 12 Feb 2026 00:03:55 +0100 Subject: [PATCH 06/12] Made use of the "onResignFocus()" function within the NewTaskView and the TaskListContent views in the app target. --- App/Sources/Views/NewTaskView.swift | 4 +++- App/Sources/Views/TaskList/TaskListContent.swift | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/App/Sources/Views/NewTaskView.swift b/App/Sources/Views/NewTaskView.swift index 7b95d0b..e5485de 100644 --- a/App/Sources/Views/NewTaskView.swift +++ b/App/Sources/Views/NewTaskView.swift @@ -50,7 +50,8 @@ struct NewTaskView: View { Section("view.new-task.section.task") { TextField( "view.new-task.placeholder.title", - text: $viewModel.title + text: $viewModel.title, + axis: .vertical ) .autocapitalization(.sentences) .autocorrectionDisabled(false) @@ -78,6 +79,7 @@ struct NewTaskView: View { } .navigationTitle("view.new-task.navigation.title") .navigationBarTitleDisplayMode(.inline) + .onResignFocus() .toolbar { ToolbarItem(placement: .cancellationAction) { Button(role: .cancel) { diff --git a/App/Sources/Views/TaskList/TaskListContent.swift b/App/Sources/Views/TaskList/TaskListContent.swift index d093caa..ed79fc7 100644 --- a/App/Sources/Views/TaskList/TaskListContent.swift +++ b/App/Sources/Views/TaskList/TaskListContent.swift @@ -124,6 +124,7 @@ extension TaskListView { items } .listStyle(.grouped) + .onResignFocus() } } } -- 2.54.0 From 4ce9e2556205c3f4a7eec4765ef32eb94cea4b1a Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Thu, 12 Feb 2026 01:20:17 +0100 Subject: [PATCH 07/12] Defined the InputFieldFocus enumeration in the app target. --- App/Sources/Enumerations/InputFieldFocus.swift | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 App/Sources/Enumerations/InputFieldFocus.swift diff --git a/App/Sources/Enumerations/InputFieldFocus.swift b/App/Sources/Enumerations/InputFieldFocus.swift new file mode 100644 index 0000000..72abfb2 --- /dev/null +++ b/App/Sources/Enumerations/InputFieldFocus.swift @@ -0,0 +1,17 @@ +/// Identifies which input field should receive keyboard focus in todo task editing interfaces. +/// +/// This enumeration is designed to work with focus state property wrapper, enabling programmatic +/// control of keyboard focus between different text input fields when editing todo tasks. +enum InputFieldFocus { + /// The note text field has keyboard focus. + /// + /// This case represents the optional multi-line note field where users can + /// add additional details about a todo task. + case note + + /// The title text field has keyboard focus. + /// + /// This case represents the primary title field where users enter the + /// title of a todo task. + case title +} -- 2.54.0 From 0bba9f5766db05c088c63dfe61c5fe76447665e2 Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Thu, 12 Feb 2026 01:37:47 +0100 Subject: [PATCH 08/12] Defined the Character+Constants extension in. the app target. --- App/Sources/Extensions/Character+Constants.swift | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 App/Sources/Extensions/Character+Constants.swift diff --git a/App/Sources/Extensions/Character+Constants.swift b/App/Sources/Extensions/Character+Constants.swift new file mode 100644 index 0000000..3b02b5d --- /dev/null +++ b/App/Sources/Extensions/Character+Constants.swift @@ -0,0 +1,11 @@ +// MARK: - Character+Constants + +extension Character { + + /// The newline character -`\n`-. + /// + /// Use this constant when checking for or comparing against newline characters + /// in text processing, making the code more expressive and self-documenting. + static let newline: Character = "\n" + +} -- 2.54.0 From b5ccb1cdac8763bdde8a373cb59eb1ddd7999dc9 Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Thu, 12 Feb 2026 01:39:55 +0100 Subject: [PATCH 09/12] Implemented the OnNewlineFocusModifier view modifier in the app target. --- .../OnNewlineFocusModifier.swift | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 App/Sources/View Modifiers/OnNewlineFocusModifier.swift diff --git a/App/Sources/View Modifiers/OnNewlineFocusModifier.swift b/App/Sources/View Modifiers/OnNewlineFocusModifier.swift new file mode 100644 index 0000000..d9efb52 --- /dev/null +++ b/App/Sources/View Modifiers/OnNewlineFocusModifier.swift @@ -0,0 +1,86 @@ +import SwiftUI + +/// A view modifier that intercepts newlines in a text field and transfers focus to another field. +/// +/// This modifier monitors text changes in a `TextField` component and prevents newlines +/// from being added. Instead, when a newline is detected, it updates the focus state to move to +/// a specified field. +struct OnNewlineFocusModifier: ViewModifier { + + // MARK: Properties + + /// Binding to the text being edited. + @Binding + private var text: String + + /// Binding to the focus state. + private let focusState: FocusState.Binding + + /// The field to focus when a newline is detected. + private let targetField: Field + + // MARK: Initializers + + /// Creates a modifier that transfers focus on newline. + /// + /// - Parameters: + /// - text: A binding to the text being edited. + /// - focusState: A binding to the focus state. + /// - targetField: The field to focus when a newline is detected. + init( + text: Binding, + focusState: FocusState.Binding, + targetField: Field + ) { + self._text = text + + self.focusState = focusState + self.targetField = targetField + } + + // MARK: Body + + /// The content and behavior of the view modifier. + func body(content: Content) -> some View { + content + .onChange(of: text) { oldValue, newValue in + guard newValue.contains(.newline) else { + return + } + + text = oldValue + focusState.wrappedValue = targetField + } + } + +} + +// MARK: - View Extension + +extension View { + + /// Transfers focus to another field when the user presses return in a text field. + /// + /// This modifier prevents newlines from being added and instead moves keyboard focus + /// to the specified field. Useful for creating a form-like flow between text fields. + /// + /// - Parameters: + /// - text: A binding to the text being edited. + /// - field: The field to focus when return is pressed. + /// - focusState: A binding to the focus state. + /// - Returns: A modified view with focus transfer behavior. + func onNewlineFocus( + _ text: Binding, + to field: Field, + on focusState: FocusState.Binding + ) -> some View { + modifier( + OnNewlineFocusModifier( + text: text, + focusState: focusState, + targetField: field + ) + ) + } + +} -- 2.54.0 From 998f8fe33046d8397bfcb850ef970f73cd50daac Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Thu, 12 Feb 2026 01:51:06 +0100 Subject: [PATCH 10/12] Implemented the OnEmptyNewlineResignFocus view modifier in the app target. --- .../OnEmptyNewlineResignFocusModifier.swift | 81 +++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 App/Sources/View Modifiers/OnEmptyNewlineResignFocusModifier.swift diff --git a/App/Sources/View Modifiers/OnEmptyNewlineResignFocusModifier.swift b/App/Sources/View Modifiers/OnEmptyNewlineResignFocusModifier.swift new file mode 100644 index 0000000..c909894 --- /dev/null +++ b/App/Sources/View Modifiers/OnEmptyNewlineResignFocusModifier.swift @@ -0,0 +1,81 @@ +import SwiftUI + +/// A view modifier that clears text and removes focus when return is pressed on empty content. +/// +/// This modifier monitors text changes and provides smart handling of newlines: +/// - Empty text is allowed normally +/// - Newlines at the end of whitespace-only text clear the field and remove focus +/// - Multi-line content with actual text is preserved normally +struct OnEmptyNewlineResignFocusModifier: ViewModifier { + + // MARK: Properties + + /// Binding to the text being edited. + @Binding + private var text: String + + /// Binding to the focus state. + private let focusState: FocusState.Binding + + // MARK: Initializers + + /// Creates a modifier that clears text on empty newline. + /// + /// - Parameters: + /// - text: A binding to the text being edited. + /// - focusState: A binding to the focus state. + init( + text: Binding, + focusState: FocusState.Binding + ) { + self._text = text + self.focusState = focusState + } + + // MARK: Body + + /// The content and behavior of the view modifier. + func body(content: Content) -> some View { + content + .onChange(of: text) { oldValue, newValue in + guard newValue.isEmpty == false else { + return + } + + if newValue.last == .newline { + if newValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + text = "" + focusState.wrappedValue = nil + } + } + } + } + +} + +// MARK: - View Extension + +extension View { + + /// Clears the text and removes focus when return is pressed on empty content. + /// + /// This modifier allows users to dismiss an optional text field by pressing return + /// when the field is empty or contains only whitespace. Multi-line content is preserved normally. + /// + /// - Parameters: + /// - focusState: A binding to the focus state. + /// - text: A binding to the text being edited. + /// - Returns: A modified view with clear-on-empty behavior. + func onEmptyNewlineResignFocus( + text: Binding, + from focusState: FocusState.Binding + ) -> some View { + modifier( + OnEmptyNewlineResignFocusModifier( + text: text, + focusState: focusState + ) + ) + } + +} -- 2.54.0 From 81859cf841d63a5e2af7ebad777b5901908dda34 Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Thu, 12 Feb 2026 01:52:45 +0100 Subject: [PATCH 11/12] Updated the TaskListItem view in the app target to use the new view modifiers. --- App/Sources/Views/TaskList/TaskListItem.swift | 108 +++--------------- 1 file changed, 17 insertions(+), 91 deletions(-) diff --git a/App/Sources/Views/TaskList/TaskListItem.swift b/App/Sources/Views/TaskList/TaskListItem.swift index 392b96a..e04eb63 100644 --- a/App/Sources/Views/TaskList/TaskListItem.swift +++ b/App/Sources/Views/TaskList/TaskListItem.swift @@ -20,7 +20,7 @@ extension TaskListView { /// Tracks which text field currently has keyboard focus. @FocusState - private var focusOnField: FieldFocus? + private var inputFocus: InputFieldFocus? /// The todo task to display. @Bindable @@ -35,7 +35,7 @@ extension TaskListView { /// /// - Parameter item: The todo task to display and edit. init(_ item: Todo) { - _item = Bindable(item) + self._item = Bindable(item) } // MARK: Body @@ -65,14 +65,19 @@ extension TaskListView { ) { TextField( "view.task-list-item.placeholder.title", - text: title, + text: $item.title, axis: .vertical ) .font(.headline) .focused( - $focusOnField, + $inputFocus, equals: .title ) + .onNewlineFocus( + $item.title, + to: .note, + on: $inputFocus + ) if isNoteShown { TextField( @@ -82,9 +87,13 @@ extension TaskListView { ) .font(.subheadline) .focused( - $focusOnField, + $inputFocus, equals: .note ) + .onEmptyNewlineResignFocus( + text: note, + from: $inputFocus + ) } DatePicker( @@ -99,7 +108,7 @@ extension TaskListView { .autocapitalization(.sentences) .autocorrectionDisabled(false) .foregroundStyle(foregroundStyleView) - .keyboardType(.asciiCapable) + .keyboardType(.default) } .animation( .easeInOut, @@ -110,20 +119,6 @@ extension TaskListView { } -// MARK: - Enumerations - -private extension TaskListView.Item { - - /// Identifies which text field should receive keyboard focus. - enum FieldFocus { - /// The note text field has focus. - case note - /// The title text field has focus. - case title - } - -} - // MARK: - Helpers private extension TaskListView.Item { @@ -151,19 +146,7 @@ private extension TaskListView.Item { .init { item.note ?? "" } set: { - setNote(with: $0) - } - } - - /// A binding to the title text of a todo task. - /// - /// This computed binding provides access to the title of a todo task with special handling - /// for newline characters. - var title: Binding { - .init { - item.title - } set: { - setTitle(with: $0) + item.note = $0.isEmpty ? nil : $0 } } @@ -184,7 +167,7 @@ private extension TaskListView.Item { /// - Any text field has focus (allowing users to add a note even if one doesn't exist yet) /// - The todo task has a note with non-whitespace content var isNoteShown: Bool { - guard focusOnField != nil else { + guard inputFocus != nil else { guard let note = item.note else { return false } @@ -195,57 +178,6 @@ private extension TaskListView.Item { return true } - // MARK: Functions - - /// Updates the note of a todo task with special handling for newlines and empty content. - /// - /// This method provides intelligent note field behavior: - /// - Empty text immediately clears the note - /// - Newlines at the end of the text trigger special handling: - /// - If the text is only whitespace/newlines, the note is cleared and focus is removed - /// - Otherwise, the text (including newlines) is saved normally - /// - All other text is saved as-is - /// - /// This allows users to press return on an empty note field to dismiss it, - /// while still supporting multi-line notes. - /// - /// - Parameter text: The new note text to set. - func setNote(with text: String) { - guard text.isEmpty == false else { - item.note = text - return - } - - if text.last == .newLine { - guard text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == true else { - item.note = text - return - } - - item.note = nil - focusOnField = nil - } - else { - item.note = text - } - } - - /// Updates the title of a todo task with special handling for newline characters. - /// - /// When the user presses return in the title field (adding a newline), this function - /// prevents the newline from being added to the title and instead moves keyboard - /// focus to the note field. If no newline is present, the title is updated normally. - /// - /// - Parameter text: The new title text to set. - func setTitle(with text: String) { - guard text.lastIndex(of: .newLine) == nil else { - focusOnField = .note - return - } - - item.title = text - } - } // MARK: - Previews @@ -264,9 +196,3 @@ private extension TaskListView.Item { } .listStyle(.grouped) } - -// MARK: - Character+Constants - -private extension Character { - static let newLine: Self = "\n" -} -- 2.54.0 From cf905540017ca7f5beafbfd79a5857610b783f50 Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Thu, 12 Feb 2026 02:17:15 +0100 Subject: [PATCH 12/12] Updated the NewTaskView view in the app target to use the new view modifiers. --- App/Sources/Views/NewTaskView.swift | 31 ++++++++++++++++++++++------- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/App/Sources/Views/NewTaskView.swift b/App/Sources/Views/NewTaskView.swift index e5485de..b2a08e8 100644 --- a/App/Sources/Views/NewTaskView.swift +++ b/App/Sources/Views/NewTaskView.swift @@ -21,6 +21,10 @@ struct NewTaskView: View { /// SwiftData model context for saving todo tasks. @Environment(\.modelContext) private var modelContext + + /// Tracks which text field currently has keyboard focus. + @FocusState + private var inputFocus: InputFieldFocus? /// The view model managing the state and logic for this view. @State @@ -53,20 +57,33 @@ struct NewTaskView: View { text: $viewModel.title, axis: .vertical ) - .autocapitalization(.sentences) - .autocorrectionDisabled(false) - .keyboardType(.asciiCapable) + .focused( + $inputFocus, + equals: .title + ) + .onNewlineFocus( + $viewModel.title, + to: .note, + on: $inputFocus + ) TextField( "view.new-task.placeholder.note", text: $viewModel.note, axis: .vertical ) - .autocapitalization(.sentences) - .autocorrectionDisabled(false) - .keyboardType(.asciiCapable) - .lineLimit(3 ... 6) + .focused( + $inputFocus, + equals: .note + ) + .onEmptyNewlineResignFocus( + text: $viewModel.note, + from: $inputFocus + ) } + .autocapitalization(.sentences) + .autocorrectionDisabled(false) + .keyboardType(.default) Section("view.new-task.section.due-date") { DatePicker( -- 2.54.0