diff --git a/Horizon/Horizon/Resources/Localizable.xcstrings b/Horizon/Horizon/Resources/Localizable.xcstrings
index 2ac0876231..960bfd548d 100644
--- a/Horizon/Horizon/Resources/Localizable.xcstrings
+++ b/Horizon/Horizon/Resources/Localizable.xcstrings
@@ -1,6 +1,13 @@
{
"sourceLanguage" : "en",
"strings" : {
+ "" : {
+
+ },
+ " , " : {
+ "comment" : "A comma used to separate items in a list.",
+ "isCommentAutoGenerated" : true
+ },
"-" : {
"localizations" : {
"ar" : {
@@ -257,6 +264,10 @@
}
}
},
+ "," : {
+ "comment" : "A comma used to separate items in a list.",
+ "isCommentAutoGenerated" : true
+ },
"'s score is now available" : {
"extractionState" : "stale",
"localizations" : {
@@ -4633,6 +4644,10 @@
}
}
},
+ "Adjust your filters to see more." : {
+ "comment" : "A description below the title that instructs the user to adjust their filters to see more content.",
+ "isCommentAutoGenerated" : true
+ },
"Advanced" : {
"localizations" : {
"ar" : {
@@ -5400,6 +5415,9 @@
}
}
}
+ },
+ "All courses" : {
+
},
"All messages" : {
"localizations" : {
@@ -24886,6 +24904,10 @@
}
}
},
+ "Count of visible courses is %@" : {
+ "comment" : "A label displaying the number of courses that are currently visible in the list. The argument is the count of visible courses.",
+ "isCommentAutoGenerated" : true
+ },
"Course %@" : {
"comment" : "A string that can be read by VoiceOver that describes the course name. The argument is the course name.",
"isCommentAutoGenerated" : true
@@ -26958,6 +26980,10 @@
"comment" : "A hint that appears when hovering over the button to select a different course.",
"isCommentAutoGenerated" : true
},
+ "Double tap to load more courses" : {
+ "comment" : "An accessibility hint for the \"Show more\" button in the course list view.",
+ "isCommentAutoGenerated" : true
+ },
"Double tap to load more notifications" : {
"localizations" : {
"ar" : {
@@ -27214,11 +27240,17 @@
}
}
},
+ "Double tap to navigate to all courses" : {
+ "comment" : "A hint that describes an action to perform on a view.",
+ "isCommentAutoGenerated" : true
+ },
"Double tap to open course" : {
-
+ "comment" : "Accessibility hint text for a course in the course list.",
+ "isCommentAutoGenerated" : true
},
"Double tap to open learning object" : {
-
+ "comment" : "Accessibility hint string for a cell that represents a learning object.",
+ "isCommentAutoGenerated" : true
},
"Double tap to open skillspace" : {
"comment" : "A hint that appears when hovering over a button in the SkillListWidgetView.",
@@ -40559,6 +40591,10 @@
}
}
},
+ "In Progress" : {
+ "comment" : "Name of the course status when the user has completed some content.",
+ "isCommentAutoGenerated" : true
+ },
"Indiana (East) (-05:00/-04:00)" : {
"localizations" : {
"ar" : {
@@ -56726,6 +56762,10 @@
"comment" : "An accessibility label describing the content of the empty view.",
"isCommentAutoGenerated" : true
},
+ "No due date" : {
+ "comment" : "Text for the due date chip when a learning object does not have a due date.",
+ "isCommentAutoGenerated" : true
+ },
"No notification activity yet." : {
"localizations" : {
"ar" : {
@@ -57750,6 +57790,10 @@
}
}
},
+ "Not Started" : {
+ "comment" : "Name of the course status when the user has not started the course.",
+ "isCommentAutoGenerated" : true
+ },
"Not submitted" : {
"localizations" : {
"ar" : {
@@ -58518,6 +58562,10 @@
}
}
},
+ "Nothing here yet" : {
+ "comment" : "A message displayed when there are no courses to display.",
+ "isCommentAutoGenerated" : true
+ },
"Notifications" : {
"localizations" : {
"ar" : {
@@ -60058,7 +60106,8 @@
},
"Open course" : {
-
+ "comment" : "A button that opens a course.",
+ "isCommentAutoGenerated" : true
},
"Open in a New Tab" : {
"localizations" : {
@@ -60317,7 +60366,8 @@
}
},
"Open learning object" : {
-
+ "comment" : "A button that opens a learning object.",
+ "isCommentAutoGenerated" : true
},
"Optional" : {
"localizations" : {
@@ -62374,8 +62424,16 @@
}
}
},
+ "Part of :" : {
+ "comment" : "Label for the status chip in the Program Name List Chip View.",
+ "isCommentAutoGenerated" : true
+ },
"Part of %@. " : {
+ },
+ "Part of program: %@" : {
+ "comment" : "A label for a chip that lists a program.",
+ "isCommentAutoGenerated" : true
},
"Perth (+08:00)" : {
"localizations" : {
@@ -72392,6 +72450,14 @@
}
}
},
+ "See all" : {
+ "comment" : "Text for a button that links to a view showing all courses.",
+ "isCommentAutoGenerated" : true
+ },
+ "See all courses" : {
+ "comment" : "A button label that links to a view showing all courses.",
+ "isCommentAutoGenerated" : true
+ },
"Select a course" : {
"localizations" : {
"ar" : {
@@ -72904,6 +72970,10 @@
}
}
},
+ "Selected filter is %@. Double tap to select another filter" : {
+ "comment" : "A hint that appears when a user taps on a filter button in the course filtering view. The argument is the name of the currently selected filter.",
+ "isCommentAutoGenerated" : true
+ },
"Send" : {
"localizations" : {
"ar" : {
@@ -94000,6 +94070,10 @@
}
}
},
+ "You’re enrolled in %d courses." : {
+ "comment" : "A label that announces the number of courses a user is enrolled in. The argument is the number of courses.",
+ "isCommentAutoGenerated" : true
+ },
"You’ve been enrolled in Course" : {
"localizations" : {
"ar" : {
diff --git a/Horizon/Horizon/Sources/Features/Dashboard/CourseList/CourseListAssembly.swift b/Horizon/Horizon/Sources/Features/Dashboard/CourseList/CourseListAssembly.swift
new file mode 100644
index 0000000000..a1c19dc13b
--- /dev/null
+++ b/Horizon/Horizon/Sources/Features/Dashboard/CourseList/CourseListAssembly.swift
@@ -0,0 +1,37 @@
+//
+// This file is part of Canvas.
+// Copyright (C) 2025-present Instructure, Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+//
+
+import Core
+import UIKit
+
+enum CourseListAssembly {
+ static func makeViewModel(courses: [CourseCardModel]) -> CourseListViewModel {
+ let onTapProgram: (ProgramSwitcherModel?, WeakViewController) -> Void = { program, viewController in
+ AppEnvironment.shared.switchToLearnTab(with: program, from: viewController)
+ }
+ return CourseListViewModel(
+ courses: courses,
+ router: AppEnvironment.shared.router,
+ onTapProgram: onTapProgram
+ )
+ }
+
+ static func makeView(courses: [CourseCardModel]) -> UIViewController {
+ CoreHostingController(CourseListView(viewModel: makeViewModel(courses: courses)))
+ }
+}
diff --git a/Horizon/Horizon/Sources/Features/Dashboard/CourseList/Data/CourseCardModel.swift b/Horizon/Horizon/Sources/Features/Dashboard/CourseList/Data/CourseCardModel.swift
new file mode 100644
index 0000000000..2d24fbc57e
--- /dev/null
+++ b/Horizon/Horizon/Sources/Features/Dashboard/CourseList/Data/CourseCardModel.swift
@@ -0,0 +1,103 @@
+//
+// This file is part of Canvas.
+// Copyright (C) 2025-present Instructure, Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+//
+
+import Core
+import Foundation
+
+struct CourseCardModel: Identifiable, Equatable {
+ let id: String
+ let enrollmentID: String
+ let name: String
+ let progress: Double
+ let programs: [CourseListWidgetModel.ProgramInfo]
+ let hasPrograms: Bool
+ let status: CourseStatus
+ let firstProgramID: String?
+ var progressPercentage: String {
+ let percentage = Int(progress.rounded())
+ return String(format: "%d%%", percentage)
+ }
+
+ init(course: HCourse) {
+ self.id = course.id
+ self.enrollmentID = course.enrollmentID
+ self.name = course.name
+ self.progress = course.progress
+ self.programs = course.programs.map { .init(id: $0.id, name: $0.name) }
+ self.hasPrograms = course.programs.isNotEmpty
+ self.status = .init(progress: progress)
+ self.firstProgramID = course.programs.first?.id
+ }
+
+ var accessibilityDescription: String {
+ var description = String.localizedStringWithFormat(
+ String(localized: "Course: %@. ", bundle: .horizon),
+ name
+ )
+
+ if programs.isNotEmpty {
+ let programsSeparated = programs.map(\.name).joined(separator: ", ")
+ if programsSeparated.isNotEmpty {
+ description += String.localizedStringWithFormat(
+ String(localized: "Part of %@. ", bundle: .horizon),
+ programsSeparated
+ )
+ }
+ }
+
+ description += String.localizedStringWithFormat(
+ String(localized: "Progress: %d percent complete. ", bundle: .horizon),
+ Int(progress.rounded())
+ )
+
+ return description
+ }
+ func viewProgramAccessibilityString(_ programName: String) -> String {
+ String.localizedStringWithFormat(
+ String(localized: "Open %@", bundle: .horizon),
+ programName
+ )
+ }
+
+ enum CourseStatus: CaseIterable {
+ case all
+ case notStarted
+ case inProgress
+ case completed
+
+ init(progress: Double) {
+ switch progress {
+ case 100.0:
+ self = .completed
+ case 0.0:
+ self = .notStarted
+ default:
+ self = .inProgress
+ }
+ }
+
+ var name: String {
+ switch self {
+ case .all: String(localized: "All courses", bundle: .horizon)
+ case .inProgress: String(localized: "In Progress", bundle: .horizon)
+ case .completed: String(localized: "Completed", bundle: .horizon)
+ case .notStarted: String(localized: "Not Started", bundle: .horizon)
+ }
+ }
+ }
+}
diff --git a/Horizon/Horizon/Sources/Features/Dashboard/CourseList/Views/CourseListView.swift b/Horizon/Horizon/Sources/Features/Dashboard/CourseList/Views/CourseListView.swift
new file mode 100644
index 0000000000..2d63b5f1ce
--- /dev/null
+++ b/Horizon/Horizon/Sources/Features/Dashboard/CourseList/Views/CourseListView.swift
@@ -0,0 +1,212 @@
+//
+// This file is part of Canvas.
+// Copyright (C) 2025-present Instructure, Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+//
+
+import Core
+import HorizonUI
+import SwiftUI
+
+struct CourseListView: View {
+ // MARK: - VO
+
+ @State private var lastFocusedCourseID: String?
+ @AccessibilityFocusState private var focusedCourseID: String?
+ private let selectFilterFocusedID = "selectFilterFocusedID"
+
+ // MARK: - Private variables
+
+ @Environment(\.dismiss) private var dismiss
+ @Environment(\.viewController) private var viewController
+ @State private var isShowHeader: Bool = true
+ @State private var isShowDivider: Bool = false
+ @State private var selectedStatus: CourseCardModel.CourseStatus = .all
+
+ // MARK: - Dependencies
+
+ let viewModel: CourseListViewModel
+
+ var body: some View {
+ ScrollView(showsIndicators: false) {
+ VStack(spacing: .zero) {
+ navigationBarHelperView
+ if viewModel.filteredCourses.isEmpty {
+ CourseListEmptyView()
+ } else {
+ coursesView
+ }
+ if viewModel.isSeeMoreButtonVisible {
+ seeMoreButton
+ }
+ }
+ .padding(.horizontal, .huiSpaces.space16)
+ .padding(.bottom, .huiSpaces.space24)
+ }
+ .toolbar(.hidden)
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .background(Color.huiColors.surface.pagePrimary)
+ .safeAreaInset(edge: .top, spacing: .zero) { headerView }
+ .animation(.linear, value: isShowHeader)
+ .animation(.easeInOut, value: viewModel.filteredCourses.count)
+ .onAppear { restoreFocusIfNeeded(after: 0.1) }
+ }
+
+ private var headerView: some View {
+ VStack(alignment: .leading, spacing: .zero) {
+ if isShowHeader {
+ navigationBar
+ .padding([.horizontal, .bottom], .huiSpaces.space24)
+ .transition(.move(edge: .top).combined(with: .opacity))
+ }
+ courseFilterView
+
+ Rectangle()
+ .fill(Color.huiColors.primitives.grey14)
+ .frame(height: 1.5)
+ .hidden(!isShowDivider)
+ }
+ .padding(.top, .huiSpaces.space8)
+ .background(Color.huiColors.surface.pagePrimary)
+ }
+
+ private var coursesView: some View {
+ VStack(spacing: .huiSpaces.space16) {
+ ForEach(viewModel.filteredCourses) { course in
+ Button {
+ lastFocusedCourseID = course.id
+ viewModel.navigateToCourseDetails(course: course, viewController: viewController)
+ } label: {
+ CourseCardView(course: course) { program in
+ lastFocusedCourseID = course.id
+ viewModel.navigateProgram(id: program.id, viewController: viewController)
+ }
+ .accessibilityActions {
+ Button("Open course") {
+ viewModel.navigateToCourseDetails(course: course, viewController: viewController)
+ }
+
+ ForEach(course.programs) { program in
+ Button {
+ viewModel.navigateProgram(id: program.id, viewController: viewController)
+ } label: {
+ Text(course.viewProgramAccessibilityString(program.name))
+ }
+ }
+ }
+ }
+ .accessibilityFocused($focusedCourseID, equals: course.id)
+ .buttonStyle(.plain)
+ .accessibilityRemoveTraits(.isButton)
+ }
+ }
+ }
+
+ private var courseFilterView: some View {
+ HStack(spacing: .zero) {
+ CourseFilteringView(selectedStatus: selectedStatus) { status in
+ viewModel.filter(status: status ?? .all)
+ lastFocusedCourseID = selectFilterFocusedID
+ restoreFocusIfNeeded(after: 1)
+ selectedStatus = status ?? .all
+ }
+ .frame(maxWidth: 200)
+ .fixedSize(horizontal: true, vertical: false)
+
+ Spacer()
+ Text(viewModel.filteredCourses.count.description)
+ .foregroundStyle(Color.huiColors.text.dataPoint)
+ .huiTypography(.p1)
+ .accessibilityLabel(
+ Text(
+ String(
+ format: String(localized: "Count of visible courses is %@"),
+ viewModel.filteredCourses.count.description
+ )
+ )
+ )
+ }
+ .padding(.horizontal, .huiSpaces.space16)
+ .padding(.bottom, .huiSpaces.space16)
+ .id(selectFilterFocusedID)
+ .accessibilityFocused($focusedCourseID, equals: selectFilterFocusedID)
+
+ }
+
+ private var navigationBarHelperView: some View {
+ Color.clear
+ .frame(height: 1)
+ .readingFrame { frame in
+ isShowHeader = frame.minY > -100
+ isShowDivider = frame.minY < 100
+ }
+ }
+
+ private var navigationBar: some View {
+ TitleBar(
+ onBack: { _ in dismiss() },
+ onClose: nil
+ ) {
+ Text("All courses", bundle: .horizon)
+ .frame(maxWidth: .infinity)
+ .huiTypography(.h3)
+ .foregroundStyle(Color.huiColors.text.title)
+ .accessibilityAddTraits(.isHeader)
+ }
+ }
+
+ private var seeMoreButton: some View {
+ HorizonUI.PrimaryButton(
+ String(localized: "Show more", bundle: .horizon),
+ type: .whiteGrayOutline,
+ isSmall: true,
+ fillsWidth: true
+ ) {
+ viewModel.seeMore()
+ }
+ .accessibilityLabel(String(localized: "Show more"))
+ .accessibilityHint( String(localized: "Double tap to load more courses"))
+ .padding(.top, .huiSpaces.space16)
+ }
+
+ private func restoreFocusIfNeeded(after: Double) {
+ guard let lastFocused = lastFocusedCourseID else { return }
+ DispatchQueue.main.asyncAfter(deadline: .now() + after) {
+ focusedCourseID = lastFocused
+ }
+ }
+}
+
+#Preview {
+ CourseListView(
+ viewModel: .init(
+ courses: [
+ .init(course: .init(id: "1", name: "Course111", progress: 100)),
+ .init(course: .init(id: "2", name: "Course111", progress: 20)),
+ .init(course: .init(id: "3", name: "Course111", progress: 0)),
+ .init(course: .init(id: "4", name: "Course111", progress: 12)),
+ .init(course: .init(id: "5", name: "Course111", progress: 90)),
+ .init(course: .init(id: "6", name: "Course111", progress: 100)),
+ .init(course: .init(id: "7", name: "Course111", progress: 0)),
+ .init(course: .init(id: "8", name: "Course111", progress: 80)),
+ .init(course: .init(id: "9", name: "Course111", progress: 0)),
+ .init(course: .init(id: "10", name: "Course111", progress: 90)),
+ .init(course: .init(id: "11", name: "Course111", progress: 100)),
+ .init(course: .init(id: "12", name: "Course111", progress: 30))
+ ],
+ router: AppEnvironment.shared.router
+ ) { _, _ in }
+ )
+}
diff --git a/Horizon/Horizon/Sources/Features/Dashboard/CourseList/Views/CourseListViewModel.swift b/Horizon/Horizon/Sources/Features/Dashboard/CourseList/Views/CourseListViewModel.swift
new file mode 100644
index 0000000000..e40cbb7017
--- /dev/null
+++ b/Horizon/Horizon/Sources/Features/Dashboard/CourseList/Views/CourseListViewModel.swift
@@ -0,0 +1,104 @@
+//
+// This file is part of Canvas.
+// Copyright (C) 2025-present Instructure, Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+//
+
+import Core
+import Observation
+
+@Observable
+final class CourseListViewModel {
+ // MARK: - Outputs
+ private(set) var filteredCourses: [CourseCardModel] = []
+ private(set) var isSeeMoreButtonVisible: Bool = false
+
+ // MARK: - Private Properties
+
+ private var allCourses: [CourseCardModel]
+ private var paginatedCourses: [[CourseCardModel]] = []
+ private var totalPages = 0
+ private var currentPage = 0 {
+ didSet {
+ isSeeMoreButtonVisible = currentPage < totalPages - 1
+ }
+ }
+
+ // MARK: - Dependencies
+ private let router: Router
+ private let onTapProgram: (ProgramSwitcherModel?, WeakViewController) -> Void
+
+ // MARK: - Init
+ init(
+ courses: [CourseCardModel],
+ router: Router,
+ onTapProgram: @escaping (ProgramSwitcherModel?, WeakViewController) -> Void
+ ) {
+ self.allCourses = courses
+ self.router = router
+ self.onTapProgram = onTapProgram
+
+ setupPagination(with: courses)
+ }
+
+ // MARK: - Pagination Setup
+ private func setupPagination(with courses: [CourseCardModel]) {
+ paginatedCourses = courses.chunked(into: 10)
+ totalPages = paginatedCourses.count
+ currentPage = 0
+ filteredCourses = paginatedCourses.first ?? []
+ isSeeMoreButtonVisible = totalPages > 1
+ }
+
+ // MARK: - Input Actions
+
+ func filter(status: CourseCardModel.CourseStatus) {
+ let filtered: [CourseCardModel]
+ switch status {
+ case .all:
+ filtered = allCourses
+ case .completed:
+ filtered = allCourses.filter { $0.status == .completed }
+ case .notStarted:
+ filtered = allCourses.filter { $0.status == .notStarted }
+ case .inProgress:
+ filtered = allCourses.filter { $0.status == .inProgress }
+ }
+
+ setupPagination(with: filtered)
+ }
+
+ func seeMore() {
+ currentPage += 1
+ guard currentPage < totalPages else { return }
+ filteredCourses.append(contentsOf: paginatedCourses[currentPage])
+ }
+
+ // MARK: - Navigation
+ func navigateToCourseDetails(course: CourseCardModel, viewController: WeakViewController) {
+ router.show(
+ LearnAssembly.makeCourseDetailsViewController(
+ courseID: course.id,
+ enrollmentID: course.enrollmentID,
+ programID: course.firstProgramID
+ ),
+ from: viewController
+ )
+ }
+
+ func navigateProgram(id: String, viewController: WeakViewController) {
+ onTapProgram(.init(id: id), viewController)
+ }
+}
diff --git a/Horizon/Horizon/Sources/Features/Dashboard/CourseList/Views/SubViews/CourseCardView.swift b/Horizon/Horizon/Sources/Features/Dashboard/CourseList/Views/SubViews/CourseCardView.swift
new file mode 100644
index 0000000000..77f55acfd8
--- /dev/null
+++ b/Horizon/Horizon/Sources/Features/Dashboard/CourseList/Views/SubViews/CourseCardView.swift
@@ -0,0 +1,149 @@
+//
+// This file is part of Canvas.
+// Copyright (C) 2025-present Instructure, Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+//
+
+import HorizonUI
+import SwiftUI
+
+struct CourseCardView: View {
+ let course: CourseCardModel
+ let onTapProgram: (CourseListWidgetModel.ProgramInfo) -> Void
+ var body: some View {
+ VStack(alignment: .leading, spacing: .huiSpaces.space16) {
+ if course.hasPrograms {
+ programLinkSection
+ }
+
+ Text(course.name)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .huiTypography(.labelLargeBold)
+ .foregroundStyle(Color.huiColors.text.title)
+ .multilineTextAlignment(.leading)
+ progressbarView
+ }
+ .padding(.huiSpaces.space24)
+ .background(Color.huiColors.surface.pageSecondary)
+ .huiCornerRadius(level: .level3_5)
+ .huiElevation(level: .level4)
+ .accessibilityElement(children: .combine)
+ .accessibilityLabel(course.accessibilityDescription)
+ }
+
+ private var programLinkSection: some View {
+ ProgramNameListView(programs: course.programs) { program in
+ onTapProgram(program)
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+
+ private var progressbarView: some View {
+ HStack(spacing: .huiSpaces.space8) {
+ HorizonUI.ProgressBar(
+ progress: course.progress / 100.0,
+ progressColor: .huiColors.surface.institution,
+ size: .small,
+ numberPosition: .hidden,
+ backgroundColor: Color.huiColors.primitives.grey14
+ )
+
+ Text(course.progressPercentage)
+ .huiTypography(.p2)
+ .foregroundStyle(Color.huiColors.surface.institution)
+ }
+ }
+}
+
+#Preview {
+ CourseCardView(
+ course: .init(
+ course: HCourse(
+ id: "mock-course-id",
+ name: "This is a mock course",
+ state: HCourse.EnrollmentState.active.rawValue,
+ progress: 40,
+ currentLearningObject: nil,
+ programs: [
+ Program(
+ id: "mock-program-id-1",
+ name: "This is a test program",
+ variant: "",
+ description: nil,
+ date: "",
+ courseCompletionCount: nil,
+ courses: [ProgramCourse(
+ id: "1",
+ isSelfEnrolled: false,
+ isRequired: false,
+ status: "",
+ progressID: "",
+ completionPercent: 0
+ )]
+ ),
+ Program(
+ id: "mock-program-id-2",
+ name: "This is a test program",
+ variant: "",
+ description: nil,
+ date: "",
+ courseCompletionCount: nil,
+ courses: [ProgramCourse(
+ id: "1",
+ isSelfEnrolled: false,
+ isRequired: false,
+ status: "",
+ progressID: "",
+ completionPercent: 0
+ )]
+ ),
+ Program(
+ id: "mock-program-id-3",
+ name: "This is a test program",
+ variant: "",
+ description: nil,
+ date: "",
+ courseCompletionCount: nil,
+ courses: [ProgramCourse(
+ id: "1",
+ isSelfEnrolled: false,
+ isRequired: false,
+ status: "",
+ progressID: "",
+ completionPercent: 0
+ )]
+ ),
+ Program(
+ id: "mock-program-id-4",
+ name: "This is a test program",
+ variant: "",
+ description: nil,
+ date: "",
+ courseCompletionCount: nil,
+ courses: [ProgramCourse(
+ id: "1",
+ isSelfEnrolled: false,
+ isRequired: false,
+ status: "",
+ progressID: "",
+ completionPercent: 0
+ )]
+ )
+ ]
+ )
+ )
+ ) { _ in }
+ .padding()
+}
diff --git a/Horizon/Horizon/Sources/Features/Dashboard/CourseList/Views/SubViews/CourseFilteringView.swift b/Horizon/Horizon/Sources/Features/Dashboard/CourseList/Views/SubViews/CourseFilteringView.swift
new file mode 100644
index 0000000000..2b3b1d7019
--- /dev/null
+++ b/Horizon/Horizon/Sources/Features/Dashboard/CourseList/Views/SubViews/CourseFilteringView.swift
@@ -0,0 +1,66 @@
+//
+// This file is part of Canvas.
+// Copyright (C) 2025-present Instructure, Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+//
+
+import HorizonUI
+import SwiftUI
+
+struct CourseFilteringView: View {
+ let selectedStatus: CourseCardModel.CourseStatus?
+ @State private var isListCoursesVisiable = false
+ let onSelect: (CourseCardModel.CourseStatus?) -> Void
+
+ var body: some View {
+ CourseSelectionButton(status: selectedStatus?.name ?? "") {
+ isListCoursesVisiable.toggle()
+ }
+ .frame(minWidth: 130)
+ .accessibilityHint(
+ Text(
+ String.localizedStringWithFormat(
+ String(localized: "Selected filter is %@. Double tap to select another filter", bundle: .horizon),
+ selectedStatus?.name ?? ""
+ )
+ )
+ )
+ .popover(isPresented: $isListCoursesVisiable, attachmentAnchor: .point(.center), arrowEdge: .top) {
+ courseListView
+ .presentationCompactAdaptation(.none)
+ .presentationBackground(Color.huiColors.surface.cardPrimary)
+ }
+ .accessibilityRemoveTraits(.isButton)
+ }
+
+ private var courseListView: some View {
+ ScrollView {
+ VStack(spacing: .zero) {
+ ForEach(CourseCardModel.CourseStatus.allCases, id: \.self) { status in
+ Button {
+ onSelect(status)
+ isListCoursesVisiable.toggle()
+ } label: {
+ TimeSpentCourseView(
+ name: status.name,
+ isSelected: status == selectedStatus
+ )
+ }
+ }
+ }
+ .padding(.vertical, .huiSpaces.space10)
+ }
+ }
+}
diff --git a/Horizon/Horizon/Sources/Features/Dashboard/CourseList/Views/SubViews/CourseListEmptyView.swift b/Horizon/Horizon/Sources/Features/Dashboard/CourseList/Views/SubViews/CourseListEmptyView.swift
new file mode 100644
index 0000000000..82273fda13
--- /dev/null
+++ b/Horizon/Horizon/Sources/Features/Dashboard/CourseList/Views/SubViews/CourseListEmptyView.swift
@@ -0,0 +1,38 @@
+//
+// This file is part of Canvas.
+// Copyright (C) 2025-present Instructure, Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+//
+
+import HorizonUI
+import SwiftUI
+
+struct CourseListEmptyView: View {
+ var body: some View {
+ VStack(alignment: .leading, spacing: .huiSpaces.space8) {
+ Text("Nothing here yet")
+ .huiTypography(.h2)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ Text("Adjust your filters to see more.")
+ .huiTypography(.p1)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+ .foregroundStyle(Color.huiColors.text.body)
+ }
+}
+
+#Preview {
+ CourseListEmptyView()
+}
diff --git a/Horizon/Horizon/Sources/Features/Dashboard/CourseList/Views/SubViews/CourseSelectionButton.swift b/Horizon/Horizon/Sources/Features/Dashboard/CourseList/Views/SubViews/CourseSelectionButton.swift
new file mode 100644
index 0000000000..a335760f36
--- /dev/null
+++ b/Horizon/Horizon/Sources/Features/Dashboard/CourseList/Views/SubViews/CourseSelectionButton.swift
@@ -0,0 +1,46 @@
+//
+// This file is part of Canvas.
+// Copyright (C) 2025-present Instructure, Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+//
+
+import HorizonUI
+import SwiftUI
+
+struct CourseSelectionButton: View {
+ let status: String
+ let onTap: () -> Void
+
+ var body: some View {
+ HorizonUI.Chip(
+ title: status,
+ style: .custom(
+ .init(
+ state: .default,
+ foregroundColor: Color.huiColors.text.title,
+ backgroundNormal: Color.huiColors.surface.cardPrimary,
+ backgroundPressed: Color.huiColors.surface.hover,
+ borderColor: Color.huiColors.lineAndBorders.lineStroke,
+ focusedBorderColor: Color.huiColors.lineAndBorders.lineStroke,
+ iconColor: Color.huiColors.icon.medium
+ )
+ ),
+ size: .large,
+ trallingIcon: Image.huiIcons.keyboardArrowDown
+ ) {
+ onTap()
+ }
+ }
+}
diff --git a/Horizon/Horizon/Sources/Features/Dashboard/CourseListWidget/View/CourseListWidgetItemView.swift b/Horizon/Horizon/Sources/Features/Dashboard/CourseListWidget/View/CourseListWidgetItemView.swift
index 24a7b0acb2..7d270d9861 100644
--- a/Horizon/Horizon/Sources/Features/Dashboard/CourseListWidget/View/CourseListWidgetItemView.swift
+++ b/Horizon/Horizon/Sources/Features/Dashboard/CourseListWidget/View/CourseListWidgetItemView.swift
@@ -22,33 +22,37 @@ import SwiftUI
struct CourseListWidgetItemView: View {
let model: CourseListWidgetModel
let width: CGFloat
+ let currentIndex: Int
+ let totalCount: Int
let onCourseTap: (String) -> Void
let onProgramTap: ((String) -> Void)?
let onLearningObjectTap: ((String, URL?) -> Void)?
-
+ private let isVoiceOverRunning = UIAccessibility.isVoiceOverRunning
private let imageHeight: CGFloat = 182
var body: some View {
ZStack(alignment: .top) {
VStack(spacing: .zero) {
- courseImageSection
- .onTapGesture {
- onCardTapGesture()
- }
- courseContentSection
- }
+ ZStack(alignment: .topLeading) {
+ courseImageSection
+ .contentShape(Rectangle())
+ .onTapGesture {
+ onCourseTap(model.id)
+ }
+ if model.hasPrograms {
+ programLinkSection
- Color.clear // This is needed to overwrite a11y VO automatic tap gesture mechanism.
- .frame(height: imageHeight)
- .contentShape(Rectangle())
- .onTapGesture {
- if model.id != "mock-course-id" { // This is mock data for skeleton loading so we disable user interaction.
- onCourseTap(model.id)
}
}
- .allowsHitTesting(true)
- .accessibilityHidden(true)
+
+ courseContentSection
+ Spacer()
+ counterView
+
+ }
+ voiceOverHelperView
}
+ .padding(.bottom, .huiSpaces.space16)
.background(Color.huiColors.surface.pageSecondary)
.huiCornerRadius(level: .level5)
.huiElevation(level: .level4)
@@ -127,10 +131,6 @@ struct CourseListWidgetItemView: View {
private var courseContentSection: some View {
VStack(alignment: .leading, spacing: .huiSpaces.space16) {
- if model.hasPrograms {
- programLinkSection
- }
-
courseTitleAndProgressSection
.onTapGesture {
onCourseTap(model.id)
@@ -144,23 +144,23 @@ struct CourseListWidgetItemView: View {
}
}
.padding(.horizontal, .huiSpaces.space24)
- .padding(.bottom, .huiSpaces.space24)
}
private var programLinkSection: some View {
- ProgramNameListView(programs: model.programs) { program in
+ ProgramNameListChipView(programs: model.programs) { program in
onProgramTap?(program.id)
}
.frame(maxWidth: .infinity, alignment: .leading)
.skeletonLoadable()
+ .padding(.huiSpaces.space24)
}
private var courseTitleAndProgressSection: some View {
VStack(alignment: .leading, spacing: .huiSpaces.space12) {
Text(model.name)
- .huiTypography(.h4)
+ .huiTypography(.labelLargeBold)
.foregroundStyle(Color.huiColors.text.title)
- .lineLimit(2)
+ .lineLimit(1)
.multilineTextAlignment(.leading)
.frame(maxWidth: .infinity, alignment: .leading)
.skeletonLoadable()
@@ -185,10 +185,12 @@ struct CourseListWidgetItemView: View {
.foregroundStyle(Color.huiColors.surface.institution)
.skeletonLoadable()
}
+ .padding(.vertical, .huiSpaces.space8)
if model.isCourseCompleted {
Text("Congrats! You’ve completed your course. View your progress and scores on the Learn page.")
.huiTypography(.p1)
.foregroundColor(.huiColors.text.title)
+ .padding(.top, .huiSpaces.space16)
}
}
}
@@ -200,15 +202,12 @@ struct CourseListWidgetItemView: View {
onLearningObjectTap?(model.id, learningObject.url)
} label: {
VStack(alignment: .leading, spacing: .huiSpaces.space12) {
- HStack {
- Text(learningObject.name)
- .huiTypography(.p2)
- .foregroundStyle(Color.huiColors.text.body)
- .lineLimit(1)
- .frame(maxWidth: .infinity, alignment: .leading)
- Spacer()
- }
- .skeletonLoadable()
+ Text(learningObject.name)
+ .huiTypography(.p2)
+ .foregroundStyle(Color.huiColors.text.body)
+ .lineLimit(1)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .skeletonLoadable()
learningObjectMetadata(for: learningObject)
}
@@ -228,21 +227,21 @@ struct CourseListWidgetItemView: View {
}
private func learningObjectMetadata(for learningObject: CourseListWidgetModel.LearningObjectInfo) -> some View {
- HorizonUI.WrappingHStack(spacing: .huiSpaces.space8) {
- if let type = learningObject.type {
- HorizonUI.StatusChip(
- title: type.rawValue,
- style: .white,
- icon: type.getIcon(isAssessment: false),
- isFilled: true
- )
- .skeletonLoadable()
- .accessibilityHidden(true)
- }
+ VStack(alignment: .leading, spacing: .huiSpaces.space8) {
+ HStack(spacing: .huiSpaces.space8) {
+ if let estimatedDuration = learningObject.estimatedDuration {
+ HorizonUI.StatusChip(
+ title: estimatedDuration,
+ style: .white,
+ icon: .huiIcons.schedule,
+ isFilled: true
+ )
+ .skeletonLoadable()
+ .accessibilityHidden(true)
+ }
- if let dueDate = learningObject.dueDate {
HorizonUI.StatusChip(
- title: dueDate,
+ title: learningObject.dueDate ?? String(localized: "No due date"),
style: .white,
icon: .huiIcons.calendarToday,
isFilled: true
@@ -251,17 +250,41 @@ struct CourseListWidgetItemView: View {
.accessibilityHidden(true)
}
- if let duration = learningObject.estimatedDuration {
- HorizonUI.StatusChip(
- title: duration,
- style: .white,
- icon: .huiIcons.schedule,
- isFilled: true
- )
- .skeletonLoadable()
+ HorizonUI.StatusChip(
+ title: (learningObject.type ?? .assessment).rawValue,
+ style: .white,
+ icon: (learningObject.type ?? .assessment).getIcon(isAssessment: false),
+ isFilled: true
+ )
+ .skeletonLoadable()
+ .accessibilityHidden(true)
+ .hidden((learningObject.type == nil))
+ }
+ }
+
+ @ViewBuilder
+ private var voiceOverHelperView: some View {
+ if isVoiceOverRunning {
+ Color.clear // This is needed to overwrite a11y VO automatic tap gesture mechanism.
+ .frame(height: imageHeight)
+ .contentShape(Rectangle())
+ .onTapGesture {
+ if model.id != "mock-course-id" { // This is mock data for skeleton loading so we disable user interaction.
+ onCourseTap(model.id)
+ }
+ }
+ .allowsHitTesting(true)
.accessibilityHidden(true)
- }
- Spacer()
+ }
+ }
+
+ @ViewBuilder
+ private var counterView: some View {
+ if totalCount > 1 {
+ CounterTextView(
+ currentIndex: currentIndex + 1,
+ totalCount: totalCount
+ )
}
}
}
@@ -286,7 +309,10 @@ struct CourseListWidgetItemView: View {
estimatedDuration: "xxxxx",
url: nil
)
- ), width: 300,
+ ),
+ width: 300,
+ currentIndex: 1,
+ totalCount: 10,
onCourseTap: { _ in },
onProgramTap: { _ in },
onLearningObjectTap: { _, _ in }
@@ -310,7 +336,10 @@ struct CourseListWidgetItemView: View {
estimatedDuration: "XX mins",
url: nil
)
- ), width: 300,
+ ),
+ width: 300,
+ currentIndex: 2,
+ totalCount: 10,
onCourseTap: { _ in },
onProgramTap: { _ in },
onLearningObjectTap: { _, _ in }
@@ -337,7 +366,10 @@ struct CourseListWidgetItemView: View {
estimatedDuration: "XX mins",
url: nil
)
- ), width: 300,
+ ),
+ width: 300,
+ currentIndex: 3,
+ totalCount: 10,
onCourseTap: { _ in },
onProgramTap: { _ in },
onLearningObjectTap: { _, _ in }
@@ -359,7 +391,10 @@ struct CourseListWidgetItemView: View {
estimatedDuration: "XX mins",
url: nil
)
- ), width: 300,
+ ),
+ width: 300,
+ currentIndex: 3,
+ totalCount: 10,
onCourseTap: { _ in },
onProgramTap: { _ in },
onLearningObjectTap: { _, _ in }
@@ -374,7 +409,10 @@ struct CourseListWidgetItemView: View {
lastActivityAt: nil,
programs: [],
currentLearningObject: nil
- ), width: 300,
+ ),
+ width: 300,
+ currentIndex: 3,
+ totalCount: 10,
onCourseTap: { _ in },
onProgramTap: { _ in },
onLearningObjectTap: { _, _ in }
diff --git a/Horizon/Horizon/Sources/Features/Dashboard/CourseListWidget/View/CourseListWidgetSeeAllCoursesView.swift b/Horizon/Horizon/Sources/Features/Dashboard/CourseListWidget/View/CourseListWidgetSeeAllCoursesView.swift
new file mode 100644
index 0000000000..480a26eda0
--- /dev/null
+++ b/Horizon/Horizon/Sources/Features/Dashboard/CourseListWidget/View/CourseListWidgetSeeAllCoursesView.swift
@@ -0,0 +1,64 @@
+//
+// This file is part of Canvas.
+// Copyright (C) 2025-present Instructure, Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+//
+
+import HorizonUI
+import SwiftUI
+
+struct CourseListWidgetSeeAllCoursesView: View {
+ let count: Int
+ let onTap: () -> Void
+
+ var body: some View {
+ VStack(spacing: .huiSpaces.space16) {
+ Image.huiIcons.book2Filled
+ .foregroundStyle(Color.huiColors.surface.institution)
+ .accessibilityHidden(true)
+
+ Text(
+ String.localizedStringWithFormat(
+ String(localized: "You’re enrolled in %d courses.", bundle: .horizon), count
+ )
+ )
+ .foregroundStyle(Color.huiColors.text.title)
+ .huiTypography(.p1)
+
+ HorizonUI.PrimaryButton(
+ String(localized: "See all"),
+ type: .grayOutline,
+ isSmall: true,
+ fillsWidth: true,
+ trailing: Image.huiIcons.arrowForward
+ ) {
+ onTap()
+ }
+ .accessibilityLabel(String(localized: "See all courses", bundle: .horizon))
+ .accessibilityHint(String(localized: "Double tap to navigate to all courses", bundle: .horizon))
+ .accessibilityAddTraits(.isButton)
+ }
+ .padding(.huiSpaces.space24)
+ .frame(height: 442)
+ .background(Color.huiColors.surface.pageSecondary)
+ .huiCornerRadius(level: .level5)
+ .huiElevation(level: .level4)
+ }
+}
+
+#Preview {
+ CourseListWidgetSeeAllCoursesView(count: 12) {}
+ .padding()
+}
diff --git a/Horizon/Horizon/Sources/Features/Dashboard/CourseListWidget/View/CourseListWidgetView.swift b/Horizon/Horizon/Sources/Features/Dashboard/CourseListWidget/View/CourseListWidgetView.swift
index 2d4160029f..54d867cb89 100644
--- a/Horizon/Horizon/Sources/Features/Dashboard/CourseListWidget/View/CourseListWidgetView.swift
+++ b/Horizon/Horizon/Sources/Features/Dashboard/CourseListWidget/View/CourseListWidgetView.swift
@@ -29,6 +29,8 @@ struct CourseListWidgetView: View {
@State private var currentCourseIndex: Int? = 0
@State private var bounceScale: CGFloat = 1.0
@State private var scrollViewID = UUID()
+ private let focusedseeAllCoursesButton = "focusedseeAllCoursesButton"
+ private let focusedseeAllCoursesCard = "focusedseeAllCoursesCard"
init(viewModel: CourseListWidgetViewModel) {
_viewModel = State(initialValue: viewModel)
@@ -40,6 +42,7 @@ struct CourseListWidgetView: View {
case .data, .loading:
programCardsView
dataView
+ seeAllCourseButton
case .empty:
emptyView
case .error:
@@ -78,10 +81,12 @@ struct CourseListWidgetView: View {
SingleAxisGeometryReader(initialSize: 300) { size in
ScrollView(.horizontal) {
HStack(alignment: .top, spacing: .huiSpaces.space12) {
- ForEach(Array(viewModel.courses.enumerated()), id: \.element.id) { index, course in
+ ForEach(Array(viewModel.allowedCourse.enumerated()), id: \.element.id) { index, course in
CourseListWidgetItemView(
model: CourseListWidgetModel(from: course),
width: size - 48,
+ currentIndex: index,
+ totalCount: viewModel.courses.count,
onCourseTap: { courseId in
lastFocusedElement.wrappedValue = .course(id: courseId)
viewModel.navigateToCourseDetails(
@@ -117,6 +122,16 @@ struct CourseListWidgetView: View {
}
.id(index)
}
+
+ if viewModel.isExceededMaxCourses {
+ CourseListWidgetSeeAllCoursesView(count: viewModel.courses.count) {
+ lastFocusedElement.wrappedValue = .course(id: focusedseeAllCoursesCard)
+ viewModel.navigateToListCourse(viewController: viewController)
+ }
+ .frame(width: size - 48)
+ .id(focusedseeAllCoursesCard)
+ .accessibilityFocused($focusedCourseID, equals: focusedseeAllCoursesCard)
+ }
}
.scrollTargetLayout()
.padding(.horizontal, .huiSpaces.space24)
@@ -127,10 +142,6 @@ struct CourseListWidgetView: View {
.scrollPosition(id: $currentCourseIndex)
.id(scrollViewID)
}
-
- if viewModel.courses.count >= 4 {
- PaginationIndicatorView(currentIndex: $currentCourseIndex, count: viewModel.courses.count)
- }
}
}
@@ -157,4 +168,16 @@ struct CourseListWidgetView: View {
CourseListWidgetEmptyView()
.padding(.horizontal, .huiSpaces.space24)
}
+
+ @ViewBuilder
+ private var seeAllCourseButton: some View {
+ if viewModel.isExceededMaxCourses {
+ SeeAllCoursesButton {
+ lastFocusedElement.wrappedValue = .course(id: focusedseeAllCoursesButton)
+ viewModel.navigateToListCourse(viewController: viewController)
+ }
+ .id(focusedseeAllCoursesButton)
+ .accessibilityFocused($focusedCourseID, equals: focusedseeAllCoursesButton)
+ }
+ }
}
diff --git a/Horizon/Horizon/Sources/Features/Dashboard/CourseListWidget/View/CourseListWidgetViewModel.swift b/Horizon/Horizon/Sources/Features/Dashboard/CourseListWidget/View/CourseListWidgetViewModel.swift
index e33510de60..6e15bc8517 100644
--- a/Horizon/Horizon/Sources/Features/Dashboard/CourseListWidget/View/CourseListWidgetViewModel.swift
+++ b/Horizon/Horizon/Sources/Features/Dashboard/CourseListWidget/View/CourseListWidgetViewModel.swift
@@ -39,6 +39,12 @@ class CourseListWidgetViewModel {
var isProgramWidgetVisible: Bool {
unenrolledPrograms.isNotEmpty && unenrolledPrograms.first?.id != "mock-program-id"
}
+ var isExceededMaxCourses: Bool {
+ courses.count > maxCourses
+ }
+ var allowedCourse: [HCourse] {
+ Array(courses.prefix(maxCourses))
+ }
// MARK: - Dependencies
@@ -53,6 +59,7 @@ class CourseListWidgetViewModel {
private var getDashboardCoursesCancellable: AnyCancellable?
private var refreshCompletedModuleItemCancellable: AnyCancellable?
private var subscriptions = Set()
+ private let maxCourses = 3
// MARK: - Init
@@ -201,6 +208,14 @@ class CourseListWidgetViewModel {
func navigateProgram(id: String, viewController: WeakViewController) {
onTapProgram(.init(id: id), viewController)
}
+
+ func navigateToListCourse(viewController: WeakViewController) {
+ let courseCardModels: [CourseCardModel] = courses.map { .init(course: $0) }
+ router.show(
+ CourseListAssembly.makeView(courses: courseCardModels),
+ from: viewController
+ )
+ }
}
extension CourseListWidgetViewModel {
diff --git a/Horizon/Horizon/Sources/Features/Dashboard/CourseListWidget/View/ProgramNameListChipView.swift b/Horizon/Horizon/Sources/Features/Dashboard/CourseListWidget/View/ProgramNameListChipView.swift
new file mode 100644
index 0000000000..13a10e9075
--- /dev/null
+++ b/Horizon/Horizon/Sources/Features/Dashboard/CourseListWidget/View/ProgramNameListChipView.swift
@@ -0,0 +1,80 @@
+//
+// This file is part of Canvas.
+// Copyright (C) 2025-present Instructure, Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+//
+
+import HorizonUI
+import SwiftUI
+
+struct ProgramNameListChipView: View {
+ let programs: [CourseListWidgetModel.ProgramInfo]
+ let onSelect: (CourseListWidgetModel.ProgramInfo) -> Void
+ var body: some View {
+ HorizonUI.HFlow {
+ ForEach(programs) { program in
+ Button {
+ onSelect(program)
+ } label: {
+ HorizonUI.StatusChip(
+ title: program.name,
+ style: .white,
+ label: String(localized: "Part of :"),
+ hasBorder: true
+ )
+ }
+ .accessibilityElement(children: .ignore)
+ .accessibilityLabel(String(localized: "Part of program: \(program.name)"))
+ }
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+}
+
+#Preview {
+ ProgramNameListChipView(
+ programs: [
+ CourseListWidgetModel.ProgramInfo(
+ id: "1",
+ name: "Program 1 Program 1 Program 1 Program 1 Program 1 Program 1 "
+ ),
+ CourseListWidgetModel.ProgramInfo(
+ id: "2",
+ name: "Program 2"
+ ),
+ CourseListWidgetModel.ProgramInfo(
+ id: "3",
+ name: " Program 3 "
+ ),
+ CourseListWidgetModel.ProgramInfo(
+ id: "4",
+ name: "Test Program 4"
+ ),
+ CourseListWidgetModel.ProgramInfo(
+ id: "5",
+ name: "Test Program 4"
+ ),
+ CourseListWidgetModel.ProgramInfo(
+ id: "6",
+ name: "Test Program 4"
+ ),
+ CourseListWidgetModel.ProgramInfo(
+ id: "7",
+ name: "Test Program 4"
+ )
+ ]
+ ) { _ in}
+ .padding()
+}
diff --git a/Horizon/Horizon/Sources/Features/Dashboard/CourseListWidget/View/ProgramNameListView.swift b/Horizon/Horizon/Sources/Features/Dashboard/CourseListWidget/View/ProgramNameListView.swift
index 533bdcd493..d947b96483 100644
--- a/Horizon/Horizon/Sources/Features/Dashboard/CourseListWidget/View/ProgramNameListView.swift
+++ b/Horizon/Horizon/Sources/Features/Dashboard/CourseListWidget/View/ProgramNameListView.swift
@@ -24,29 +24,28 @@ struct ProgramNameListView: View {
let onSelect: (CourseListWidgetModel.ProgramInfo) -> Void
var body: some View {
- HorizonUI.WrappingHStack(spacing: .huiSpaces.space4) {
+ HorizonUI.HFlow(spacing: .huiSpaces.space4, lineSpacing: .huiSpaces.space2) {
Text("Part of", bundle: .horizon)
.huiTypography(.labelSmall)
.foregroundStyle(Color.huiColors.text.timestamp)
ForEach(programs) { program in
+ let isLast = program.id == programs.last?.id
+
Button {
onSelect(program)
} label: {
- Text(program.name)
- .foregroundStyle(Color.huiColors.text.body)
- .huiTypography(.labelSmall)
- .overlay(alignment: .bottom) {
- Rectangle()
- .fill(Color.huiColors.text.body)
- .frame(height: 1)
- .offset(y: 2)
- }
- }
- if program.id != programs.last?.id {
- Text(verbatim: ",")
- .foregroundStyle(Color.huiColors.text.body)
- .huiTypography(.buttonTextLarge)
+ (
+ Text(program.name)
+ .underline(true, color: Color.huiColors.text.body)
+ +
+ Text(verbatim: isLast ? "" : " , ")
+ .underline(false)
+ )
+ .huiTypography(.labelSmallBold)
+ .foregroundStyle(Color.huiColors.text.body)
+ .baselineOffset(2)
+ .multilineTextAlignment(.leading)
}
}
}
diff --git a/Horizon/Horizon/Sources/Features/Dashboard/CourseListWidget/View/SeeAllCoursesButton.swift b/Horizon/Horizon/Sources/Features/Dashboard/CourseListWidget/View/SeeAllCoursesButton.swift
new file mode 100644
index 0000000000..f100b513e2
--- /dev/null
+++ b/Horizon/Horizon/Sources/Features/Dashboard/CourseListWidget/View/SeeAllCoursesButton.swift
@@ -0,0 +1,50 @@
+//
+// This file is part of Canvas.
+// Copyright (C) 2025-present Instructure, Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+//
+
+import HorizonUI
+import SwiftUI
+
+struct SeeAllCoursesButton: View {
+ let onTap: () -> Void
+
+ var body: some View {
+ Button {
+ onTap()
+ } label: {
+ HStack(spacing: .huiSpaces.space10) {
+ Text("See all courses")
+ .huiTypography(.buttonTextLarge)
+ .foregroundStyle(Color.huiColors.text.title)
+ .frame(alignment: .center)
+ Image.huiIcons.arrowForward
+ .foregroundStyle(Color.huiColors.icon.default)
+ }
+ .frame(maxWidth: .infinity)
+ .padding(.vertical, .huiSpaces.space10)
+ .background(Color.huiColors.surface.pageSecondary)
+ .huiCornerRadius(level: .level6)
+ .huiElevation(level: .level4)
+ .padding(.top, .huiSpaces.space16)
+ .padding(.horizontal, .huiSpaces.space24)
+ }
+ }
+}
+
+#Preview {
+ SeeAllCoursesButton {}
+}
diff --git a/Horizon/Horizon/Sources/Features/Dashboard/TimeSpentWidget/Views/SubViews/TimeSpentCourseView.swift b/Horizon/Horizon/Sources/Features/Dashboard/TimeSpentWidget/Views/SubViews/TimeSpentCourseView.swift
index 5b74d1fa66..24feb3dfce 100644
--- a/Horizon/Horizon/Sources/Features/Dashboard/TimeSpentWidget/Views/SubViews/TimeSpentCourseView.swift
+++ b/Horizon/Horizon/Sources/Features/Dashboard/TimeSpentWidget/Views/SubViews/TimeSpentCourseView.swift
@@ -32,10 +32,10 @@ struct TimeSpentCourseView: View {
Text(name)
.frame(maxWidth: .infinity, alignment: .leading)
.multilineTextAlignment(.leading)
- .frame(minHeight: 42)
.huiTypography(.buttonTextMedium)
}
.padding(.horizontal, .huiSpaces.space16)
+ .padding(.vertical, .huiSpaces.space8)
.foregroundStyle(
isSelected
? Color.huiColors.surface.pageSecondary
diff --git a/Horizon/Horizon/Sources/Features/Dashboard/TimeSpentWidget/Views/SubViews/TimeSpentWidgetListCursesView.swift b/Horizon/Horizon/Sources/Features/Dashboard/TimeSpentWidget/Views/SubViews/TimeSpentWidgetListCursesView.swift
deleted file mode 100644
index 129cd7f251..0000000000
--- a/Horizon/Horizon/Sources/Features/Dashboard/TimeSpentWidget/Views/SubViews/TimeSpentWidgetListCursesView.swift
+++ /dev/null
@@ -1,71 +0,0 @@
-//
-// This file is part of Canvas.
-// Copyright (C) 2025-present Instructure, Inc.
-//
-// This program is free software: you can redistribute it and/or modify
-// it under the terms of the GNU Affero General Public License as
-// published by the Free Software Foundation, either version 3 of the
-// License, or (at your option) any later version.
-//
-// This program is distributed in the hope that it will be useful,
-// but WITHOUT ANY WARRANTY; without even the implied warranty of
-// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-// GNU Affero General Public License for more details.
-//
-// You should have received a copy of the GNU Affero General Public License
-// along with this program. If not, see .
-//
-
-import HorizonUI
-import SwiftUI
-
-struct TimeSpentWidgetListCursesView: View {
- @State private var isCourseListVisible = false
- let courses: [TimeSpentWidgetModel]
- @State var selectedCourse: TimeSpentWidgetModel?
- let onSelect: (TimeSpentWidgetModel?) -> Void
-
- var body: some View {
- TimeSpentWidgetCourseButton(
- courseName: selectedCourse?.courseName ?? "",
- isSelected: selectedCourse != nil
- ) {
- isCourseListVisible.toggle()
- }
- .accessibilityLabel(Text(selectedCourse?.titleAccessibilityButtonLabel ?? ""))
- .accessibilityHint(Text("Double tab to select a different course", bundle: .horizon))
- .popover(isPresented: $isCourseListVisible, attachmentAnchor: .point(.center), arrowEdge: .top) {
- courseListView
- .presentationCompactAdaptation(.none)
- .presentationBackground(Color.huiColors.surface.cardPrimary)
- }
- }
-
- private var courseListView: some View {
- ScrollView {
- VStack(spacing: .zero) {
- ForEach(courses) { course in
- Button {
- selectedCourse = course
- onSelect(course)
- isCourseListVisible.toggle()
- } label: {
- TimeSpentCourseView(
- name: course.courseName,
- isSelected: course == selectedCourse
- )
- }
- .accessibilityLabel(Text(course.titleAccessibilityLabel))
- }
- }
- }
- }
-}
-
-#Preview {
- TimeSpentWidgetListCursesView(courses: [
- .init(id: "1", courseName: "Introduction to SwiftUI", minutesPerDay: 125),
- .init(id: "2", courseName: "Advanced iOS Development", minutesPerDay: 90),
- .init(id: "3", courseName: "UI/UX Design Principles", minutesPerDay: 45)
- ]) { _ in }
-}
diff --git a/Horizon/Horizon/Sources/Features/HorizonTabBarController.swift b/Horizon/Horizon/Sources/Features/HorizonTabBarController.swift
index bbb587469f..bf158dca6e 100644
--- a/Horizon/Horizon/Sources/Features/HorizonTabBarController.swift
+++ b/Horizon/Horizon/Sources/Features/HorizonTabBarController.swift
@@ -140,7 +140,7 @@ extension HorizonTabBarController {
guard let selectedIndex = tabBarController.viewControllers?.firstIndex(of: viewController) else {
return true
}
- if selectedIndex == 2, shouldPresentChatBot {
+ if selectedIndex == 2 {
presentChatBot()
return false
}
diff --git a/Horizon/HorizonUnitTests/Features/Dashboard/ViewModels/CourseListViewTests.swift b/Horizon/HorizonUnitTests/Features/Dashboard/ViewModels/CourseListViewTests.swift
new file mode 100644
index 0000000000..1a31968a15
--- /dev/null
+++ b/Horizon/HorizonUnitTests/Features/Dashboard/ViewModels/CourseListViewTests.swift
@@ -0,0 +1,166 @@
+//
+// This file is part of Canvas.
+// Copyright (C) 2025-present Instructure, Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+//
+
+@testable import Horizon
+@testable import Core
+import XCTest
+import SwiftUI
+
+final class CourseListViewTests: HorizonTestCase {
+ private var testee: CourseListViewModel!
+ private var onTapProgramCalled: Bool!
+ private var courses: [CourseCardModel]!
+
+ override func setUp() {
+ super.setUp()
+ onTapProgramCalled = false
+ courses = [
+ .init(course: .init(id: "1", name: "Course 1", progress: 100)), // Completed
+ .init(course: .init(id: "2", name: "Course 2", progress: 50)), // In Progress
+ .init(course: .init(id: "3", name: "Course 3", progress: 0)), // Not Started
+ .init(course: .init(id: "4", name: "Course 4", progress: 100)), // Completed
+ .init(course: .init(id: "5", name: "Course 5", progress: 20)), // In Progress
+ .init(course: .init(id: "6", name: "Course 6", progress: 0)), // Not Started
+ .init(course: .init(id: "7", name: "Course 7", progress: 100)), // Completed
+ .init(course: .init(id: "8", name: "Course 8", progress: 80)), // In Progress
+ .init(course: .init(id: "9", name: "Course 9", progress: 0)), // Not Started
+ .init(course: .init(id: "10", name: "Course 10", progress: 90)), // In Progress
+ .init(course: .init(id: "11", name: "Course 11", progress: 100)),// Completed
+ .init(course: .init(id: "12", name: "Course 12", progress: 30)) // In Progress
+ ]
+ }
+
+ override func tearDown() {
+ testee = nil
+ onTapProgramCalled = nil
+ courses = nil
+ super.tearDown()
+ }
+ func test_filter_byCompleted_showsCompletedCourses() {
+ // Given
+ createtestee(courses: courses)
+
+ // When
+ testee.filter(status: .completed)
+
+ // Then
+ XCTAssertEqual(testee.filteredCourses.count, 4)
+ XCTAssertTrue(testee.filteredCourses.allSatisfy { $0.status == .completed })
+ }
+
+ func test_filter_byInProgress_showsInProgressCourses() {
+ // Given
+ createtestee(courses: courses)
+
+ // When
+ testee.filter(status: .inProgress)
+
+ // Then
+ XCTAssertEqual(testee.filteredCourses.count, 5)
+ XCTAssertTrue(testee.filteredCourses.allSatisfy { $0.status == .inProgress })
+ }
+
+ func test_filter_byNotStarted_showsNotStartedCourses() {
+ // Given
+ createtestee(courses: courses)
+
+ // When
+ testee.filter(status: .notStarted)
+
+ // Then
+ XCTAssertEqual(testee.filteredCourses.count, 3)
+ XCTAssertTrue(testee.filteredCourses.allSatisfy { $0.status == .notStarted })
+ }
+
+ func test_filter_byAll_showsAllCourses() {
+ // Given
+ createtestee(courses: courses)
+
+ // When
+ testee.filter(status: .inProgress) // Filter first
+ testee.filter(status: .all) // Then select all
+
+ // Then
+ XCTAssertEqual(testee.filteredCourses.count, 10) // Paginated
+ }
+
+ func test_seeMoreButton_isVisibleWhenThereAreMorePages() {
+ // Given
+ createtestee(courses: courses) // 12 courses, 2 pages
+
+ // Then
+ XCTAssertTrue(testee.isSeeMoreButtonVisible)
+ }
+
+ func test_seeMoreButton_isHiddenWhenThereIsOnlyOnePage() {
+ // Given
+ createtestee(courses: Array(courses.prefix(5))) // 5 courses, 1 page
+
+ // Then
+ XCTAssertFalse(testee.isSeeMoreButtonVisible)
+ }
+
+ func test_seeMore_loadsNextPage() {
+ // Given
+ createtestee(courses: courses) // 12 courses, 2 pages
+ XCTAssertEqual(testee.filteredCourses.count, 10)
+
+ // When
+ testee.seeMore()
+
+ // Then
+ XCTAssertEqual(testee.filteredCourses.count, 12)
+ XCTAssertFalse(testee.isSeeMoreButtonVisible)
+ }
+
+ func test_navigateToCourseDetails_callsRouter() {
+ // Given
+ createtestee(courses: courses)
+ let courseToNavigate = courses[0]
+ let viewController = WeakViewController(UIViewController())
+ // When
+ testee.navigateToCourseDetails(course: courseToNavigate, viewController: viewController)
+
+ // Then
+ wait(for: [router.showExpectation], timeout: 1)
+ let messageDetailsVC = router.lastViewController as? CoreHostingController
+ XCTAssertNotNil(messageDetailsVC)
+ }
+
+ func test_navigateProgram_callsOnTapProgram() {
+ // Given
+ createtestee(courses: courses)
+ let viewController = WeakViewController(UIViewController())
+ // When
+ testee.navigateProgram(id: "program1", viewController: viewController)
+
+ // Then
+ XCTAssertTrue(onTapProgramCalled)
+ }
+
+}
+
+extension CourseListViewTests {
+ private func createtestee(courses: [CourseCardModel]) {
+ testee = CourseListViewModel(
+ courses: courses,
+ router: router,
+ onTapProgram: { _, _ in self.onTapProgramCalled = true }
+ )
+ }
+}
diff --git a/Horizon/HorizonUnitTests/Features/Dashboard/ViewModels/CourseListWidgetViewModelTests.swift b/Horizon/HorizonUnitTests/Features/Dashboard/ViewModels/CourseListWidgetViewModelTests.swift
index 0b591e0cf5..7a7784a5d9 100644
--- a/Horizon/HorizonUnitTests/Features/Dashboard/ViewModels/CourseListWidgetViewModelTests.swift
+++ b/Horizon/HorizonUnitTests/Features/Dashboard/ViewModels/CourseListWidgetViewModelTests.swift
@@ -293,6 +293,72 @@ final class CourseListWidgetViewModelTests: HorizonTestCase {
XCTAssertEqual(onTapProgramModel?.id, "program-1")
}
+ func test_isExceededMaxCourses_whenCoursesCountIsLessThanMax() {
+ // Given
+ courseListWidgetInteractor.coursesToReturn = Array(HCourseStubs.activeCourses.prefix(2)) // 2 courses
+ let testee = createVM()
+
+ // Then
+ XCTAssertFalse(testee.isExceededMaxCourses)
+ }
+
+ func test_isExceededMaxCourses_whenCoursesCountIsEqualToMax() {
+ // Given
+ courseListWidgetInteractor.coursesToReturn = Array(HCourseStubs.activeCourses.prefix(3)) // 3 courses
+ let testee = createVM()
+
+ // Then
+ XCTAssertFalse(testee.isExceededMaxCourses)
+ }
+
+ func test_isExceededMaxCourses_whenCoursesCountIsGreaterThanMax() {
+ // Given
+ courseListWidgetInteractor.coursesToReturn = HCourseStubs.activeCourses // 3 active courses
+ let extraCourse = HCourse(id: "extra-course", name: "Extra Course", state: "active", enrollmentID: "extra-enrollment")
+ courseListWidgetInteractor.coursesToReturn.append(extraCourse) // 4 courses
+ let testee = createVM()
+
+ // Then
+ XCTAssertTrue(testee.isExceededMaxCourses)
+ }
+
+ func test_allowedCourse_returnsAllCoursesWhenLessThanMax() {
+ // Given
+ let courses = Array(HCourseStubs.activeCourses.prefix(2)) // 2 courses
+ courseListWidgetInteractor.coursesToReturn = courses
+ let testee = createVM()
+
+ // Then
+ XCTAssertEqual(testee.allowedCourse.count, 2)
+ XCTAssertEqual(testee.allowedCourse.map(\.id), courses.map(\.id))
+ }
+
+ func test_allowedCourse_returnsMaxCoursesWhenGreaterThanMax() {
+ // Given
+ let courses = HCourseStubs.activeCourses // 3 active courses
+ let extraCourse = HCourse(id: "extra-course", name: "Extra Course", state: "active", enrollmentID: "extra-enrollment")
+ courseListWidgetInteractor.coursesToReturn = courses + [extraCourse] // 4 courses
+ let testee = createVM()
+
+ // Then
+ XCTAssertEqual(testee.allowedCourse.count, 3)
+ XCTAssertEqual(testee.allowedCourse.map(\.id), courses.map(\.id)) // Should only contain the first 3
+ }
+
+ func test_navigateToListCourse_callsRouterWithCorrectView() {
+ // Given
+ courseListWidgetInteractor.coursesToReturn = HCourseStubs.activeCourses
+ let testee = createVM()
+ let sourceView = UIViewController()
+ let viewController = WeakViewController(sourceView)
+
+ // When
+ testee.navigateToListCourse(viewController: viewController)
+
+ // Then
+ let presentedViewController = router.lastViewController as? CoreHostingController
+ XCTAssertNotNil(presentedViewController)
+ }
// MARK: - Helper Methods
private func createVM() -> CourseListWidgetViewModel {
diff --git a/packages/HorizonUI/Sources/HorizonUI/Sources/Components/Chips/StatusChip/HorizonUI.StatusChip.swift b/packages/HorizonUI/Sources/HorizonUI/Sources/Components/Chips/StatusChip/HorizonUI.StatusChip.swift
index e2eece6019..05edda7c9d 100644
--- a/packages/HorizonUI/Sources/HorizonUI/Sources/Components/Chips/StatusChip/HorizonUI.StatusChip.swift
+++ b/packages/HorizonUI/Sources/HorizonUI/Sources/Components/Chips/StatusChip/HorizonUI.StatusChip.swift
@@ -32,6 +32,7 @@ public extension HorizonUI {
private let title: String
private let isFilled: Bool
private let hasBorder: Bool
+ private let lineLimit: Int?
// MARK: - Init
@@ -41,7 +42,8 @@ public extension HorizonUI {
icon: Image? = nil,
label: String? = nil,
isFilled: Bool = true,
- hasBorder: Bool = false
+ hasBorder: Bool = false,
+ lineLimit: Int? = 1
) {
self.style = style
self.icon = icon
@@ -49,10 +51,11 @@ public extension HorizonUI {
self.title = title
self.isFilled = isFilled
self.hasBorder = hasBorder
+ self.lineLimit = lineLimit
}
public var body: some View {
- HStack(spacing: .huiSpaces.space2) {
+ HStack(alignment: .top, spacing: .huiSpaces.space2) {
if let icon = icon {
icon
.resizable()
@@ -69,6 +72,8 @@ public extension HorizonUI {
Text(title)
.foregroundStyle(style.foregroundColor(isFilled: isFilled))
.huiTypography(.p2)
+ .multilineTextAlignment(.leading)
+ .lineLimit(lineLimit)
}
.padding(.horizontal, isFilled ? .huiSpaces.space8 : .zero)
.padding(.vertical, .huiSpaces.space2)