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" : {
|
"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.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
.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
|
// 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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
.buttonStyle(.plain)
|
)
|
||||||
}
|
)
|
||||||
}
|
.listRowBackground(Color.clear)
|
||||||
.padding(.horizontal)
|
.listRowSeparator(.hidden)
|
||||||
|
.tag(group)
|
||||||
}
|
}
|
||||||
|
.listStyle(.plain)
|
||||||
.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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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