From 1ec6aa5297fc257c1764404b054d580df5cec958 Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Mon, 9 Feb 2026 17:56:58 +0100 Subject: [PATCH] Implemented the NewTaskView view with its respective view model. --- App/Resources/Localizable.xcstrings | 72 ++++++ .../View Models/NewTaskViewModel.swift | 81 ++++++ App/Sources/Views/NewTaskView.swift | 105 ++++++++ .../View Models/NewTaskViewModelTests.swift | 239 ++++++++++++++++++ UnitTests/TodosTests.swift | 9 - 5 files changed, 497 insertions(+), 9 deletions(-) create mode 100644 App/Resources/Localizable.xcstrings create mode 100644 App/Sources/View Models/NewTaskViewModel.swift create mode 100644 App/Sources/Views/NewTaskView.swift create mode 100644 UnitTests/Tests/View Models/NewTaskViewModelTests.swift delete mode 100644 UnitTests/TodosTests.swift diff --git a/App/Resources/Localizable.xcstrings b/App/Resources/Localizable.xcstrings new file mode 100644 index 0000000..c025f50 --- /dev/null +++ b/App/Resources/Localizable.xcstrings @@ -0,0 +1,72 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "view.new-task.navigation.title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Create a new todo" + } + } + } + }, + "view.new-task.placeholder.due-date" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Select a due date for you task" + } + } + } + }, + "view.new-task.placeholder.note" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Add extra notes to your task (if needed)" + } + } + } + }, + "view.new-task.placeholder.title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Give a title to your task" + } + } + } + }, + "view.new-task.section.due-date" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Due Date" + } + } + } + }, + "view.new-task.section.task" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Task" + } + } + } + } + }, + "version" : "1.1" +} \ No newline at end of file diff --git a/App/Sources/View Models/NewTaskViewModel.swift b/App/Sources/View Models/NewTaskViewModel.swift new file mode 100644 index 0000000..cc300f6 --- /dev/null +++ b/App/Sources/View Models/NewTaskViewModel.swift @@ -0,0 +1,81 @@ +import SwiftData +import SwiftUI + +extension NewTaskView { + + /// View model for managing the logic for creating todo tasks. + /// + /// This observable class encapsulates the state and business logic for creating new todo tasks. + /// It follows the MVVM pattern to separate presentation logic from the view layer. + /// + /// The view model manages: + /// - User input for task title, note, and due date + /// - Form validation (ensuring title is not empty) + /// - Saving the todo to SwiftData model context + @Observable + class ViewModel { + + // MARK: Properties + + /// The title of the todo task. + /// + /// This is a required field. The save button is disabled when this is empty. + var title: String + + /// A note providing additional details about the task, if required. + /// + /// If left empty, the note will be stored as `nil` in the model. + var note: String + + /// The due date for the todo task. + /// + /// Defaults to the current date when the view model is initialized. + var due: Date + + // MARK: Initialization + + /// Initializes a new view model instance with default values. + /// + /// The view model is initialized with: + /// - Empty title + /// - Empty note + /// - Current date as due date + init() { + title = "" + note = "" + due = .init() + } + + // MARK: Computed + + /// Whether the confirmation button should be enabled. + /// + /// Returns `true` when the title is not empty after trimming whitespace, `false` otherwise. + var canConfirm: Bool { + !title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + // MARK: Functions + + /// Saves a new todo task to the SwiftData model context. + /// + /// This function creates a new `Todo` instance with the current property values and inserts it into the provided model context. + /// The title and note are trimmed of leading and trailing whitespace before saving. + /// The `note` property value is only included if it's not empty after trimming. + /// + /// - Parameter modelContext: The SwiftData model context to insert a new todo task. + func save(to modelContext: ModelContext) { + let trimmedTitle = title.trimmingCharacters(in: .whitespacesAndNewlines) + let trimmedNote = note.trimmingCharacters(in: .whitespacesAndNewlines) + + let todo = Todo( + title: trimmedTitle, + note: trimmedNote.isEmpty ? nil : trimmedNote, + due: due + ) + + modelContext.insert(todo) + } + + } +} diff --git a/App/Sources/Views/NewTaskView.swift b/App/Sources/Views/NewTaskView.swift new file mode 100644 index 0000000..9a7ec71 --- /dev/null +++ b/App/Sources/Views/NewTaskView.swift @@ -0,0 +1,105 @@ +import SwiftData +import SwiftUI + +/// A view for creating new todo tasks. +/// +/// This view provides a form interface for users to input todo task details including: +/// - *Title* (required) +/// - *Note* (optional) +/// - *Due date* (required) +/// +/// The view uses a dedicated view model (`ViewModel`) to manage state and business logic, following the MVVM pattern. +/// When the user saves, the todo is inserted into the SwiftData model context and the view is dismissed afterwards. +struct NewTaskView: View { + + // MARK: Properties + + /// Environment value for dismissing this view. + @Environment(\.dismiss) + private var dismiss + + /// SwiftData model context for saving todo tasks. + @Environment(\.modelContext) + private var modelContext + + /// The view model managing the state and logic for this view. + @State + private var viewModel: ViewModel + + // MARK: Initialization + + /// Initializes a new task view with a fresh view model instance. + init() { + _viewModel = State(initialValue: .init()) + } + + // MARK: Body + + /// The content and behavior of the view. + /// + /// Displays a navigation stack containing a form with sections for: + /// - Task details (title and note fields) + /// - Due date picker with graphical style + /// + /// The toolbar includes: + /// - Cancel button to dismiss without saving + /// - Confirm button to persist the todo and dismiss (disabled when title is empty) + var body: some View { + NavigationStack { + Form { + Section("view.new-task.section.task") { + TextField( + "view.new-task.placeholder.title", + text: $viewModel.title + ) + .autocapitalization(.none) + .autocorrectionDisabled(false) + .keyboardType(.asciiCapable) + + TextField( + "view.new-task.placeholder.note", + text: $viewModel.note, + axis: .vertical + ) + .autocapitalization(.none) + .autocorrectionDisabled(false) + .keyboardType(.asciiCapable) + .lineLimit(3 ... 6) + } + + Section("view.new-task.section.due-date") { + DatePicker( + "view.new-task.placeholder.due-date", + selection: $viewModel.due, + displayedComponents: [.date] + ) + .datePickerStyle(.graphical) + } + } + .navigationTitle("view.new-task.navigation.title") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(role: .cancel) { + dismiss() + } + } + ToolbarItem(placement: .confirmationAction) { + Button(role: .confirm) { + viewModel.save(to: modelContext) + dismiss() + } + .disabled(!viewModel.canConfirm) + } + } + } + } + +} + +// MARK: - Previews + +#Preview("New todo task") { + NewTaskView() + .modelContainer(for: Todo.self, inMemory: true) +} diff --git a/UnitTests/Tests/View Models/NewTaskViewModelTests.swift b/UnitTests/Tests/View Models/NewTaskViewModelTests.swift new file mode 100644 index 0000000..e33bc0c --- /dev/null +++ b/UnitTests/Tests/View Models/NewTaskViewModelTests.swift @@ -0,0 +1,239 @@ +import Foundation +import SwiftData +import Testing + +@testable import Todos + +@Suite("NewTaskView ViewModel Tests") +struct NewTaskViewModelTests { + + // MARK: Initialization tests + + @Test("View model initializes with default values") + func initialization() { + // GIVEN + // WHEN + let viewModel = NewTaskView.ViewModel() + + // THEN + #expect(viewModel.title == "") + #expect(viewModel.note == "") + #expect(viewModel.due.timeIntervalSinceNow < 1.0) + } + + // MARK: Properties tests + + @Test("due property can be modified") + func dueModifications() { + // GIVEN + let dateInsert = Date(timeIntervalSince1970: 1000000000) + let dateUpdate = Date(timeIntervalSince1970: 2000000000) + + // WHEN + let viewModel = NewTaskView.ViewModel() + + // THEN + viewModel.due = dateInsert + #expect(viewModel.due == dateInsert) + + viewModel.due = dateUpdate + #expect(viewModel.due == dateUpdate) + } + + @Test("note property can be modified") + func noteModifications() { + // GIVEN + let noteInsert = "Initial note" + let noteUpdate = "Updated note" + + // WHEN + let viewModel = NewTaskView.ViewModel() + + // THEN + viewModel.note = noteInsert + #expect(viewModel.note == noteInsert) + + viewModel.note = noteUpdate + #expect(viewModel.note == noteUpdate) + } + + @Test("title property can be modified") + func titleModifications() { + // GIVEN + let titleInsert = "Initial title" + let titleUpdate = "Updated title" + + // WHEN + let viewModel = NewTaskView.ViewModel() + + // THEN + viewModel.title = titleInsert + #expect(viewModel.title == titleInsert) + + viewModel.title = titleUpdate + #expect(viewModel.title == titleUpdate) + } + + // MARK: Computed tests + + @Test( + "canConfirm property validation", + arguments: zip( + ["", "Some title goes here...", " "], + [false, true, false] + ) + ) + func canConfirm( + with title: String, + expects expectation: Bool + ) { + // GIVEN + let viewModel = NewTaskView.ViewModel() + + // WHEN + viewModel.title = title + + // THEN + #expect(viewModel.canConfirm == expectation) + } + + @Test("canConfirm updates when title changes") + func canConfirmWithTitleChanges() { + // GIVEN + let titleEmpty = "" + let titleFilled = "New task" + let titleTrimmed = " " + + // WHEN + let viewModel = NewTaskView.ViewModel() + + // THEN + viewModel.title = titleEmpty + #expect(viewModel.canConfirm == false) + + viewModel.title = titleFilled + #expect(viewModel.canConfirm == true) + + viewModel.title = titleTrimmed + #expect(viewModel.canConfirm == false) + } + + // MARK: Functions tests + + @Test("save creates todo with all properties") + @MainActor + func saveWithAllProperties() throws { + // GIVEN + let container = try ModelContainer( + for: Todo.self, + configurations: .init(isStoredInMemoryOnly: true) + ) + let context = container.mainContext + let viewModel = NewTaskView.ViewModel() + + // WHEN + viewModel.title = "Complete project" + viewModel.note = "Finish by end of week" + viewModel.due = Date(timeIntervalSince1970: 1000000000) + + viewModel.save(to: context) + + // THEN + let descriptor = FetchDescriptor() + let todos = try context.fetch(descriptor) + let todo = try #require(todos.first) + + #expect(todo.title == viewModel.title) + #expect(todo.note == viewModel.note) + #expect(todo.due == viewModel.due) + #expect(todo.completed == nil) + } + + @Test("save creates todo without note when note is empty") + @MainActor + func saveWithEmptyNote() throws { + // GIVEN + let container = try ModelContainer( + for: Todo.self, + configurations: .init(isStoredInMemoryOnly: true) + ) + let context = container.mainContext + let viewModel = NewTaskView.ViewModel() + + // WHEN + viewModel.title = "Task without note" + viewModel.note = "" + viewModel.due = Date() + + viewModel.save(to: context) + + // THEN + let descriptor = FetchDescriptor() + let todos = try context.fetch(descriptor) + let todo = try #require(todos.first) + + #expect(todo.title == viewModel.title) + #expect(todo.note == nil) + #expect(todo.due == viewModel.due) + #expect(todo.completed == nil) + } + + @Test("save creates todo with note containing only whitespace as nil") + @MainActor + func saveWithWhitespaceNote() throws { + // GIVEN + let container = try ModelContainer( + for: Todo.self, + configurations: .init(isStoredInMemoryOnly: true) + ) + let context = container.mainContext + let viewModel = NewTaskView.ViewModel() + + // WHEN + viewModel.title = "Task with whitespace note" + viewModel.note = " " + viewModel.due = Date() + + viewModel.save(to: context) + + // THEN + let descriptor = FetchDescriptor() + let todos = try context.fetch(descriptor) + let todo = try #require(todos.first) + + #expect(todo.title == viewModel.title) + #expect(todo.note == nil) + #expect(todo.due == viewModel.due) + #expect(todo.completed == nil) + } + + @Test("save inserts multiple todos independently") + @MainActor + func saveMultipleTodos() throws { + // GIVEN + let container = try ModelContainer( + for: Todo.self, + configurations: .init(isStoredInMemoryOnly: true) + ) + let context = container.mainContext + let viewModelFirst = NewTaskView.ViewModel() + let viewModelSecond = NewTaskView.ViewModel() + + // WHEN + viewModelFirst.title = "First task" + viewModelFirst.save(to: context) + + viewModelSecond.title = "Second task" + viewModelSecond.note = "With a note" + viewModelSecond.save(to: context) + + // THEN + let descriptor = FetchDescriptor() + let todos = try context.fetch(descriptor) + + #expect(todos.count == 2) + #expect(todos.contains { $0.title == "First task" }) + #expect(todos.contains { $0.title == "Second task" }) + } + +} diff --git a/UnitTests/TodosTests.swift b/UnitTests/TodosTests.swift deleted file mode 100644 index 7ac4a8a..0000000 --- a/UnitTests/TodosTests.swift +++ /dev/null @@ -1,9 +0,0 @@ -import Testing - -struct TodosTests { - - @Test - func example() async throws { - // Write your test here and use APIs like `#expect(...)` to check expected conditions. - } -}