App layout improvements #10
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
.navigationTitle("Todos")
|
||||
} 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")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Previews
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)) {
|
||||
List(
|
||||
viewModel.groups,
|
||||
selection: $group
|
||||
) { group in
|
||||
Card(
|
||||
group: group,
|
||||
count: viewModel.count(tasks, for: group)
|
||||
count: viewModel.count(
|
||||
todos,
|
||||
for: group
|
||||
)
|
||||
)
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowSeparator(.hidden)
|
||||
.tag(group)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
}
|
||||
.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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user