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: /// The view model manages:
/// - Presentation state for the new task view within a sheet. /// - Presentation state for the new task view within a sheet.
/// - Selected task group for the navigation split view.
@Observable @Observable
final class ViewModel { final class ViewModel {
@@ -18,22 +19,60 @@ extension ContentView {
/// Whether the new task view should be shown. /// Whether the new task view should be shown.
var newTaskShown: Bool 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 // MARK: Initialization
/// Initializes a new view model instance with default values. /// Initializes a new view model instance with default values.
/// ///
/// The view model is initialized with: /// The view model is initialized with:
/// - New task view hidden /// - New task view hidden
/// - No task group selected
init() { init() {
newTaskShown = false 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 // MARK: Functions
/// Shows the new task view by setting the presentation state to true. /// 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() { func showNewTask() {
newTaskShown = true 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. /// The main content view of the app.
/// ///
/// This view serves as the primary interface for managing todo tasks. /// 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 { struct ContentView: View {
// MARK: Properties // 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. /// The view model managing the state and logic for this view.
@State @State
private var viewModel = ViewModel() private var viewModel = ViewModel()
@@ -19,30 +20,38 @@ struct ContentView: View {
/// The content and behavior of the 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 /// - A floating action button for adding new tasks
/// ///
/// The view presents a sheet when `viewModel.newTaskShown` is true, /// The view presents a sheet to allow users to create new todo tasks.
/// allowing users to create new todo tasks.
var body: some View { var body: some View {
NavigationView { NavigationSplitView {
ZStack(alignment: .bottomTrailing) { TaskGroupListView($viewModel.group)
TaskGroupListView() .newTaskButton(show: viewModel.showNewTaskButtonInSidebar(when: horizontalSizeClass)) {
viewModel.showNewTask()
Button(action: viewModel.showNewTask) {
Image(systemName: "plus")
.frame(width: 48, height: 48)
.foregroundStyle(.white)
} }
.glassEffect(.regular.tint(.blue.opacity(0.8)).interactive()) } detail: {
.padding(.trailing, 20) if let group = viewModel.group {
} TaskListView(group: group)
.navigationTitle("Todos") .newTaskButton(show: viewModel.showNewTaskButtonInDetails) {
viewModel.showNewTask()
} }
.sheet(isPresented: $viewModel.newTaskShown) { .sheet(isPresented: $viewModel.newTaskShown) {
NewTaskView() NewTaskView()
} }
} }
else {
ContentUnavailableView(
"Select a Group",
systemImage: "sidebar.left",
description: Text("Choose a task group from the sidebar")
)
}
}
}
} }
// MARK: - Previews // MARK: - Previews
-3
View File
@@ -7,9 +7,6 @@ import SwiftUI
/// - *Title* (required) /// - *Title* (required)
/// - *Note* (optional) /// - *Note* (optional)
/// - *Due date* (required) /// - *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 { struct NewTaskView: View {
// MARK: Properties // MARK: Properties
-1
View File
@@ -4,7 +4,6 @@ import SwiftUI
/// A view that displays filtered todo tasks for a specific task group. /// 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. /// 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 { struct TaskListView: View {
// MARK: Properties // MARK: Properties
@@ -1,3 +1,4 @@
import SwiftUI
import Testing import Testing
@testable import Todos @testable import Todos
@@ -16,11 +17,37 @@ struct ContentViewModelTests {
// THEN // THEN
#expect(viewModel.newTaskShown == false) #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 // MARK: - Functions Tests
@Test("showNewTask sets showNewTaskView to true") @Test("showNewTask sets newTaskShown to true")
func newTaskShown() { func newTaskShown() {
// GIVEN // GIVEN
let viewModel = ContentView.ViewModel() let viewModel = ContentView.ViewModel()
@@ -32,4 +59,25 @@ struct ContentViewModelTests {
#expect(viewModel.newTaskShown == true) #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)
}
} }