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)