App layout improvements #10
@@ -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
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user