Task edition improvements #9

Merged
javier merged 12 commits from app/todo-edit into main 2026-02-12 01:21:19 +00:00
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. /// It follows the MVVM pattern to separate presentation logic from the view layer.
/// ///
/// ## Responsibilities /// ## Responsibilities
/// - Providing access to all task groups for iteration /// - Providing access to all todo task groups for iteration
/// - Efficiently counting tasks per group using SwiftData fetch descriptors /// - Efficiently counting todo tasks per group using SwiftData fetch descriptors
/// - Managing date calculations for group categorization /// - Managing date calculations for group categorization
@Observable @Observable
final class ViewModel { final class ViewModel {
@@ -38,46 +38,46 @@ extension TaskGroupListView {
/// ///
/// This computed property provides access to all task group cases for iteration in the view. /// 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] { var groups: [TodoGroup] {
TodoGroup.allCases TodoGroup.allCases
} }
// MARK: Functions // 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 /// 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. Tasks are /// to the specified group based on their due date and completion status. Todo tasks are
/// categorized as follows: /// categorized as follows:
/// ///
/// - **Completed**: Tasks with a non-nil `completed` date /// - **Completed**: Todo tasks with a non-nil `completed` date
/// - **Overdue**: Incomplete tasks due before today /// - **Overdue**: Incomplete todo tasks due before today
/// - **Today**: Incomplete tasks due today (from start of day to end of day) /// - **Today**: Incomplete todo tasks due today (from start of day to end of day)
/// - **Upcoming**: Incomplete tasks due tomorrow or later /// - **Upcoming**: Incomplete todo tasks due tomorrow or later
/// ///
/// - Parameters: /// - 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. /// - group: The task group to count tasks for.
/// - Returns: The number of tasks in the specified group. /// - Returns: The number of tasks in the specified group.
func count( func count(
_ tasks: [Todo], _ todos: [Todo],
for group: TodoGroup for group: TodoGroup
) -> Int { ) -> Int {
let today = calendar.startOfDay(for: .now) let today = calendar.startOfDay(for: .now)
let tomorrow = calendar.date(byAdding: .day, value: 1, to: today) ?? .now let tomorrow = calendar.date(byAdding: .day, value: 1, to: today) ?? .now
return tasks return todos
.filter { task in .filter { todo in
switch group { switch group {
case .completed: case .completed:
task.completed != nil todo.completed != nil
case .overdue: case .overdue:
task.completed == nil && task.due < today todo.completed == nil && todo.due < today
case .today: case .today:
task.completed == nil && task.due >= today && task.due < tomorrow todo.completed == nil && todo.due >= today && todo.due < tomorrow
case .upcoming: case .upcoming:
task.completed == nil && task.due >= tomorrow todo.completed == nil && todo.due >= tomorrow
} }
} }
.count .count
+11 -11
View File
@@ -85,52 +85,52 @@ extension TaskListView {
// MARK: Methods // 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. /// This method removes tasks from the repository after a swipe-to-delete action.
/// The deletion is performed through the repository, which handles /// The deletion is performed through the repository, which handles
/// persistence and triggers automatic UI updates. /// persistence and triggers automatic UI updates.
/// ///
/// - Parameters: /// - Parameters:
/// - tasks: The current array of tasks. /// - todos: The current array of todo tasks.
/// - offsets: The index set of tasks to delete. /// - offsets: The index set of tasks to delete.
/// - repository: The repository to perform the deletion. /// - repository: The repository to perform the deletion.
func delete( func delete(
_ tasks: [Todo], _ todos: [Todo],
at offsets: IndexSet, at offsets: IndexSet,
from repository: TodoRepository from repository: TodoRepository
) { ) {
for index in offsets { 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. /// 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. /// that the user's manual ordering preference is persisted.
/// ///
/// - Parameters: /// - 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). /// - source: The index set of items being moved (typically a single index).
/// - destination: The destination index where items should be inserted. /// - destination: The destination index where items should be inserted.
/// - Note: This method should only be called for groups where `canReorder` is `true`. /// - 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. /// The view layer is responsible for only enabling drag and drop for applicable groups.
/// - SeeAlso: ``TodoGroup/canReorder`` for determining which groups support reordering. /// - SeeAlso: ``TodoGroup/canReorder`` for determining which groups support reordering.
func reorderTasks( func reorderTasks(
_ tasks: [Todo], _ todos: [Todo],
from source: IndexSet, from source: IndexSet,
to destination: Int to destination: Int
) { ) {
var updatedTasks = tasks var todosUpdated = todos
updatedTasks.move( todosUpdated.move(
fromOffsets: source, fromOffsets: source,
toOffset: destination toOffset: destination
) )
for (index, task) in updatedTasks.enumerated() { for (index, task) in todosUpdated.enumerated() {
task.order = index 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())
}
}
+26 -7
View File
@@ -22,6 +22,10 @@ struct NewTaskView: View {
@Environment(\.modelContext) @Environment(\.modelContext)
private var 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. /// The view model managing the state and logic for this view.
@State @State
private var viewModel: ViewModel private var viewModel: ViewModel
@@ -50,22 +54,36 @@ struct NewTaskView: View {
Section("view.new-task.section.task") { Section("view.new-task.section.task") {
TextField( TextField(
"view.new-task.placeholder.title", "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( TextField(
"view.new-task.placeholder.note", "view.new-task.placeholder.note",
text: $viewModel.note, text: $viewModel.note,
axis: .vertical axis: .vertical
) )
.focused(
$inputFocus,
equals: .note
)
.onEmptyNewlineResignFocus(
text: $viewModel.note,
from: $inputFocus
)
}
.autocapitalization(.sentences) .autocapitalization(.sentences)
.autocorrectionDisabled(false) .autocorrectionDisabled(false)
.keyboardType(.asciiCapable) .keyboardType(.default)
.lineLimit(3 ... 6)
}
Section("view.new-task.section.due-date") { Section("view.new-task.section.due-date") {
DatePicker( DatePicker(
@@ -78,6 +96,7 @@ struct NewTaskView: View {
} }
.navigationTitle("view.new-task.navigation.title") .navigationTitle("view.new-task.navigation.title")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.onResignFocus()
.toolbar { .toolbar {
ToolbarItem(placement: .cancellationAction) { ToolbarItem(placement: .cancellationAction) {
Button(role: .cancel) { Button(role: .cancel) {
@@ -124,6 +124,7 @@ extension TaskListView {
items items
} }
.listStyle(.grouped) .listStyle(.grouped)
.onResignFocus()
} }
} }
} }
+57 -7
View File
@@ -18,6 +18,10 @@ extension TaskListView {
// MARK: Properties // MARK: Properties
/// Tracks which text field currently has keyboard focus.
@FocusState
private var inputFocus: InputFieldFocus?
/// The todo task to display. /// The todo task to display.
@Bindable @Bindable
private var item: Todo private var item: Todo
@@ -31,7 +35,7 @@ extension TaskListView {
/// ///
/// - Parameter item: The todo task to display and edit. /// - Parameter item: The todo task to display and edit.
init(_ item: Todo) { init(_ item: Todo) {
_item = Bindable(item) self._item = Bindable(item)
} }
// MARK: Body // MARK: Body
@@ -50,6 +54,9 @@ extension TaskListView {
"view.task-list-item.placeholder.completed", "view.task-list-item.placeholder.completed",
isOn: isCompleted isOn: isCompleted
) )
.sensoryFeedback(trigger: item.completed) {
item.completed != nil ? .increase : .decrease
}
.toggleStyle(.completion) .toggleStyle(.completion)
VStack( VStack(
@@ -62,17 +69,37 @@ extension TaskListView {
axis: .vertical axis: .vertical
) )
.font(.headline) .font(.headline)
.focused(
$inputFocus,
equals: .title
)
.onNewlineFocus(
$item.title,
to: .note,
on: $inputFocus
)
if showNote { if isNoteShown {
TextField( TextField(
"view.task-list-item.placeholder.note", "view.task-list-item.placeholder.note",
text: note, text: note,
axis: .vertical axis: .vertical
) )
.font(.subheadline) .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") Image(systemName: "calendar")
} }
.frame(width: 160) .frame(width: 160)
@@ -80,8 +107,8 @@ extension TaskListView {
} }
.autocapitalization(.sentences) .autocapitalization(.sentences)
.autocorrectionDisabled(false) .autocorrectionDisabled(false)
.foregroundStyle(foregroundColor) .foregroundStyle(foregroundStyleView)
.keyboardType(.asciiCapable) .keyboardType(.default)
} }
.animation( .animation(
.easeInOut, .easeInOut,
@@ -98,6 +125,11 @@ private extension TaskListView.Item {
// MARK: Bindings // 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> { var isCompleted: Binding<Bool> {
.init { .init {
item.completed != nil 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> { var note: Binding<String> {
.init { .init {
item.note ?? "" item.note ?? ""
@@ -116,11 +152,22 @@ private extension TaskListView.Item {
// MARK: Computed // 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 item.completed == nil ? .primary : .secondary
} }
var showNote: Bool { /// 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 { guard let note = item.note else {
return false return false
} }
@@ -128,6 +175,9 @@ private extension TaskListView.Item {
return note.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false return note.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
} }
return true
}
} }
// MARK: - Previews // MARK: - Previews