Task edition improvements (#9)

This PR contains the work done to improve upon the editing experience on the **Task list item and the **New task** views.

Reviewed-on: rock-n-code/todos-app#9
Co-authored-by: Javier Cicchelli <javier@rock-n-code.com>
Co-committed-by: Javier Cicchelli <javier@rock-n-code.com>
This commit was merged in pull request #9.
This commit is contained in:
2026-02-12 01:21:19 +00:00
committed by javier
parent bbd7ec264b
commit 52376695a4
11 changed files with 355 additions and 47 deletions
@@ -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
}
@@ -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"
}
@@ -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
+11 -11
View File
@@ -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
}
}
@@ -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<Field: Hashable>: ViewModifier {
// MARK: Properties
/// Binding to the text being edited.
@Binding
private var text: String
/// Binding to the focus state.
private let focusState: FocusState<Field?>.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<String>,
focusState: FocusState<Field?>.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<Field: Hashable>(
text: Binding<String>,
from focusState: FocusState<Field?>.Binding
) -> some View {
modifier(
OnEmptyNewlineResignFocusModifier(
text: text,
focusState: focusState
)
)
}
}
@@ -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<Field: Hashable>: ViewModifier {
// MARK: Properties
/// Binding to the text being edited.
@Binding
private var text: String
/// Binding to the focus state.
private let focusState: FocusState<Field?>.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<String>,
focusState: FocusState<Field?>.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<Field: Hashable>(
_ text: Binding<String>,
to field: Field,
on focusState: FocusState<Field?>.Binding
) -> some View {
modifier(
OnNewlineFocusModifier(
text: text,
focusState: focusState,
targetField: field
)
)
}
}
@@ -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())
}
}
+27 -8
View File
@@ -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) {
@@ -124,6 +124,7 @@ extension TaskListView {
items
}
.listStyle(.grouped)
.onResignFocus()
}
}
}
+60 -10
View File
@@ -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<Bool> {
.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<String> {
.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
}
}