App layout improvements (#10)

This PR contains the work done to improve the app layout for max iPhones and iPads by levering on the navigation split view. Plus, the floating button to add todo tasks was improved and integrated better into the layout and a critical bug occurring on max iPhones and iPads was fixed.

Reviewed-on: rock-n-code/todos-app#10
Co-authored-by: Javier Cicchelli <javier@rock-n-code.com>
Co-committed-by: Javier Cicchelli <javier@rock-n-code.com>
This commit was merged in pull request #10.
This commit is contained in:
2026-02-12 03:55:17 +00:00
committed by javier
parent 52376695a4
commit dd6f453466
9 changed files with 288 additions and 43 deletions
+22
View File
@@ -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" : {
@@ -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
}
}
}
@@ -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.
}
}
+30 -20
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,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
-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
@@ -63,7 +63,7 @@ extension TaskGroupListView {
Spacer()
Text("\(count)")
Text(String(count))
.font(.title)
.fontWeight(.bold)
.foregroundStyle(group.color)
+49 -17
View File
@@ -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<TodoGroup?>) {
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)
}
}
-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)
}
}