Files
todos/App/Sources/Views/TaskList/TaskListItem.swift
T

273 lines
8.4 KiB
Swift
Raw Normal View History

2026-02-10 15:31:52 +00:00
import SwiftUI
extension TaskListView {
/// A view that displays a single editable todo task in a list.
///
/// This view provides a comprehensive interface for viewing and editing task information,
/// including:
/// - A completion toggle using a circular checkbox design
/// - An editable title field with multi-line support
/// - An optional note field that appears when the task has notes
/// - A date picker for selecting the task's due date
///
/// The view automatically adjusts its appearance based on completion state, dimming
/// completed tasks to provide visual feedback. All text fields support automatic
/// capitalization and keyboard correction for improved user experience.
struct Item: View {
// MARK: Properties
/// Tracks which text field currently has keyboard focus.
@FocusState
private var focusOnField: FieldFocus?
2026-02-10 15:31:52 +00:00
/// The todo task to display.
@Bindable
private var item: Todo
// MARK: Initializers
/// Creates a new task list item view for the specified todo.
///
/// The item is wrapped in a `@Bindable` property wrapper to enable two-way
/// data binding for editing task properties directly within the view.
///
/// - Parameter item: The todo task to display and edit.
init(_ item: Todo) {
_item = Bindable(item)
}
// MARK: Body
/// The content and behavior of the view.
///
/// Renders an editable task list item with a completion toggle, title field,
/// optional note field, and date picker. The view's foreground color automatically
/// adjusts based on completion state, and changes animate smoothly.
var body: some View {
HStack(
alignment: .top,
spacing: 16
) {
Toggle(
"view.task-list-item.placeholder.completed",
isOn: isCompleted
)
.sensoryFeedback(trigger: item.completed) {
item.completed != nil ? .increase : .decrease
}
2026-02-10 15:31:52 +00:00
.toggleStyle(.completion)
VStack(
alignment: .leading,
spacing: 8
) {
TextField(
"view.task-list-item.placeholder.title",
text: title,
2026-02-10 15:31:52 +00:00
axis: .vertical
)
.font(.headline)
.focused(
$focusOnField,
equals: .title
)
2026-02-10 15:31:52 +00:00
if isNoteShown {
2026-02-10 15:31:52 +00:00
TextField(
"view.task-list-item.placeholder.note",
text: note,
axis: .vertical
)
.font(.subheadline)
.focused(
$focusOnField,
equals: .note
)
2026-02-10 15:31:52 +00:00
}
DatePicker(
selection: $item.due,
displayedComponents: .date
) {
2026-02-10 15:31:52 +00:00
Image(systemName: "calendar")
}
.frame(width: 160)
.disabled(isCompleted.wrappedValue)
}
.autocapitalization(.sentences)
.autocorrectionDisabled(false)
.foregroundStyle(foregroundStyleView)
2026-02-10 15:31:52 +00:00
.keyboardType(.asciiCapable)
}
.animation(
.easeInOut,
value: item.completed
)
}
}
}
// 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
}
}
2026-02-10 15:31:52 +00:00
// 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.
2026-02-10 15:31:52 +00:00
var isCompleted: Binding<Bool> {
.init {
item.completed != nil
} set: {
item.completed = $0 ? .now : nil
}
}
/// 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.
2026-02-10 15:31:52 +00:00
var note: Binding<String> {
.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<String> {
.init {
item.title
} set: {
setTitle(with: $0)
2026-02-10 15:31:52 +00:00
}
}
// MARK: Computed
/// 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 {
2026-02-10 15:31:52 +00:00
item.completed == nil ? .primary : .secondary
}
/// 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
2026-02-10 15:31:52 +00:00
}
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
2026-02-10 15:31:52 +00:00
}
}
// MARK: - Previews
#Preview("Task list item") {
@Previewable
var todo: Todo = .init(
title: "Complete project documentation",
note: "Add detailed comments and README",
due: .now,
completed: nil
)
List {
TaskListView.Item(todo)
}
.listStyle(.grouped)
}
// MARK: - Character+Constants
private extension Character {
static let newLine: Self = "\n"
}