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/App/TodosApp.swift b/App/Sources/App/TodosApp.swift index ebd7187..e467f70 100644 --- a/App/Sources/App/TodosApp.swift +++ b/App/Sources/App/TodosApp.swift @@ -5,7 +5,7 @@ import SwiftUI struct TodosApp: App { var sharedModelContainer: ModelContainer = { let schema = Schema([ - Item.self + Todo.self ]) let modelConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false) diff --git a/App/Sources/Models/Item.swift b/App/Sources/Models/Item.swift deleted file mode 100644 index a2f85bf..0000000 --- a/App/Sources/Models/Item.swift +++ /dev/null @@ -1,11 +0,0 @@ -import Foundation -import SwiftData - -@Model -final class Item { - var timestamp: Date - - init(timestamp: Date) { - self.timestamp = timestamp - } -} diff --git a/App/Sources/Models/Todo.swift b/App/Sources/Models/Todo.swift new file mode 100644 index 0000000..b127928 --- /dev/null +++ b/App/Sources/Models/Todo.swift @@ -0,0 +1,42 @@ +import Foundation +import SwiftData + +/// Represents a todo task created by a user. +@Model +final class Todo { + + // MARK: Properties + + /// A date when the todo was completed, if applicable. + var completed: Date? + + /// A date by which the todo should be completed. + var due: Date + + /// An optional note providing additional details about the todo task. + var note: String? + + /// The title of the todo task. + var title: String + + // MARK: Initializers + + /// Creates a new todo item. + /// - Parameters: + /// - title: The title of the todo item. + /// - note: An optional note providing additional details. + /// - due: The date by which the todo should be completed. + /// - completed: The date when the todo was completed, if applicable. + init( + title: String, + note: String? = nil, + due: Date, + completed: Date? = nil + ) { + self.completed = completed + self.due = due + self.note = note + self.title = title + } + +} diff --git a/App/Sources/View Models/ContentViewModel.swift b/App/Sources/View Models/ContentViewModel.swift new file mode 100644 index 0000000..ecddcec --- /dev/null +++ b/App/Sources/View Models/ContentViewModel.swift @@ -0,0 +1,39 @@ +import SwiftData +import SwiftUI + +extension ContentView { + + /// View model for managing the logic for the content view. + /// + /// This observable class encapsulates the state and business logic for the main content view. + /// It follows the MVVM pattern to separate presentation logic from the view layer. + /// + /// The view model manages: + /// - Presentation state for the new task view within a sheet. + @Observable + class ViewModel { + + // MARK: Properties + + /// Whether the new task view should be shown. + var newTaskShown: Bool + + // MARK: Initialization + + /// Initializes a new view model instance with default values. + /// + /// The view model is initialized with: + /// - New task view hidden + init() { + newTaskShown = false + } + + // MARK: Functions + + /// Shows the new task view by setting the presentation state to true. + func showNewTask() { + newTaskShown = true + } + + } +} 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/ContentView.swift b/App/Sources/Views/ContentView.swift index e28a317..34bb847 100644 --- a/App/Sources/Views/ContentView.swift +++ b/App/Sources/Views/ContentView.swift @@ -1,63 +1,52 @@ import SwiftData import SwiftUI +/// The main content view of the app. +/// +/// This view serves as the primary interface for managing todo tasks. +/// +/// The view uses a dedicated view model (`ViewModel`) to manage state and business logic, following the MVVM pattern. +/// User interactions are handled through the view model, maintaining separation of concerns. struct ContentView: View { - @Environment(\.modelContext) - private var modelContext + // MARK: Properties - @Query - private var items: [Item] + /// The view model managing the state and logic for this view. + @State + private var viewModel = ViewModel() + // MARK: Body + + /// The content and behavior of the view. + /// + /// Displays a navigation view containing: + /// - A floating action button for adding new tasks + /// + /// The view presents a sheet when `viewModel.newTaskShown` is true, + /// allowing users to create new todo tasks. var body: some View { - NavigationSplitView { - List { - ForEach(items) { item in - NavigationLink { - Text("Item at \(item.timestamp, format: Date.FormatStyle(date: .numeric, time: .standard))") - } label: { - Text(item.timestamp, format: Date.FormatStyle(date: .numeric, time: .standard)) - } - } - .onDelete(perform: deleteItems) - } - #if os(macOS) - .navigationSplitViewColumnWidth(min: 180, ideal: 200) - #endif - .toolbar { - #if os(iOS) - ToolbarItem(placement: .navigationBarTrailing) { - EditButton() - } - #endif - ToolbarItem { - Button(action: addItem) { - Label("Add Item", systemImage: "plus") - } - } - } - } detail: { - Text("Select an item") - } - } + NavigationView { + ZStack(alignment: .bottomTrailing) { + List {} - private func addItem() { - withAnimation { - let newItem = Item(timestamp: Date()) - modelContext.insert(newItem) - } - } - - private func deleteItems(offsets: IndexSet) { - withAnimation { - for index in offsets { - modelContext.delete(items[index]) + Button(action: viewModel.showNewTask) { + Image(systemName: "plus") + .frame(width: 48, height: 48) + .foregroundStyle(.white) + } + .glassEffect(.regular.tint(.blue.opacity(0.8)).interactive()) + .padding(.trailing, 20) } + .navigationTitle("Todos") + } + .sheet(isPresented: $viewModel.newTaskShown) { + NewTaskView() } } } +// MARK: - Previews + #Preview { ContentView() - .modelContainer(for: Item.self, inMemory: true) } 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/ContentViewModelTests.swift b/UnitTests/Tests/View Models/ContentViewModelTests.swift new file mode 100644 index 0000000..668bd6e --- /dev/null +++ b/UnitTests/Tests/View Models/ContentViewModelTests.swift @@ -0,0 +1,36 @@ +import Foundation +import SwiftData +import Testing + +@testable import Todos + +@Suite("ContentView.ViewModel Tests") +struct ContentViewModelTests { + + // MARK: Initialization Tests + + @Test("View model initializes with default values") + func initialization() { + // GIVEN + // WHEN + let viewModel = ContentView.ViewModel() + + // THEN + #expect(viewModel.newTaskShown == false) + } + + // MARK: - Functions Tests + + @Test("showNewTask sets showNewTaskView to true") + func newTaskShown() { + // GIVEN + let viewModel = ContentView.ViewModel() + + // WHEN + viewModel.showNewTask() + + // THEN + #expect(viewModel.newTaskShown == 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. - } -}