App layout improvements #10

Merged
javier merged 5 commits from app/app-layout into main 2026-02-12 03:55:17 +00:00
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)
}
}