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 +} 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" + +} 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 } } 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 + ) + ) + } + +} 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 + ) + ) + } + +} 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()) + } + +} diff --git a/App/Sources/Views/NewTaskView.swift b/App/Sources/Views/NewTaskView.swift index 7b95d0b..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 @@ -50,22 +54,36 @@ struct NewTaskView: View { Section("view.new-task.section.task") { TextField( "view.new-task.placeholder.title", - text: $viewModel.title + text: $viewModel.title, + axis: .vertical + ) + .focused( + $inputFocus, + equals: .title + ) + .onNewlineFocus( + $viewModel.title, + to: .note, + on: $inputFocus ) - .autocapitalization(.sentences) - .autocorrectionDisabled(false) - .keyboardType(.asciiCapable) 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( @@ -78,6 +96,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() } } } diff --git a/App/Sources/Views/TaskList/TaskListItem.swift b/App/Sources/Views/TaskList/TaskListItem.swift index 7a825ba..e04eb63 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 inputFocus: InputFieldFocus? + /// The todo task to display. @Bindable private var item: Todo @@ -31,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 @@ -50,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( @@ -62,17 +69,37 @@ extension TaskListView { axis: .vertical ) .font(.headline) + .focused( + $inputFocus, + equals: .title + ) + .onNewlineFocus( + $item.title, + to: .note, + on: $inputFocus + ) - if showNote { + if isNoteShown { TextField( "view.task-list-item.placeholder.note", text: note, axis: .vertical ) .font(.subheadline) + .focused( + $inputFocus, + equals: .note + ) + .onEmptyNewlineResignFocus( + text: note, + from: $inputFocus + ) } - DatePicker(selection: $item.due, displayedComponents: .date) { + DatePicker( + selection: $item.due, + displayedComponents: .date + ) { Image(systemName: "calendar") } .frame(width: 160) @@ -80,8 +107,8 @@ extension TaskListView { } .autocapitalization(.sentences) .autocorrectionDisabled(false) - .foregroundStyle(foregroundColor) - .keyboardType(.asciiCapable) + .foregroundStyle(foregroundStyleView) + .keyboardType(.default) } .animation( .easeInOut, @@ -98,6 +125,11 @@ 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,6 +138,10 @@ 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 ?? "" @@ -116,16 +152,30 @@ private extension TaskListView.Item { // 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 inputFocus != 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 } } 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