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:
@@ -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
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
}
|
||||
@@ -22,6 +22,10 @@ struct NewTaskView: View {
|
||||
@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
|
||||
private var viewModel: ViewModel
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user