From 67854af21d52bf929099630a8a9e7cd3175d1d5a Mon Sep 17 00:00:00 2001 From: Ahmed Naguib Date: Wed, 5 Nov 2025 20:00:52 +0200 Subject: [PATCH 01/11] Update: Announcement widget UI refs: CLX-3258 builds: Student affects: Student release note: none test plan: none --- .../Horizon/Resources/Localizable.xcstrings | 12 +- .../Views/AnnouncementWidgetView.swift | 110 +++++++++--------- .../Views/AnnouncementsListWidgetView.swift | 1 + .../CommonViews/CounterTextView.swift | 42 +++++++ .../SubViews/CompletedWidgetHeader.swift | 34 ++++-- .../View/CourseListWidgetView.swift | 6 +- .../SkillsCountWidgetHeaderView.swift | 35 ++++-- .../SkillsHighlightsWidgetHeaderView.swift | 16 +-- .../SubViews/TimeSpentWidgetHeaderView.swift | 33 ++++-- .../UnenrolledProgramListItemWidgetView.swift | 53 ++++----- .../Icons/announcement.imageset/Contents.json | 14 +-- 11 files changed, 226 insertions(+), 130 deletions(-) create mode 100644 Horizon/Horizon/Sources/Features/Dashboard/CommonViews/CounterTextView.swift diff --git a/Horizon/Horizon/Resources/Localizable.xcstrings b/Horizon/Horizon/Resources/Localizable.xcstrings index c84e5c917e..2ac0876231 100644 --- a/Horizon/Horizon/Resources/Localizable.xcstrings +++ b/Horizon/Horizon/Resources/Localizable.xcstrings @@ -24893,8 +24893,8 @@ "Course: %@. " : { }, - "Courses activities" : { - "comment" : "A label describing the content of the header in the completed widget.", + "Courses activities, Widget 1 of 3" : { + "comment" : "A label describing a widget that shows the user how many courses they have completed.", "isCommentAutoGenerated" : true }, "Courses are loading" : { @@ -74452,6 +74452,10 @@ "comment" : "The title of a section in the user interface.", "isCommentAutoGenerated" : true }, + "Skills, Widget 3 of 3" : { + "comment" : "A label describing a section of a widget, such as \"Skills\" or \"Upcoming Events\".", + "isCommentAutoGenerated" : true + }, "Skillspace" : { "localizations" : { "ar" : { @@ -83681,6 +83685,10 @@ "comment" : "The title of a section in a widget that displays the user's time spent learning.", "isCommentAutoGenerated" : true }, + "Time learning, Widget 2 of 3" : { + "comment" : "A label describing the title and position of a time learning widget.", + "isCommentAutoGenerated" : true + }, "Time spent for all courses is %@" : { "comment" : "A description of the total time spent on all courses. The argument is a description of the time spent on all courses.", "isCommentAutoGenerated" : true diff --git a/Horizon/Horizon/Sources/Features/Dashboard/AnnouncementWidget/Views/AnnouncementWidgetView.swift b/Horizon/Horizon/Sources/Features/Dashboard/AnnouncementWidget/Views/AnnouncementWidgetView.swift index f9776107c8..0a59f0dfe8 100644 --- a/Horizon/Horizon/Sources/Features/Dashboard/AnnouncementWidget/Views/AnnouncementWidgetView.swift +++ b/Horizon/Horizon/Sources/Features/Dashboard/AnnouncementWidget/Views/AnnouncementWidgetView.swift @@ -41,75 +41,75 @@ struct AnnouncementWidgetView: View { } var body: some View { - VStack(alignment: .leading, spacing: .huiSpaces.space4) { - VStack(alignment: .leading, spacing: .huiSpaces.space4) { - headerView - if let courseName = announcement.courseName { - Text(courseName) - .lineLimit(1) - .huiTypography(.p2) - .foregroundStyle(Color.huiColors.text.dataPoint) - .frame(maxWidth: .infinity, alignment: .leading) - .skeletonLoadable() - .accessibilityHidden(true) - } - - Text(announcement.dateFormatted) - .huiTypography(.p3) - .foregroundStyle(Color.huiColors.text.timestamp) - .padding(.bottom, .huiSpaces.space4) - .frame(maxWidth: .infinity, alignment: .leading) - .skeletonLoadable() - .accessibilityHidden(true) - - Text(announcement.title) - .huiTypography(.p1) - .multilineTextAlignment(.leading) - .foregroundStyle(Color.huiColors.text.body) + VStack(alignment: .leading, spacing: .zero) { + headerView + .padding(.bottom, .huiSpaces.space16) + if let courseName = announcement.courseName { + Text(courseName) + .lineLimit(1) + .huiTypography(.p2) + .foregroundStyle(Color.huiColors.text.dataPoint) .frame(maxWidth: .infinity, alignment: .leading) .skeletonLoadable() .accessibilityHidden(true) + .padding(.bottom, .huiSpaces.space4) } - .padding(.huiSpaces.space24) - .background(Color.huiColors.surface.pageSecondary) - .huiCornerRadius(level: .level5) - .huiElevation(level: .level4) - .accessibilityElement(children: .combine) - .accessibilityLabel(Text(combinedAccessibilityLabel)) - .accessibilityFocused(focusedAnnouncementID, equals: announcement.id) + + Text(announcement.dateFormatted) + .huiTypography(.p2) + .foregroundStyle(Color.huiColors.text.timestamp) + .padding(.bottom, .huiSpaces.space4) + .frame(maxWidth: .infinity, alignment: .leading) + .skeletonLoadable() + .accessibilityHidden(true) + .padding(.bottom, .huiSpaces.space8) + + Text(announcement.title) + .lineLimit(3) + .huiTypography(.p1) + .multilineTextAlignment(.leading) + .foregroundStyle(Color.huiColors.text.body) + .frame(maxWidth: .infinity, alignment: .leading) + .skeletonLoadable() + .accessibilityHidden(true) } + .padding(.huiSpaces.space24) + .background(Color.huiColors.surface.pageSecondary) + .huiCornerRadius(level: .level5) + .huiElevation(level: .level4) + .accessibilityElement(children: .combine) + .accessibilityLabel(Text(combinedAccessibilityLabel)) + .accessibilityFocused(focusedAnnouncementID, equals: announcement.id) } private var headerView: some View { - HStack(spacing: .zero) { - HorizonUI.StatusChip( - title: announcement.type.title, - style: announcement.type.style - ) - .padding(.bottom, .huiSpaces.space10) - .padding(.bottom, .huiSpaces.space2) - .skeletonLoadable() - .accessibilityHidden(true) + HStack(spacing: .huiSpaces.space8) { + Image.huiIcons.announcement + .frame(width: 16, height: 16) + .foregroundStyle(Color.huiColors.icon.default) + .padding(.huiSpaces.space8) + .background(Color(hexString: "#E6EDF3")) + .clipShape(.circle) + .skeletonLoadable() + .accessibilityHidden(true) + + Text(announcement.type.title) + .foregroundStyle(Color.huiColors.text.dataPoint) + .huiTypography(.labelMediumBold) + .skeletonLoadable() + .accessibilityHidden(true) + Spacer() if isCounterVisible { - countView + CounterTextView( + currentIndex: currentIndex + 1, + totalCount: totalCount + ) + .accessibilityHidden(true) } } } - private var countView: some View { - Text( - String( - format: String(localized: "%@ of %@"), - (currentIndex + 1).description, - totalCount.description - ) - ) - .huiTypography(.p1) - .foregroundStyle(Color.huiColors.text.dataPoint) - .skeletonLoadable() - } - private var combinedAccessibilityLabel: String { var components: [String] = [] components.append(announcement.type.title) diff --git a/Horizon/Horizon/Sources/Features/Dashboard/AnnouncementWidget/Views/AnnouncementsListWidgetView.swift b/Horizon/Horizon/Sources/Features/Dashboard/AnnouncementWidget/Views/AnnouncementsListWidgetView.swift index 4d524683fd..6ddaadc9fb 100644 --- a/Horizon/Horizon/Sources/Features/Dashboard/AnnouncementWidget/Views/AnnouncementsListWidgetView.swift +++ b/Horizon/Horizon/Sources/Features/Dashboard/AnnouncementWidget/Views/AnnouncementsListWidgetView.swift @@ -100,6 +100,7 @@ struct AnnouncementsListWidgetView: View { ) .containerRelativeFrame(.horizontal) } + .disabled(viewModel.state == .loading) } } diff --git a/Horizon/Horizon/Sources/Features/Dashboard/CommonViews/CounterTextView.swift b/Horizon/Horizon/Sources/Features/Dashboard/CommonViews/CounterTextView.swift new file mode 100644 index 0000000000..53550cfde3 --- /dev/null +++ b/Horizon/Horizon/Sources/Features/Dashboard/CommonViews/CounterTextView.swift @@ -0,0 +1,42 @@ +// +// 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 CounterTextView: View { + let currentIndex: Int + let totalCount: Int + + var body: some View { + Text( + String( + format: String(localized: "%@ of %@"), + currentIndex.description, + totalCount.description + ) + ) + .huiTypography(.p2) + .foregroundStyle(Color.huiColors.text.dataPoint) + .skeletonLoadable() + } +} + +#Preview { + CounterTextView(currentIndex: 1, totalCount: 10) +} diff --git a/Horizon/Horizon/Sources/Features/Dashboard/CompletedWidget/Views/SubViews/CompletedWidgetHeader.swift b/Horizon/Horizon/Sources/Features/Dashboard/CompletedWidget/Views/SubViews/CompletedWidgetHeader.swift index a60e1ce0b3..9285c19fad 100644 --- a/Horizon/Horizon/Sources/Features/Dashboard/CompletedWidget/Views/SubViews/CompletedWidgetHeader.swift +++ b/Horizon/Horizon/Sources/Features/Dashboard/CompletedWidget/Views/SubViews/CompletedWidgetHeader.swift @@ -21,16 +21,7 @@ import SwiftUI struct CompletedWidgetHeader: View { var body: some View { - HStack { - Text("Activities", bundle: .horizon) - .foregroundStyle(Color.huiColors.text.dataPoint) - .huiTypography(.labelMediumBold) - .frame(alignment: .leading) - .skeletonLoadable() - .accessibilityLabel(Text("Courses activities", bundle: .horizon)) - - Spacer() - + HStack(spacing: .huiSpaces.space8) { Image.huiIcons.trendingUp .frame(width: 16, height: 16) .foregroundStyle(Color.huiColors.icon.default) @@ -41,8 +32,31 @@ struct CompletedWidgetHeader: View { } .accessibilityHidden(true) .skeletonLoadable() + + Text("Activities", bundle: .horizon) + .foregroundStyle(Color.huiColors.text.dataPoint) + .huiTypography(.labelMediumBold) + .frame(alignment: .leading) + .skeletonLoadable() + .accessibilityHidden(true) + + Spacer() + + CounterTextView( + currentIndex: 1, + totalCount: 3 + ) + .accessibilityHidden(true) } .accessibilityElement(children: .combine) + .accessibilityLabel( + Text( + String( + localized: "Courses activities, Widget 1 of 3", + bundle: .horizon + ) + ) + ) .accessibilityAddTraits(.isHeader) } } diff --git a/Horizon/Horizon/Sources/Features/Dashboard/CourseListWidget/View/CourseListWidgetView.swift b/Horizon/Horizon/Sources/Features/Dashboard/CourseListWidget/View/CourseListWidgetView.swift index 1f83799026..2d4160029f 100644 --- a/Horizon/Horizon/Sources/Features/Dashboard/CourseListWidget/View/CourseListWidgetView.swift +++ b/Horizon/Horizon/Sources/Features/Dashboard/CourseListWidget/View/CourseListWidgetView.swift @@ -138,8 +138,10 @@ struct CourseListWidgetView: View { private var programCardsView: some View { if viewModel.isProgramWidgetVisible { UnenrolledProgramListWidgetView(programs: viewModel.unenrolledPrograms) { program in - lastFocusedElement.wrappedValue = .programInvitation(id: program.id) - viewModel.navigateProgram(id: program.id, viewController: viewController) + if program.id != "mock-program-id" { + lastFocusedElement.wrappedValue = .programInvitation(id: program.id) + viewModel.navigateProgram(id: program.id, viewController: viewController) + } } } } diff --git a/Horizon/Horizon/Sources/Features/Dashboard/SkillsHighlightsWidget/Views/SubViews/SkillsCountWidgetHeaderView.swift b/Horizon/Horizon/Sources/Features/Dashboard/SkillsHighlightsWidget/Views/SubViews/SkillsCountWidgetHeaderView.swift index 989d838f6a..adc673e950 100644 --- a/Horizon/Horizon/Sources/Features/Dashboard/SkillsHighlightsWidget/Views/SubViews/SkillsCountWidgetHeaderView.swift +++ b/Horizon/Horizon/Sources/Features/Dashboard/SkillsHighlightsWidget/Views/SubViews/SkillsCountWidgetHeaderView.swift @@ -21,15 +21,7 @@ import SwiftUI struct SkillsCountWidgetHeaderView: View { var body: some View { - HStack { - Text("Skills", bundle: .horizon) - .foregroundStyle(Color.huiColors.text.dataPoint) - .huiTypography(.labelMediumBold) - .frame(alignment: .leading) - .skeletonLoadable() - .accessibilityAddTraits(.isHeader) - Spacer() - + HStack(spacing: .huiSpaces.space8) { Image.huiIcons.hub .resizable() .frame(width: 16, height: 16) @@ -41,6 +33,31 @@ struct SkillsCountWidgetHeaderView: View { } .accessibilityHidden(true) .skeletonLoadable() + + Text("Skills", bundle: .horizon) + .foregroundStyle(Color.huiColors.text.dataPoint) + .huiTypography(.labelMediumBold) + .frame(alignment: .leading) + .skeletonLoadable() + .accessibilityHidden(true) + + Spacer() + + CounterTextView( + currentIndex: 3, + totalCount: 3 + ) + .accessibilityHidden(true) } + .accessibilityElement(children: .combine) + .accessibilityLabel( + Text( + String( + localized: "Skills, Widget 3 of 3", + bundle: .horizon + ) + ) + ) + .accessibilityAddTraits(.isHeader) } } diff --git a/Horizon/Horizon/Sources/Features/Dashboard/SkillsHighlightsWidget/Views/SubViews/SkillsHighlightsWidgetHeaderView.swift b/Horizon/Horizon/Sources/Features/Dashboard/SkillsHighlightsWidget/Views/SubViews/SkillsHighlightsWidgetHeaderView.swift index 21e10a8caf..291d3aeb24 100644 --- a/Horizon/Horizon/Sources/Features/Dashboard/SkillsHighlightsWidget/Views/SubViews/SkillsHighlightsWidgetHeaderView.swift +++ b/Horizon/Horizon/Sources/Features/Dashboard/SkillsHighlightsWidget/Views/SubViews/SkillsHighlightsWidgetHeaderView.swift @@ -21,13 +21,7 @@ import SwiftUI struct SkillsHighlightsWidgetHeaderView: View { var body: some View { - HStack { - Text("Skill highlights", bundle: .horizon) - .frame(maxWidth: .infinity, alignment: .leading) - .huiTypography(.labelMediumBold) - .foregroundStyle(Color.huiColors.text.timestamp) - .skeletonLoadable() - Spacer() + HStack(spacing: .huiSpaces.space8) { Image.huiIcons.hub .resizable() .frame(width: 16, height: 16) @@ -39,6 +33,14 @@ struct SkillsHighlightsWidgetHeaderView: View { } .accessibilityHidden(true) .skeletonLoadable() + + Text("Skill highlights", bundle: .horizon) + .frame(maxWidth: .infinity, alignment: .leading) + .huiTypography(.labelMediumBold) + .foregroundStyle(Color.huiColors.text.timestamp) + .skeletonLoadable() + Spacer() + } .accessibilityElement(children: .combine) .accessibilityAddTraits(.isHeader) diff --git a/Horizon/Horizon/Sources/Features/Dashboard/TimeSpentWidget/Views/SubViews/TimeSpentWidgetHeaderView.swift b/Horizon/Horizon/Sources/Features/Dashboard/TimeSpentWidget/Views/SubViews/TimeSpentWidgetHeaderView.swift index 7ecfbdcbd9..18bfcbd78e 100644 --- a/Horizon/Horizon/Sources/Features/Dashboard/TimeSpentWidget/Views/SubViews/TimeSpentWidgetHeaderView.swift +++ b/Horizon/Horizon/Sources/Features/Dashboard/TimeSpentWidget/Views/SubViews/TimeSpentWidgetHeaderView.swift @@ -21,15 +21,7 @@ import SwiftUI struct TimeSpentWidgetHeader: View { var body: some View { - HStack { - Text("Time learning", bundle: .horizon) - .foregroundStyle(Color.huiColors.text.dataPoint) - .huiTypography(.labelMediumBold) - .frame(alignment: .leading) - .skeletonLoadable() - - Spacer() - + HStack(spacing: .huiSpaces.space8) { Image.huiIcons.schedule .resizable() .frame(width: 16, height: 16) @@ -41,8 +33,31 @@ struct TimeSpentWidgetHeader: View { } .accessibilityHidden(true) .skeletonLoadable() + + Text("Time learning", bundle: .horizon) + .foregroundStyle(Color.huiColors.text.dataPoint) + .huiTypography(.labelMediumBold) + .frame(alignment: .leading) + .skeletonLoadable() + .accessibilityHidden(true) + + Spacer() + + CounterTextView( + currentIndex: 2, + totalCount: 3 + ) + .accessibilityHidden(true) } .accessibilityElement(children: .combine) + .accessibilityLabel( + Text( + String( + localized: "Time learning, Widget 2 of 3", + bundle: .horizon + ) + ) + ) .accessibilityAddTraits(.isHeader) } } diff --git a/Horizon/Horizon/Sources/Features/Dashboard/UnenrolledProgramListWidget/Views/UnenrolledProgramListItemWidgetView.swift b/Horizon/Horizon/Sources/Features/Dashboard/UnenrolledProgramListWidget/Views/UnenrolledProgramListItemWidgetView.swift index 2e443525be..18eda5e86d 100644 --- a/Horizon/Horizon/Sources/Features/Dashboard/UnenrolledProgramListWidget/Views/UnenrolledProgramListItemWidgetView.swift +++ b/Horizon/Horizon/Sources/Features/Dashboard/UnenrolledProgramListWidget/Views/UnenrolledProgramListItemWidgetView.swift @@ -42,21 +42,7 @@ struct UnenrolledProgramListItemWidgetView: View { var body: some View { VStack(alignment: .leading, spacing: .huiSpaces.space16) { - HStack(spacing: .zero) { - HorizonUI.StatusChip( - title: String(localized: "Program", bundle: .horizon), - style: .gray - ) - .skeletonLoadable() - .accessibilityHidden(true) - Spacer() - - if isCounterVisible { - countView - .accessibilityHidden(true) - } - } - + headerView Text(descriptionText) .foregroundStyle(Color.huiColors.text.body) .huiTypography(.p1) @@ -74,17 +60,32 @@ struct UnenrolledProgramListItemWidgetView: View { .accessibilityFocused(focusedProgramID, equals: program.id) } - private var countView: some View { - Text( - String( - format: String(localized: "%@ of %@"), - (currentIndex + 1).description, - totalCount.description - ) - ) - .huiTypography(.p1) - .foregroundStyle(Color.huiColors.text.dataPoint) - .skeletonLoadable() + private var headerView: some View { + HStack(spacing: .huiSpaces.space8) { + Image.huiIcons.book2 + .frame(width: 16, height: 16) + .foregroundStyle(Color.huiColors.icon.default) + .padding(.huiSpaces.space8) + .background(Color(hexString: "#E6EDF3")) + .clipShape(.circle) + .skeletonLoadable() + .accessibilityHidden(true) + + Text("Program") + .foregroundStyle(Color.huiColors.text.dataPoint) + .huiTypography(.labelMediumBold) + .skeletonLoadable() + + Spacer() + + if isCounterVisible { + CounterTextView( + currentIndex: currentIndex + 1, + totalCount: totalCount + ) + .accessibilityHidden(true) + } + } } private var descriptionText: String { diff --git a/packages/HorizonUI/Sources/HorizonUI/Resources/Assets.xcassets/Icons/announcement.imageset/Contents.json b/packages/HorizonUI/Sources/HorizonUI/Resources/Assets.xcassets/Icons/announcement.imageset/Contents.json index fdf1b9196f..3af3e63285 100644 --- a/packages/HorizonUI/Sources/HorizonUI/Resources/Assets.xcassets/Icons/announcement.imageset/Contents.json +++ b/packages/HorizonUI/Sources/HorizonUI/Resources/Assets.xcassets/Icons/announcement.imageset/Contents.json @@ -2,20 +2,14 @@ "images" : [ { "filename" : "Vector.svg", - "idiom" : "universal", - "scale" : "1x" - }, - { - "idiom" : "universal", - "scale" : "2x" - }, - { - "idiom" : "universal", - "scale" : "3x" + "idiom" : "universal" } ], "info" : { "author" : "xcode", "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" } } From 844f085d2fbe38147853816c16e0b70d9358e6cc Mon Sep 17 00:00:00 2001 From: Ahmed Naguib Date: Fri, 7 Nov 2025 18:48:31 +0200 Subject: [PATCH 02/11] feat: update courses cards at dashboard refs: CLX-3259 builds: Student affects: Student release note: none test plan: none --- .../Horizon/Resources/Localizable.xcstrings | 43 +++++ .../CourseList/CourseListAssembly.swift | 37 ++++ .../CourseList/Data/CourseCardModel.swift | 72 ++++++++ .../CourseList/Views/CourseListView.swift | 160 ++++++++++++++++++ .../Views/CourseListViewModel.swift | 104 ++++++++++++ .../Views/SubViews/CourseCardView.swift | 147 ++++++++++++++++ .../Views/SubViews/CourseFilteringView.swift} | 41 ++--- .../Views/SubViews/CourseListEmptyView.swift | 38 +++++ .../SubViews/CourseSelectionButton.swift | 46 +++++ .../CourseListWidgetSeeAllCoursesView.swift | 64 +++++++ .../View/CourseListWidgetView.swift | 25 ++- .../View/CourseListWidgetViewModel.swift | 17 +- .../View/ProgramNameListChipView.swift | 78 +++++++++ .../View/ProgramNameListView.swift | 13 +- .../View/SeeAllCoursesButton.swift | 49 ++++++ .../StatusChip/HorizonUI.StatusChip.swift | 9 +- 16 files changed, 900 insertions(+), 43 deletions(-) create mode 100644 Horizon/Horizon/Sources/Features/Dashboard/CourseList/CourseListAssembly.swift create mode 100644 Horizon/Horizon/Sources/Features/Dashboard/CourseList/Data/CourseCardModel.swift create mode 100644 Horizon/Horizon/Sources/Features/Dashboard/CourseList/Views/CourseListView.swift create mode 100644 Horizon/Horizon/Sources/Features/Dashboard/CourseList/Views/CourseListViewModel.swift create mode 100644 Horizon/Horizon/Sources/Features/Dashboard/CourseList/Views/SubViews/CourseCardView.swift rename Horizon/Horizon/Sources/Features/Dashboard/{TimeSpentWidget/Views/SubViews/TimeSpentWidgetListCursesView.swift => CourseList/Views/SubViews/CourseFilteringView.swift} (51%) create mode 100644 Horizon/Horizon/Sources/Features/Dashboard/CourseList/Views/SubViews/CourseListEmptyView.swift create mode 100644 Horizon/Horizon/Sources/Features/Dashboard/CourseList/Views/SubViews/CourseSelectionButton.swift create mode 100644 Horizon/Horizon/Sources/Features/Dashboard/CourseListWidget/View/CourseListWidgetSeeAllCoursesView.swift create mode 100644 Horizon/Horizon/Sources/Features/Dashboard/CourseListWidget/View/ProgramNameListChipView.swift create mode 100644 Horizon/Horizon/Sources/Features/Dashboard/CourseListWidget/View/SeeAllCoursesButton.swift diff --git a/Horizon/Horizon/Resources/Localizable.xcstrings b/Horizon/Horizon/Resources/Localizable.xcstrings index 2ac0876231..26bd9dd74b 100644 --- a/Horizon/Horizon/Resources/Localizable.xcstrings +++ b/Horizon/Horizon/Resources/Localizable.xcstrings @@ -4633,6 +4633,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 +5404,9 @@ } } } + }, + "All courses" : { + }, "All messages" : { "localizations" : { @@ -26958,6 +26965,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,6 +27225,10 @@ } } }, + "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" : { }, @@ -40559,6 +40574,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" : { @@ -57750,6 +57769,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 +58541,10 @@ } } }, + "Nothing here yet" : { + "comment" : "A message displayed when there are no courses to display.", + "isCommentAutoGenerated" : true + }, "Notifications" : { "localizations" : { "ar" : { @@ -62374,6 +62401,10 @@ } } }, + "Part of :" : { + "comment" : "Label for the status chip in the Program Name List Chip View.", + "isCommentAutoGenerated" : true + }, "Part of %@. " : { }, @@ -72392,6 +72423,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" : { @@ -94000,6 +94039,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..aa5364251b --- /dev/null +++ b/Horizon/Horizon/Sources/Features/Dashboard/CourseList/Data/CourseCardModel.swift @@ -0,0 +1,72 @@ +// +// 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 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 + } + + 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..65da5af8fc --- /dev/null +++ b/Horizon/Horizon/Sources/Features/Dashboard/CourseList/Views/CourseListView.swift @@ -0,0 +1,160 @@ +// +// 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 { + @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 + 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) + } + + 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 { + viewModel.navigateToCourseDetails(course: course, viewController: viewController) + } label: { + CourseCardView(course: course) { program in + viewModel.navigateProgram(id: program.id, viewController: viewController) + } + } + } + } + } + + private var courseFilterView: some View { + HStack(spacing: .zero) { + CourseFilteringView(selectedStatus: selectedStatus) { status in + viewModel.filter(status: status ?? .all) + } + .frame(maxWidth: 200) + .fixedSize(horizontal: true, vertical: false) + + Spacer() + Text(viewModel.filteredCourses.count.description) + .foregroundStyle(Color.huiColors.text.dataPoint) + .huiTypography(.p1) + } + .padding(.horizontal, .huiSpaces.space16) + .padding(.bottom, .huiSpaces.space16) + } + + 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) + } + } + + 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) + } +} + +#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..f7b2166279 --- /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() { + guard currentPage + 1 < totalPages else { return } + currentPage += 1 + 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..853271a7e5 --- /dev/null +++ b/Horizon/Horizon/Sources/Features/Dashboard/CourseList/Views/SubViews/CourseCardView.swift @@ -0,0 +1,147 @@ +// +// 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) + } + + 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/TimeSpentWidget/Views/SubViews/TimeSpentWidgetListCursesView.swift b/Horizon/Horizon/Sources/Features/Dashboard/CourseList/Views/SubViews/CourseFilteringView.swift similarity index 51% rename from Horizon/Horizon/Sources/Features/Dashboard/TimeSpentWidget/Views/SubViews/TimeSpentWidgetListCursesView.swift rename to Horizon/Horizon/Sources/Features/Dashboard/CourseList/Views/SubViews/CourseFilteringView.swift index 129cd7f251..526f0d291b 100644 --- a/Horizon/Horizon/Sources/Features/Dashboard/TimeSpentWidget/Views/SubViews/TimeSpentWidgetListCursesView.swift +++ b/Horizon/Horizon/Sources/Features/Dashboard/CourseList/Views/SubViews/CourseFilteringView.swift @@ -19,22 +19,18 @@ import HorizonUI import SwiftUI -struct TimeSpentWidgetListCursesView: View { - @State private var isCourseListVisible = false - let courses: [TimeSpentWidgetModel] - @State var selectedCourse: TimeSpentWidgetModel? - let onSelect: (TimeSpentWidgetModel?) -> Void +struct CourseFilteringView: View { + @State var selectedStatus: CourseCardModel.CourseStatus? + @State private var isListCoursesVisiable = false + let onSelect: (CourseCardModel.CourseStatus?) -> Void var body: some View { - TimeSpentWidgetCourseButton( - courseName: selectedCourse?.courseName ?? "", - isSelected: selectedCourse != nil - ) { - isCourseListVisible.toggle() + CourseSelectionButton(status: selectedStatus?.name ?? "") { + isListCoursesVisiable.toggle() } - .accessibilityLabel(Text(selectedCourse?.titleAccessibilityButtonLabel ?? "")) + .frame(minWidth: 130) .accessibilityHint(Text("Double tab to select a different course", bundle: .horizon)) - .popover(isPresented: $isCourseListVisible, attachmentAnchor: .point(.center), arrowEdge: .top) { + .popover(isPresented: $isListCoursesVisiable, attachmentAnchor: .point(.center), arrowEdge: .top) { courseListView .presentationCompactAdaptation(.none) .presentationBackground(Color.huiColors.surface.cardPrimary) @@ -44,28 +40,19 @@ struct TimeSpentWidgetListCursesView: View { private var courseListView: some View { ScrollView { VStack(spacing: .zero) { - ForEach(courses) { course in + ForEach(CourseCardModel.CourseStatus.allCases, id: \.self) { status in Button { - selectedCourse = course - onSelect(course) - isCourseListVisible.toggle() + selectedStatus = status + onSelect(status) + isListCoursesVisiable.toggle() } label: { TimeSpentCourseView( - name: course.courseName, - isSelected: course == selectedCourse + name: status.name, + isSelected: status == selectedStatus ) } - .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/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/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..1992810cd4 100644 --- a/Horizon/Horizon/Sources/Features/Dashboard/CourseListWidget/View/CourseListWidgetView.swift +++ b/Horizon/Horizon/Sources/Features/Dashboard/CourseListWidget/View/CourseListWidgetView.swift @@ -40,6 +40,7 @@ struct CourseListWidgetView: View { case .data, .loading: programCardsView dataView + seeAllCourseButton case .empty: emptyView case .error: @@ -78,10 +79,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 +120,13 @@ struct CourseListWidgetView: View { } .id(index) } + + if viewModel.isExceededMaxCourses { + CourseListWidgetSeeAllCoursesView(count: viewModel.courses.count) { + viewModel.navigateToListCourse(viewController: viewController) + } + .frame(width: size - 48) + } } .scrollTargetLayout() .padding(.horizontal, .huiSpaces.space24) @@ -127,10 +137,6 @@ struct CourseListWidgetView: View { .scrollPosition(id: $currentCourseIndex) .id(scrollViewID) } - - if viewModel.courses.count >= 4 { - PaginationIndicatorView(currentIndex: $currentCourseIndex, count: viewModel.courses.count) - } } } @@ -157,4 +163,13 @@ struct CourseListWidgetView: View { CourseListWidgetEmptyView() .padding(.horizontal, .huiSpaces.space24) } + + @ViewBuilder + private var seeAllCourseButton: some View { + if viewModel.isExceededMaxCourses { + SeeAllCoursesButton { + viewModel.navigateToListCourse(viewController: viewController) + } + } + } } diff --git a/Horizon/Horizon/Sources/Features/Dashboard/CourseListWidget/View/CourseListWidgetViewModel.swift b/Horizon/Horizon/Sources/Features/Dashboard/CourseListWidget/View/CourseListWidgetViewModel.swift index e33510de60..e3b5fda33c 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 @@ -108,7 +115,7 @@ class CourseListWidgetViewModel { let filteredPrograms = programs.filter { !$0.hasEnrolledCourse } self?.courses = attachedCourses - self?.unenrolledPrograms = filteredPrograms + self?.unenrolledPrograms = Self.programsMock //filteredPrograms let invitedCourses = items.filter { $0.state == HCourse.EnrollmentState.invited.rawValue } self?.acceptInvitation(courses: invitedCourses) @@ -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..b33c1b7305 --- /dev/null +++ b/Horizon/Horizon/Sources/Features/Dashboard/CourseListWidget/View/ProgramNameListChipView.swift @@ -0,0 +1,78 @@ +// +// 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 + ) + } + } + } + .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..6539f4c77c 100644 --- a/Horizon/Horizon/Sources/Features/Dashboard/CourseListWidget/View/ProgramNameListView.swift +++ b/Horizon/Horizon/Sources/Features/Dashboard/CourseListWidget/View/ProgramNameListView.swift @@ -24,7 +24,7 @@ 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) @@ -35,13 +35,10 @@ struct ProgramNameListView: View { } 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) - } + .huiTypography(.labelSmallBold) + .underline(true, color: Color.huiColors.text.body) + .baselineOffset(2) + .multilineTextAlignment(.leading) } if program.id != programs.last?.id { Text(verbatim: ",") 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..aba15c2d97 --- /dev/null +++ b/Horizon/Horizon/Sources/Features/Dashboard/CourseListWidget/View/SeeAllCoursesButton.swift @@ -0,0 +1,49 @@ +// +// 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 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/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) From ef543faf04a80f1ea6de50b768047957d95dbb42 Mon Sep 17 00:00:00 2001 From: Ahmed Naguib Date: Fri, 7 Nov 2025 18:49:38 +0200 Subject: [PATCH 03/11] update course card view --- .../View/CourseListWidgetItemView.swift | 90 ++++++++++--------- 1 file changed, 50 insertions(+), 40 deletions(-) diff --git a/Horizon/Horizon/Sources/Features/Dashboard/CourseListWidget/View/CourseListWidgetItemView.swift b/Horizon/Horizon/Sources/Features/Dashboard/CourseListWidget/View/CourseListWidgetItemView.swift index 24a7b0acb2..ca1d73d69e 100644 --- a/Horizon/Horizon/Sources/Features/Dashboard/CourseListWidget/View/CourseListWidgetItemView.swift +++ b/Horizon/Horizon/Sources/Features/Dashboard/CourseListWidget/View/CourseListWidgetItemView.swift @@ -22,6 +22,8 @@ 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)? @@ -127,10 +129,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 +142,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 +183,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 +200,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,40 +225,38 @@ 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) - } - - if let dueDate = learningObject.dueDate { + VStack(alignment: .leading, spacing: .huiSpaces.space8) { + HStack(spacing: .huiSpaces.space8) { HorizonUI.StatusChip( - title: dueDate, + title: learningObject.dueDate ?? "", style: .white, icon: .huiIcons.calendarToday, isFilled: true ) .skeletonLoadable() .accessibilityHidden(true) - } + .hidden(learningObject.dueDate == nil) - if let duration = learningObject.estimatedDuration { HorizonUI.StatusChip( - title: duration, + title: learningObject.estimatedDuration ?? "", style: .white, icon: .huiIcons.schedule, isFilled: true ) .skeletonLoadable() .accessibilityHidden(true) + .hidden(learningObject.estimatedDuration == nil) } - Spacer() + + 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)) } } } @@ -286,7 +281,10 @@ struct CourseListWidgetItemView: View { estimatedDuration: "xxxxx", url: nil ) - ), width: 300, + ), + width: 300, + currentIndex: 1, + totalCount: 10, onCourseTap: { _ in }, onProgramTap: { _ in }, onLearningObjectTap: { _, _ in } @@ -310,7 +308,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 +338,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 +363,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 +381,10 @@ struct CourseListWidgetItemView: View { lastActivityAt: nil, programs: [], currentLearningObject: nil - ), width: 300, + ), + width: 300, + currentIndex: 3, + totalCount: 10, onCourseTap: { _ in }, onProgramTap: { _ in }, onLearningObjectTap: { _, _ in } From d1dfaf38ea37dcd340d944fb97f4bbb9743543dd Mon Sep 17 00:00:00 2001 From: Ahmed Naguib Date: Sun, 9 Nov 2025 19:09:20 +0200 Subject: [PATCH 04/11] feat: add course list view refs: CLX-3259 builds: Student affects: Student release note: none test plan: none --- .../Horizon/Resources/Localizable.xcstrings | 39 ++++++++- .../CourseList/Data/CourseCardModel.swift | 31 +++++++ .../CourseList/Views/CourseListView.swift | 51 ++++++++++++ .../Views/SubViews/CourseCardView.swift | 2 + .../Views/SubViews/CourseFilteringView.swift | 10 ++- .../View/CourseListWidgetItemView.swift | 82 +++++++++++++------ .../View/CourseListWidgetView.swift | 9 ++ .../View/CourseListWidgetViewModel.swift | 2 +- .../View/ProgramNameListChipView.swift | 2 + .../View/ProgramNameListView.swift | 24 +++--- .../View/SeeAllCoursesButton.swift | 1 + .../Views/SubViews/TimeSpentCourseView.swift | 2 +- 12 files changed, 210 insertions(+), 45 deletions(-) diff --git a/Horizon/Horizon/Resources/Localizable.xcstrings b/Horizon/Horizon/Resources/Localizable.xcstrings index 26bd9dd74b..4cef5f321b 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" : { @@ -24893,6 +24904,10 @@ } } }, + "Count of visible courses is @%" : { + "comment" : "A label describing the number of courses that are currently visible in the list. The number is dynamically updated as the user filters the list.", + "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 @@ -27230,10 +27245,12 @@ "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.", @@ -56745,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" : { @@ -60085,7 +60106,8 @@ }, "Open course" : { - + "comment" : "A button that opens a course.", + "isCommentAutoGenerated" : true }, "Open in a New Tab" : { "localizations" : { @@ -60344,7 +60366,8 @@ } }, "Open learning object" : { - + "comment" : "A button that opens a learning object.", + "isCommentAutoGenerated" : true }, "Optional" : { "localizations" : { @@ -62407,6 +62430,10 @@ }, "Part of %@. " : { + }, + "Part of program: %@" : { + "comment" : "A label for a chip that lists a program.", + "isCommentAutoGenerated" : true }, "Perth (+08:00)" : { "localizations" : { @@ -72943,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" : { diff --git a/Horizon/Horizon/Sources/Features/Dashboard/CourseList/Data/CourseCardModel.swift b/Horizon/Horizon/Sources/Features/Dashboard/CourseList/Data/CourseCardModel.swift index aa5364251b..2d24fbc57e 100644 --- a/Horizon/Horizon/Sources/Features/Dashboard/CourseList/Data/CourseCardModel.swift +++ b/Horizon/Horizon/Sources/Features/Dashboard/CourseList/Data/CourseCardModel.swift @@ -16,6 +16,7 @@ // along with this program. If not, see . // +import Core import Foundation struct CourseCardModel: Identifiable, Equatable { @@ -43,6 +44,36 @@ struct CourseCardModel: Identifiable, Equatable { 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 diff --git a/Horizon/Horizon/Sources/Features/Dashboard/CourseList/Views/CourseListView.swift b/Horizon/Horizon/Sources/Features/Dashboard/CourseList/Views/CourseListView.swift index 65da5af8fc..a0a50483c0 100644 --- a/Horizon/Horizon/Sources/Features/Dashboard/CourseList/Views/CourseListView.swift +++ b/Horizon/Horizon/Sources/Features/Dashboard/CourseList/Views/CourseListView.swift @@ -21,11 +21,22 @@ import HorizonUI import SwiftUI struct CourseListView: View { + // MARK: - VO + + @State private var lastFocusedCcourseID: 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 { @@ -50,6 +61,8 @@ struct CourseListView: View { .safeAreaInset(edge: .top, spacing: .zero) { headerView } .animation(.linear, value: isShowHeader) .animation(.easeInOut, value: viewModel.filteredCourses.count) + .onAppear { restoreFocusIfNeeded() } + .onChange(of: lastFocusedCcourseID) { _, _ in restoreFocusIfNeeded() } } private var headerView: some View { @@ -74,12 +87,30 @@ struct CourseListView: View { VStack(spacing: .huiSpaces.space16) { ForEach(viewModel.filteredCourses) { course in Button { + lastFocusedCcourseID = course.id viewModel.navigateToCourseDetails(course: course, viewController: viewController) } label: { CourseCardView(course: course) { program in + lastFocusedCcourseID = 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) } } } @@ -88,6 +119,7 @@ struct CourseListView: View { HStack(spacing: .zero) { CourseFilteringView(selectedStatus: selectedStatus) { status in viewModel.filter(status: status ?? .all) + lastFocusedCcourseID = selectFilterFocusedID } .frame(maxWidth: 200) .fixedSize(horizontal: true, vertical: false) @@ -96,9 +128,20 @@ struct CourseListView: View { 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 { @@ -119,6 +162,7 @@ struct CourseListView: View { .frame(maxWidth: .infinity) .huiTypography(.h3) .foregroundStyle(Color.huiColors.text.title) + .accessibilityAddTraits(.isHeader) } } @@ -135,6 +179,13 @@ struct CourseListView: View { .accessibilityHint( String(localized: "Double tap to load more courses")) .padding(.top, .huiSpaces.space16) } + + private func restoreFocusIfNeeded() { + guard let lastFocused = lastFocusedCcourseID else { return } + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + focusedCourseID = lastFocused + } + } } #Preview { diff --git a/Horizon/Horizon/Sources/Features/Dashboard/CourseList/Views/SubViews/CourseCardView.swift b/Horizon/Horizon/Sources/Features/Dashboard/CourseList/Views/SubViews/CourseCardView.swift index 853271a7e5..77f55acfd8 100644 --- a/Horizon/Horizon/Sources/Features/Dashboard/CourseList/Views/SubViews/CourseCardView.swift +++ b/Horizon/Horizon/Sources/Features/Dashboard/CourseList/Views/SubViews/CourseCardView.swift @@ -39,6 +39,8 @@ struct CourseCardView: View { .background(Color.huiColors.surface.pageSecondary) .huiCornerRadius(level: .level3_5) .huiElevation(level: .level4) + .accessibilityElement(children: .combine) + .accessibilityLabel(course.accessibilityDescription) } private var programLinkSection: some View { diff --git a/Horizon/Horizon/Sources/Features/Dashboard/CourseList/Views/SubViews/CourseFilteringView.swift b/Horizon/Horizon/Sources/Features/Dashboard/CourseList/Views/SubViews/CourseFilteringView.swift index 526f0d291b..b152d68b4a 100644 --- a/Horizon/Horizon/Sources/Features/Dashboard/CourseList/Views/SubViews/CourseFilteringView.swift +++ b/Horizon/Horizon/Sources/Features/Dashboard/CourseList/Views/SubViews/CourseFilteringView.swift @@ -29,12 +29,20 @@ struct CourseFilteringView: View { isListCoursesVisiable.toggle() } .frame(minWidth: 130) - .accessibilityHint(Text("Double tab to select a different course", bundle: .horizon)) + .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 { diff --git a/Horizon/Horizon/Sources/Features/Dashboard/CourseListWidget/View/CourseListWidgetItemView.swift b/Horizon/Horizon/Sources/Features/Dashboard/CourseListWidget/View/CourseListWidgetItemView.swift index ca1d73d69e..7d270d9861 100644 --- a/Horizon/Horizon/Sources/Features/Dashboard/CourseListWidget/View/CourseListWidgetItemView.swift +++ b/Horizon/Horizon/Sources/Features/Dashboard/CourseListWidget/View/CourseListWidgetItemView.swift @@ -27,30 +27,32 @@ struct CourseListWidgetItemView: View { 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) @@ -227,25 +229,25 @@ struct CourseListWidgetItemView: View { private func learningObjectMetadata(for learningObject: CourseListWidgetModel.LearningObjectInfo) -> some View { VStack(alignment: .leading, spacing: .huiSpaces.space8) { HStack(spacing: .huiSpaces.space8) { - HorizonUI.StatusChip( - title: learningObject.dueDate ?? "", - style: .white, - icon: .huiIcons.calendarToday, - isFilled: true - ) - .skeletonLoadable() - .accessibilityHidden(true) - .hidden(learningObject.dueDate == nil) + if let estimatedDuration = learningObject.estimatedDuration { + HorizonUI.StatusChip( + title: estimatedDuration, + style: .white, + icon: .huiIcons.schedule, + isFilled: true + ) + .skeletonLoadable() + .accessibilityHidden(true) + } HorizonUI.StatusChip( - title: learningObject.estimatedDuration ?? "", + title: learningObject.dueDate ?? String(localized: "No due date"), style: .white, - icon: .huiIcons.schedule, + icon: .huiIcons.calendarToday, isFilled: true ) .skeletonLoadable() .accessibilityHidden(true) - .hidden(learningObject.estimatedDuration == nil) } HorizonUI.StatusChip( @@ -259,6 +261,32 @@ struct CourseListWidgetItemView: View { .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) + } + } + + @ViewBuilder + private var counterView: some View { + if totalCount > 1 { + CounterTextView( + currentIndex: currentIndex + 1, + totalCount: totalCount + ) + } + } } #if DEBUG diff --git a/Horizon/Horizon/Sources/Features/Dashboard/CourseListWidget/View/CourseListWidgetView.swift b/Horizon/Horizon/Sources/Features/Dashboard/CourseListWidget/View/CourseListWidgetView.swift index 1992810cd4..6989277da4 100644 --- a/Horizon/Horizon/Sources/Features/Dashboard/CourseListWidget/View/CourseListWidgetView.swift +++ b/Horizon/Horizon/Sources/Features/Dashboard/CourseListWidget/View/CourseListWidgetView.swift @@ -26,9 +26,12 @@ struct CourseListWidgetView: View { @Environment(\.dashboardLastFocusedElement) private var lastFocusedElement @Environment(\.dashboardRestoreFocusTrigger) private var restoreFocusTrigger @AccessibilityFocusState private var focusedCourseID: String? + @AccessibilityFocusState private var focusedSeeAllCourses: Bool? @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) @@ -123,9 +126,12 @@ struct CourseListWidgetView: View { 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() @@ -168,8 +174,11 @@ struct CourseListWidgetView: View { 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 e3b5fda33c..6e15bc8517 100644 --- a/Horizon/Horizon/Sources/Features/Dashboard/CourseListWidget/View/CourseListWidgetViewModel.swift +++ b/Horizon/Horizon/Sources/Features/Dashboard/CourseListWidget/View/CourseListWidgetViewModel.swift @@ -115,7 +115,7 @@ class CourseListWidgetViewModel { let filteredPrograms = programs.filter { !$0.hasEnrolledCourse } self?.courses = attachedCourses - self?.unenrolledPrograms = Self.programsMock //filteredPrograms + self?.unenrolledPrograms = filteredPrograms let invitedCourses = items.filter { $0.state == HCourse.EnrollmentState.invited.rawValue } self?.acceptInvitation(courses: invitedCourses) diff --git a/Horizon/Horizon/Sources/Features/Dashboard/CourseListWidget/View/ProgramNameListChipView.swift b/Horizon/Horizon/Sources/Features/Dashboard/CourseListWidget/View/ProgramNameListChipView.swift index b33c1b7305..13a10e9075 100644 --- a/Horizon/Horizon/Sources/Features/Dashboard/CourseListWidget/View/ProgramNameListChipView.swift +++ b/Horizon/Horizon/Sources/Features/Dashboard/CourseListWidget/View/ProgramNameListChipView.swift @@ -35,6 +35,8 @@ struct ProgramNameListChipView: View { hasBorder: true ) } + .accessibilityElement(children: .ignore) + .accessibilityLabel(String(localized: "Part of program: \(program.name)")) } } .frame(maxWidth: .infinity, alignment: .leading) diff --git a/Horizon/Horizon/Sources/Features/Dashboard/CourseListWidget/View/ProgramNameListView.swift b/Horizon/Horizon/Sources/Features/Dashboard/CourseListWidget/View/ProgramNameListView.swift index 6539f4c77c..d947b96483 100644 --- a/Horizon/Horizon/Sources/Features/Dashboard/CourseListWidget/View/ProgramNameListView.swift +++ b/Horizon/Horizon/Sources/Features/Dashboard/CourseListWidget/View/ProgramNameListView.swift @@ -30,20 +30,22 @@ struct ProgramNameListView: View { .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(.labelSmallBold) - .underline(true, color: Color.huiColors.text.body) - .baselineOffset(2) - .multilineTextAlignment(.leading) - } - 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 index aba15c2d97..f100b513e2 100644 --- a/Horizon/Horizon/Sources/Features/Dashboard/CourseListWidget/View/SeeAllCoursesButton.swift +++ b/Horizon/Horizon/Sources/Features/Dashboard/CourseListWidget/View/SeeAllCoursesButton.swift @@ -16,6 +16,7 @@ // along with this program. If not, see . // +import HorizonUI import SwiftUI struct SeeAllCoursesButton: View { 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 From 57b06cf87407db59ebbc48f91364c876287d9ccf Mon Sep 17 00:00:00 2001 From: Ahmed Naguib Date: Sun, 9 Nov 2025 19:44:09 +0200 Subject: [PATCH 05/11] Add test cases --- .../ViewModels/CourseListViewTests.swift | 166 ++++++++++++++++++ .../CourseListWidgetViewModelTests.swift | 68 +++++++ 2 files changed, 234 insertions(+) create mode 100644 Horizon/HorizonUnitTests/Features/Dashboard/ViewModels/CourseListViewTests.swift 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..209bbfd4fe 100644 --- a/Horizon/HorizonUnitTests/Features/Dashboard/ViewModels/CourseListWidgetViewModelTests.swift +++ b/Horizon/HorizonUnitTests/Features/Dashboard/ViewModels/CourseListWidgetViewModelTests.swift @@ -293,6 +293,74 @@ 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 { From cfc31bad1aa9c274dd91706e830c97aef52f3b70 Mon Sep 17 00:00:00 2001 From: Ahmed Naguib Date: Sun, 9 Nov 2025 20:44:36 +0200 Subject: [PATCH 06/11] fix: tabBar --- Horizon/Horizon/Sources/Features/HorizonTabBarController.swift | 2 +- .../Dashboard/ViewModels/CourseListWidgetViewModelTests.swift | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) 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/CourseListWidgetViewModelTests.swift b/Horizon/HorizonUnitTests/Features/Dashboard/ViewModels/CourseListWidgetViewModelTests.swift index 209bbfd4fe..7a7784a5d9 100644 --- a/Horizon/HorizonUnitTests/Features/Dashboard/ViewModels/CourseListWidgetViewModelTests.swift +++ b/Horizon/HorizonUnitTests/Features/Dashboard/ViewModels/CourseListWidgetViewModelTests.swift @@ -359,8 +359,6 @@ final class CourseListWidgetViewModelTests: HorizonTestCase { let presentedViewController = router.lastViewController as? CoreHostingController XCTAssertNotNil(presentedViewController) } - - // MARK: - Helper Methods private func createVM() -> CourseListWidgetViewModel { From b3d67509c2bbbf379ff2f7a82a9439f2e43eaf08 Mon Sep 17 00:00:00 2001 From: Ahmed Naguib Date: Sun, 9 Nov 2025 20:56:10 +0200 Subject: [PATCH 07/11] remove unneeded parameter --- .../Dashboard/CourseListWidget/View/CourseListWidgetView.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Horizon/Horizon/Sources/Features/Dashboard/CourseListWidget/View/CourseListWidgetView.swift b/Horizon/Horizon/Sources/Features/Dashboard/CourseListWidget/View/CourseListWidgetView.swift index 6989277da4..54d867cb89 100644 --- a/Horizon/Horizon/Sources/Features/Dashboard/CourseListWidget/View/CourseListWidgetView.swift +++ b/Horizon/Horizon/Sources/Features/Dashboard/CourseListWidget/View/CourseListWidgetView.swift @@ -26,7 +26,6 @@ struct CourseListWidgetView: View { @Environment(\.dashboardLastFocusedElement) private var lastFocusedElement @Environment(\.dashboardRestoreFocusTrigger) private var restoreFocusTrigger @AccessibilityFocusState private var focusedCourseID: String? - @AccessibilityFocusState private var focusedSeeAllCourses: Bool? @State private var currentCourseIndex: Int? = 0 @State private var bounceScale: CGFloat = 1.0 @State private var scrollViewID = UUID() From b86eda19f46b312443c68d3b4252689c386472f9 Mon Sep 17 00:00:00 2001 From: Ahmed Naguib Date: Sun, 9 Nov 2025 21:14:26 +0200 Subject: [PATCH 08/11] fix: code review --- .../Dashboard/CourseList/Views/CourseListView.swift | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/Horizon/Horizon/Sources/Features/Dashboard/CourseList/Views/CourseListView.swift b/Horizon/Horizon/Sources/Features/Dashboard/CourseList/Views/CourseListView.swift index a0a50483c0..56b8ce0002 100644 --- a/Horizon/Horizon/Sources/Features/Dashboard/CourseList/Views/CourseListView.swift +++ b/Horizon/Horizon/Sources/Features/Dashboard/CourseList/Views/CourseListView.swift @@ -61,8 +61,7 @@ struct CourseListView: View { .safeAreaInset(edge: .top, spacing: .zero) { headerView } .animation(.linear, value: isShowHeader) .animation(.easeInOut, value: viewModel.filteredCourses.count) - .onAppear { restoreFocusIfNeeded() } - .onChange(of: lastFocusedCcourseID) { _, _ in restoreFocusIfNeeded() } + .onAppear { restoreFocusIfNeeded(after: 0.1) } } private var headerView: some View { @@ -120,6 +119,8 @@ struct CourseListView: View { CourseFilteringView(selectedStatus: selectedStatus) { status in viewModel.filter(status: status ?? .all) lastFocusedCcourseID = selectFilterFocusedID + restoreFocusIfNeeded(after: 1) + selectedStatus = status ?? .all } .frame(maxWidth: 200) .fixedSize(horizontal: true, vertical: false) @@ -180,9 +181,9 @@ struct CourseListView: View { .padding(.top, .huiSpaces.space16) } - private func restoreFocusIfNeeded() { + private func restoreFocusIfNeeded(after: Double) { guard let lastFocused = lastFocusedCcourseID else { return } - DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + DispatchQueue.main.asyncAfter(deadline: .now() + after) { focusedCourseID = lastFocused } } From 7200f5592d38dd36f0eb5111969f65d42dad291f Mon Sep 17 00:00:00 2001 From: Ahmed Naguib Date: Sun, 9 Nov 2025 21:34:46 +0200 Subject: [PATCH 09/11] fix: typos --- Horizon/Horizon/Resources/Localizable.xcstrings | 4 ++-- .../Dashboard/CourseList/Views/CourseListView.swift | 12 ++++++------ .../CourseList/Views/CourseListViewModel.swift | 2 +- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/Horizon/Horizon/Resources/Localizable.xcstrings b/Horizon/Horizon/Resources/Localizable.xcstrings index 4cef5f321b..5c617eece0 100644 --- a/Horizon/Horizon/Resources/Localizable.xcstrings +++ b/Horizon/Horizon/Resources/Localizable.xcstrings @@ -24904,8 +24904,8 @@ } } }, - "Count of visible courses is @%" : { - "comment" : "A label describing the number of courses that are currently visible in the list. The number is dynamically updated as the user filters the list.", + "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 %@" : { diff --git a/Horizon/Horizon/Sources/Features/Dashboard/CourseList/Views/CourseListView.swift b/Horizon/Horizon/Sources/Features/Dashboard/CourseList/Views/CourseListView.swift index 56b8ce0002..2d63b5f1ce 100644 --- a/Horizon/Horizon/Sources/Features/Dashboard/CourseList/Views/CourseListView.swift +++ b/Horizon/Horizon/Sources/Features/Dashboard/CourseList/Views/CourseListView.swift @@ -23,7 +23,7 @@ import SwiftUI struct CourseListView: View { // MARK: - VO - @State private var lastFocusedCcourseID: String? + @State private var lastFocusedCourseID: String? @AccessibilityFocusState private var focusedCourseID: String? private let selectFilterFocusedID = "selectFilterFocusedID" @@ -86,11 +86,11 @@ struct CourseListView: View { VStack(spacing: .huiSpaces.space16) { ForEach(viewModel.filteredCourses) { course in Button { - lastFocusedCcourseID = course.id + lastFocusedCourseID = course.id viewModel.navigateToCourseDetails(course: course, viewController: viewController) } label: { CourseCardView(course: course) { program in - lastFocusedCcourseID = course.id + lastFocusedCourseID = course.id viewModel.navigateProgram(id: program.id, viewController: viewController) } .accessibilityActions { @@ -118,7 +118,7 @@ struct CourseListView: View { HStack(spacing: .zero) { CourseFilteringView(selectedStatus: selectedStatus) { status in viewModel.filter(status: status ?? .all) - lastFocusedCcourseID = selectFilterFocusedID + lastFocusedCourseID = selectFilterFocusedID restoreFocusIfNeeded(after: 1) selectedStatus = status ?? .all } @@ -132,7 +132,7 @@ struct CourseListView: View { .accessibilityLabel( Text( String( - format: String(localized: "Count of visible courses is @%"), + format: String(localized: "Count of visible courses is %@"), viewModel.filteredCourses.count.description ) ) @@ -182,7 +182,7 @@ struct CourseListView: View { } private func restoreFocusIfNeeded(after: Double) { - guard let lastFocused = lastFocusedCcourseID else { return } + guard let lastFocused = lastFocusedCourseID else { return } DispatchQueue.main.asyncAfter(deadline: .now() + after) { focusedCourseID = lastFocused } diff --git a/Horizon/Horizon/Sources/Features/Dashboard/CourseList/Views/CourseListViewModel.swift b/Horizon/Horizon/Sources/Features/Dashboard/CourseList/Views/CourseListViewModel.swift index f7b2166279..0fbb5f7463 100644 --- a/Horizon/Horizon/Sources/Features/Dashboard/CourseList/Views/CourseListViewModel.swift +++ b/Horizon/Horizon/Sources/Features/Dashboard/CourseList/Views/CourseListViewModel.swift @@ -81,8 +81,8 @@ final class CourseListViewModel { } func seeMore() { - guard currentPage + 1 < totalPages else { return } currentPage += 1 + guard currentPage + 1 < totalPages else { return } filteredCourses.append(contentsOf: paginatedCourses[currentPage]) } From 6bf583405ef4b9633318c8fef9ca86bb3d4b8e9a Mon Sep 17 00:00:00 2001 From: Ahmed Naguib Date: Sun, 9 Nov 2025 21:45:49 +0200 Subject: [PATCH 10/11] fix: review comments --- Horizon/Horizon/Resources/Localizable.xcstrings | 2 +- .../Dashboard/CourseList/Views/CourseListViewModel.swift | 2 +- .../CourseList/Views/SubViews/CourseFilteringView.swift | 3 +-- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/Horizon/Horizon/Resources/Localizable.xcstrings b/Horizon/Horizon/Resources/Localizable.xcstrings index 5c617eece0..960bfd548d 100644 --- a/Horizon/Horizon/Resources/Localizable.xcstrings +++ b/Horizon/Horizon/Resources/Localizable.xcstrings @@ -24904,7 +24904,7 @@ } } }, - "Count of visible courses is %@ " : { + "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 }, diff --git a/Horizon/Horizon/Sources/Features/Dashboard/CourseList/Views/CourseListViewModel.swift b/Horizon/Horizon/Sources/Features/Dashboard/CourseList/Views/CourseListViewModel.swift index 0fbb5f7463..e40cbb7017 100644 --- a/Horizon/Horizon/Sources/Features/Dashboard/CourseList/Views/CourseListViewModel.swift +++ b/Horizon/Horizon/Sources/Features/Dashboard/CourseList/Views/CourseListViewModel.swift @@ -82,7 +82,7 @@ final class CourseListViewModel { func seeMore() { currentPage += 1 - guard currentPage + 1 < totalPages else { return } + guard currentPage < totalPages else { return } filteredCourses.append(contentsOf: paginatedCourses[currentPage]) } diff --git a/Horizon/Horizon/Sources/Features/Dashboard/CourseList/Views/SubViews/CourseFilteringView.swift b/Horizon/Horizon/Sources/Features/Dashboard/CourseList/Views/SubViews/CourseFilteringView.swift index b152d68b4a..d65ac93f7a 100644 --- a/Horizon/Horizon/Sources/Features/Dashboard/CourseList/Views/SubViews/CourseFilteringView.swift +++ b/Horizon/Horizon/Sources/Features/Dashboard/CourseList/Views/SubViews/CourseFilteringView.swift @@ -20,7 +20,7 @@ import HorizonUI import SwiftUI struct CourseFilteringView: View { - @State var selectedStatus: CourseCardModel.CourseStatus? + let selectedStatus: CourseCardModel.CourseStatus? @State private var isListCoursesVisiable = false let onSelect: (CourseCardModel.CourseStatus?) -> Void @@ -50,7 +50,6 @@ struct CourseFilteringView: View { VStack(spacing: .zero) { ForEach(CourseCardModel.CourseStatus.allCases, id: \.self) { status in Button { - selectedStatus = status onSelect(status) isListCoursesVisiable.toggle() } label: { From 3fc92a7cb99db32bb9bb78f0f554be8f4fd4b912 Mon Sep 17 00:00:00 2001 From: Ahmed Naguib Date: Mon, 17 Nov 2025 23:05:42 +0200 Subject: [PATCH 11/11] Add padding --- .../CourseList/Views/SubViews/CourseFilteringView.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Horizon/Horizon/Sources/Features/Dashboard/CourseList/Views/SubViews/CourseFilteringView.swift b/Horizon/Horizon/Sources/Features/Dashboard/CourseList/Views/SubViews/CourseFilteringView.swift index d65ac93f7a..2b3b1d7019 100644 --- a/Horizon/Horizon/Sources/Features/Dashboard/CourseList/Views/SubViews/CourseFilteringView.swift +++ b/Horizon/Horizon/Sources/Features/Dashboard/CourseList/Views/SubViews/CourseFilteringView.swift @@ -60,6 +60,7 @@ struct CourseFilteringView: View { } } } + .padding(.vertical, .huiSpaces.space10) } } }