diff --git a/App/Resources/Localizable.xcstrings b/App/Resources/Localizable.xcstrings index cfe439f..be674fb 100644 --- a/App/Resources/Localizable.xcstrings +++ b/App/Resources/Localizable.xcstrings @@ -45,6 +45,27 @@ } } }, + "view.content.content-unavailable.description" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Choose a task group from the sidebar" + } + } + } + }, + "view.content.content-unavailable.title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Select a Group" + } + } + } }, "view.new-task.navigation.title" : { "extractionState" : "manual", @@ -122,6 +143,7 @@ } } } + }, "view.task-list-content.content-unavailable.title" : { "extractionState" : "manual", "localizations" : { diff --git a/App/Sources/View Models/ContentViewModel.swift b/App/Sources/View Models/ContentViewModel.swift index d565b92..915bba4 100644 --- a/App/Sources/View Models/ContentViewModel.swift +++ b/App/Sources/View Models/ContentViewModel.swift @@ -10,6 +10,7 @@ extension ContentView { /// /// The view model manages: /// - Presentation state for the new task view within a sheet. + /// - Selected task group for the navigation split view. @Observable final class ViewModel { @@ -18,22 +19,60 @@ extension ContentView { /// Whether the new task view should be shown. var newTaskShown: Bool + /// The currently selected todo task group in the navigation split view. + /// + /// When a user selects a group from the sidebar, this property is updated + /// to reflect the selection. The detail view displays content based on this value. + var group: TodoGroup? + // MARK: Initialization /// Initializes a new view model instance with default values. /// /// The view model is initialized with: /// - New task view hidden + /// - No task group selected init() { newTaskShown = false + group = nil + } + + // MARK: Computed + + /// Determines whether the new task button should be shown in the detail view. + /// + /// This prevents showing the add button when viewing completed tasks, + /// as users should not create new tasks in the completed state. + var showNewTaskButtonInDetails: Bool { + group != nil + && group != .completed } // MARK: Functions /// Shows the new task view by setting the presentation state to true. + /// + /// This method triggers the presentation of a sheet where users can create a new task. + /// Called when the floating action button is tapped. func showNewTask() { newTaskShown = true } + /// Determines whether the new task button should be shown in the sidebar. + /// + /// The button appears in the sidebar only on compact horizontal size class devices + /// (typically iPhones in portrait mode). This provides a way to create tasks when + /// the sidebar is displayed as a full screen view. + /// + /// - Parameter horizontalSizeClass: The current horizontal size class of the environment. + /// - Returns: `true` if the button should be shown in the sidebar, `false` otherwise. + func showNewTaskButtonInSidebar(when horizontalSizeClass: UserInterfaceSizeClass?) -> Bool { + guard let horizontalSizeClass else { + return false + } + + return horizontalSizeClass == .compact + } + } } diff --git a/App/Sources/View Modifiers/NewTaskButtonModifier.swift b/App/Sources/View Modifiers/NewTaskButtonModifier.swift new file mode 100644 index 0000000..2722de8 --- /dev/null +++ b/App/Sources/View Modifiers/NewTaskButtonModifier.swift @@ -0,0 +1,98 @@ +import SwiftUI + +/// A view modifier that adds a floating action button for creating new tasks. +/// +/// This modifier overlays a glass-effect button in the bottom-trailing corner of the view. +/// The button can be conditionally shown or hidden with smooth animations. +struct NewTaskButtonModifier: ViewModifier { + + // MARK: Properties + + /// Whether the floating button should be displayed. + let showButton: Bool + + /// The action to perform when the button is tapped. + let action: () -> Void + + // MARK: Functions + + /// Applies the floating action button to the content view. + /// + /// When `showButton` is true, overlays a blue glass-effect button with a plus icon + /// in the bottom-trailing corner. The button animates in/out with opacity and scale transitions, + /// and includes a spring animation when pressed. + /// + /// - Parameter content: The view to which the button will be added. + /// - Returns: The modified view with the floating button overlay. + func body(content: Content) -> some View { + ZStack(alignment: .bottomTrailing) { + content + + if showButton { + Button(action: action) { + Image(systemName: "plus") + .frame(width: 48, height: 48) + .foregroundStyle(.white) + } + .glassEffect(.regular.tint(.blue.opacity(0.8)).interactive()) + .padding(.trailing, 20) + } + } + .animation( + .spring(response: 0.4, dampingFraction: 0.8), + value: showButton + ) + } + +} + +// MARK: - View Extension + +extension View { + + /// Adds a floating action button for creating new tasks. + /// + /// This convenience method applies the ``NewTaskButtonModifier`` to show a glass-effect + /// button in the bottom-trailing corner of the view. + /// + /// - Parameters: + /// - show: Whether the floating button should be displayed. + /// - action: The action to perform when the button is tapped. + /// - Returns: The modified view with the floating button overlay. + func newTaskButton( + show: Bool, + action: @escaping () -> Void + ) -> some View { + modifier( + NewTaskButtonModifier( + showButton: show, + action: action + ) + ) + } + +} +// MARK: - Previews + +#Preview("New Task Button") { + @Previewable + @State + var showButton = true + + VStack { + Spacer() + Toggle( + "Show Button", + isOn: $showButton + ) + Spacer() + } + .padding(.horizontal) + .frame( + maxWidth: .infinity, + maxHeight: .infinity + ) + .newTaskButton(show: showButton) { + // On action closure. + } +} diff --git a/App/Sources/Views/ContentView.swift b/App/Sources/Views/ContentView.swift index 0e861e8..13b3111 100644 --- a/App/Sources/Views/ContentView.swift +++ b/App/Sources/Views/ContentView.swift @@ -4,13 +4,14 @@ 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 { // MARK: Properties + /// The horizontal size class of the environment. + @Environment(\.horizontalSizeClass) + private var horizontalSizeClass + /// The view model managing the state and logic for this view. @State private var viewModel = ViewModel() @@ -19,30 +20,39 @@ struct ContentView: View { /// The content and behavior of the view. /// - /// Displays a navigation view containing: + /// Displays a navigation split view with: + /// - Sidebar: List of todo task groups + /// - Detail: List of todo task related to a selected todo task group /// - 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. + /// The view presents a sheet to allow users to create new todo tasks. var body: some View { - NavigationView { - ZStack(alignment: .bottomTrailing) { - TaskGroupListView() - - Button(action: viewModel.showNewTask) { - Image(systemName: "plus") - .frame(width: 48, height: 48) - .foregroundStyle(.white) + NavigationSplitView { + TaskGroupListView($viewModel.group) + .newTaskButton(show: viewModel.showNewTaskButtonInSidebar(when: horizontalSizeClass)) { + viewModel.showNewTask() } - .glassEffect(.regular.tint(.blue.opacity(0.8)).interactive()) - .padding(.trailing, 20) + } detail: { + if let group = viewModel.group { + TaskListView(group: group) + .id(group) + .newTaskButton(show: viewModel.showNewTaskButtonInDetails) { + viewModel.showNewTask() + } + .sheet(isPresented: $viewModel.newTaskShown) { + NewTaskView() + } + } + else { + ContentUnavailableView( + "view.content.content-unavailable.title", + systemImage: "sidebar.left", + description: Text("view.content.content-unavailable.description") + ) } - .navigationTitle("Todos") - } - .sheet(isPresented: $viewModel.newTaskShown) { - NewTaskView() } } + } // MARK: - Previews diff --git a/App/Sources/Views/NewTaskView.swift b/App/Sources/Views/NewTaskView.swift index b2a08e8..2979772 100644 --- a/App/Sources/Views/NewTaskView.swift +++ b/App/Sources/Views/NewTaskView.swift @@ -7,9 +7,6 @@ import SwiftUI /// - *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 diff --git a/App/Sources/Views/TaskGroupList/TaskGroupListCard.swift b/App/Sources/Views/TaskGroupList/TaskGroupListCard.swift index 47e06dc..4628b23 100644 --- a/App/Sources/Views/TaskGroupList/TaskGroupListCard.swift +++ b/App/Sources/Views/TaskGroupList/TaskGroupListCard.swift @@ -63,7 +63,7 @@ extension TaskGroupListView { Spacer() - Text("\(count)") + Text(String(count)) .font(.title) .fontWeight(.bold) .foregroundStyle(group.color) diff --git a/App/Sources/Views/TaskGroupListView.swift b/App/Sources/Views/TaskGroupListView.swift index da3a86d..f2dd2e7 100644 --- a/App/Sources/Views/TaskGroupListView.swift +++ b/App/Sources/Views/TaskGroupListView.swift @@ -12,12 +12,18 @@ struct TaskGroupListView: View { // MARK: Properties + /// Binding to the currently selected todo task group. + /// + /// When provided, the view will use this binding for navigation split view selection. + @Binding + private var group: TodoGroup? + /// Query to monitor all todo tasks for changes. /// /// This query triggers view updates whenever todo tasks are inserted, updated, or deleted, /// ensuring the task counts displayed in the view remain accurate. @Query - private var tasks: [Todo] + private var todos: [Todo] /// The view model managing the state and logic for this view. /// @@ -26,6 +32,18 @@ struct TaskGroupListView: View { @State private var viewModel = ViewModel() + // MARK: Initializers + + /// Creates a task group list view with a binding to the selected group. + /// + /// This initializer configures the view for use in a navigation split view, + /// where the binding tracks which task group is currently selected in the sidebar. + /// + /// - Parameter group: A binding to the currently selected task group. + init(_ group: Binding) { + self._group = group + } + // MARK: Body /// The content and behavior of the view. @@ -35,24 +53,30 @@ struct TaskGroupListView: View { /// - The group's title /// - The current count of tasks in that group /// - /// Cards are tappable and navigate to a detailed task list for the selected group. + /// Cards are tappable and update the selected group binding for navigation split view. /// Task counts are fetched on-demand from the model context for each render. var body: some View { - ScrollView { - VStack(spacing: 16) { - ForEach(viewModel.groups) { group in - NavigationLink(destination: TaskListView(group: group)) { - Card( - group: group, - count: viewModel.count(tasks, for: group) - ) - } - .buttonStyle(.plain) - } - } - .padding(.horizontal) + List( + viewModel.groups, + selection: $group + ) { group in + Card( + group: group, + count: viewModel.count( + todos, + for: group + ) + ) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + .tag(group) } + .listStyle(.plain) .navigationTitle("view.task-group-list.navigation.title") + .sensoryFeedback( + .selection, + trigger: group + ) } } @@ -62,8 +86,12 @@ struct TaskGroupListView: View { "Task group list with empty tasks", traits: .emptyContainer ) { + @Previewable + @State + var group: TodoGroup? + NavigationStack { - TaskGroupListView() + TaskGroupListView($group) } } @@ -71,7 +99,11 @@ struct TaskGroupListView: View { "Task group list with sample tasks", traits: .sampleTasks ) { + @Previewable + @State + var group: TodoGroup? + NavigationStack { - TaskGroupListView() + TaskGroupListView($group) } } diff --git a/App/Sources/Views/TaskListView.swift b/App/Sources/Views/TaskListView.swift index dacf3e2..deccbbb 100644 --- a/App/Sources/Views/TaskListView.swift +++ b/App/Sources/Views/TaskListView.swift @@ -4,7 +4,6 @@ import SwiftUI /// A view that displays filtered todo tasks for a specific task group. /// /// This view provides the presentation layer for displaying and managing tasks within a specific group category. -/// It follows the MVVM pattern, delegating business logic to a view model and data fetching to a separate content view. struct TaskListView: View { // MARK: Properties diff --git a/UnitTests/Tests/View Models/ContentViewModelTests.swift b/UnitTests/Tests/View Models/ContentViewModelTests.swift index 677520a..bb699b9 100644 --- a/UnitTests/Tests/View Models/ContentViewModelTests.swift +++ b/UnitTests/Tests/View Models/ContentViewModelTests.swift @@ -1,3 +1,4 @@ +import SwiftUI import Testing @testable import Todos @@ -16,11 +17,37 @@ struct ContentViewModelTests { // THEN #expect(viewModel.newTaskShown == false) + #expect(viewModel.group == nil) + } + + // MARK: - Computed Property Tests + + @Test( + "showNewTaskButtonInDetails returns expected value for every group", + arguments: zip( + [TodoGroup.today, .upcoming, .overdue, .completed, nil], + [true, true, true, false, false] + ) + ) + func showNewTaskButtonInDetails( + for group: TodoGroup?, + expects expectation: Bool + ) { + // GIVEN + let viewModel = ContentView.ViewModel() + + viewModel.group = group + + // WHEN + let shouldNewTaskButton = viewModel.showNewTaskButtonInDetails + + // THEN + #expect(shouldNewTaskButton == expectation) } // MARK: - Functions Tests - @Test("showNewTask sets showNewTaskView to true") + @Test("showNewTask sets newTaskShown to true") func newTaskShown() { // GIVEN let viewModel = ContentView.ViewModel() @@ -32,4 +59,25 @@ struct ContentViewModelTests { #expect(viewModel.newTaskShown == true) } + @Test( + "showNewTaskButtonInSidebar returns expected value for every horizontal size class", + arguments: zip( + [UserInterfaceSizeClass.compact, .regular, nil], + [true, false, false] + ) + ) + func showNewTaskButtonInSidebar( + for horizontalSizeClass: UserInterfaceSizeClass?, + expects expectation: Bool + ) { + // GIVEN + let viewModel = ContentView.ViewModel() + + // WHEN + let shouldNewTaskButton = viewModel.showNewTaskButtonInSidebar(when: horizontalSizeClass) + + // THEN + #expect(shouldNewTaskButton == expectation) + } + }