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" : { "view.new-task.navigation.title" : {
"extractionState" : "manual", "extractionState" : "manual",
@@ -122,6 +143,7 @@
} }
} }
} }
},
"view.task-list-content.content-unavailable.title" : { "view.task-list-content.content-unavailable.title" : {
"extractionState" : "manual", "extractionState" : "manual",
"localizations" : { "localizations" : {
@@ -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
}
} }
} }
@@ -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.
}
}
+28 -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,39 @@ 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") .id(group)
.newTaskButton(show: viewModel.showNewTaskButtonInDetails) {
viewModel.showNewTask()
} }
.sheet(isPresented: $viewModel.newTaskShown) { .sheet(isPresented: $viewModel.newTaskShown) {
NewTaskView() NewTaskView()
} }
} }
else {
ContentUnavailableView(
"view.content.content-unavailable.title",
systemImage: "sidebar.left",
description: Text("view.content.content-unavailable.description")
)
}
}
}
} }
// 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
@@ -63,7 +63,7 @@ extension TaskGroupListView {
Spacer() Spacer()
Text("\(count)") Text(String(count))
.font(.title) .font(.title)
.fontWeight(.bold) .fontWeight(.bold)
.foregroundStyle(group.color) .foregroundStyle(group.color)
+46 -14
View File
@@ -12,12 +12,18 @@ struct TaskGroupListView: View {
// MARK: Properties // 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. /// Query to monitor all todo tasks for changes.
/// ///
/// This query triggers view updates whenever todo tasks are inserted, updated, or deleted, /// This query triggers view updates whenever todo tasks are inserted, updated, or deleted,
/// ensuring the task counts displayed in the view remain accurate. /// ensuring the task counts displayed in the view remain accurate.
@Query @Query
private var tasks: [Todo] private var todos: [Todo]
/// The view model managing the state and logic for this view. /// The view model managing the state and logic for this view.
/// ///
@@ -26,6 +32,18 @@ struct TaskGroupListView: View {
@State @State
private var viewModel = ViewModel() 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 // MARK: Body
/// The content and behavior of the view. /// The content and behavior of the view.
@@ -35,24 +53,30 @@ struct TaskGroupListView: View {
/// - The group's title /// - The group's title
/// - The current count of tasks in that group /// - 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. /// Task counts are fetched on-demand from the model context for each render.
var body: some View { var body: some View {
ScrollView { List(
VStack(spacing: 16) { viewModel.groups,
ForEach(viewModel.groups) { group in selection: $group
NavigationLink(destination: TaskListView(group: group)) { ) { group in
Card( Card(
group: group, group: group,
count: viewModel.count(tasks, for: group) count: viewModel.count(
todos,
for: group
) )
)
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
.tag(group)
} }
.buttonStyle(.plain) .listStyle(.plain)
}
}
.padding(.horizontal)
}
.navigationTitle("view.task-group-list.navigation.title") .navigationTitle("view.task-group-list.navigation.title")
.sensoryFeedback(
.selection,
trigger: group
)
} }
} }
@@ -62,8 +86,12 @@ struct TaskGroupListView: View {
"Task group list with empty tasks", "Task group list with empty tasks",
traits: .emptyContainer traits: .emptyContainer
) { ) {
@Previewable
@State
var group: TodoGroup?
NavigationStack { NavigationStack {
TaskGroupListView() TaskGroupListView($group)
} }
} }
@@ -71,7 +99,11 @@ struct TaskGroupListView: View {
"Task group list with sample tasks", "Task group list with sample tasks",
traits: .sampleTasks traits: .sampleTasks
) { ) {
@Previewable
@State
var group: TodoGroup?
NavigationStack { 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. /// 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)
}
} }