App layout improvements #10

Merged
javier merged 5 commits from app/app-layout into main 2026-02-12 03:55:17 +00:00
5 changed files with 117 additions and 25 deletions
Showing only changes of commit c02f027687 - Show all commits
@@ -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
}
}
}
+27 -18
View File
@@ -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,38 @@ 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)
}
.navigationTitle("Todos")
} detail: {
if let group = viewModel.group {
TaskListView(group: group)
.newTaskButton(show: viewModel.showNewTaskButtonInDetails) {
viewModel.showNewTask()
}
.sheet(isPresented: $viewModel.newTaskShown) {
NewTaskView()
}
}
else {
ContentUnavailableView(
"Select a Group",
systemImage: "sidebar.left",
description: Text("Choose a task group from the sidebar")
)
}
}
}
}
// MARK: - Previews
-3
View File
@@ -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
-1
View File
@@ -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
@@ -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)
}
}