Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
721702a
feat: Add new notifications screen
Ahmed-Naguib93 Sep 26, 2025
c1e0330
Add accessibility
Ahmed-Naguib93 Sep 26, 2025
388488f
Add tests
Ahmed-Naguib93 Sep 29, 2025
dcc7605
Add release note
Ahmed-Naguib93 Sep 29, 2025
294f136
fix: review comments
Ahmed-Naguib93 Oct 5, 2025
0e3246d
Addressed code review comments
Ahmed-Naguib93 Oct 6, 2025
6cbf7f1
feat: add skill widgets
Ahmed-Naguib93 Oct 7, 2025
c1d35e9
fix: accessibility issues
Ahmed-Naguib93 Oct 8, 2025
79ea4ac
Add text line height & fix review comments
Ahmed-Naguib93 Oct 8, 2025
197ed18
Add accessibility for notification button at dashboard
Ahmed-Naguib93 Oct 9, 2025
e267e8d
fix liniting
Ahmed-Naguib93 Oct 9, 2025
fdcfff9
fix swiftling
Ahmed-Naguib93 Oct 9, 2025
86c6ec1
Merge branch 'feature/learner-dashboard' into feature/horizon-My-Skil…
Ahmed-Naguib93 Oct 9, 2025
22ad569
feat: Add My Skills widgets
Ahmed-Naguib93 Oct 9, 2025
ff2185a
Add accessibility
Ahmed-Naguib93 Oct 9, 2025
8398c73
Merge branch 'feature/learner-dashboard' into feature/horizon-My-Skil…
Ahmed-Naguib93 Oct 10, 2025
f9836ed
Remove unneeded code
Ahmed-Naguib93 Oct 10, 2025
f11a0c3
Fix: swiftling
Ahmed-Naguib93 Oct 10, 2025
19ff0d6
Fix spacing
Ahmed-Naguib93 Oct 10, 2025
dfab629
fix test cases
Ahmed-Naguib93 Oct 10, 2025
ce27585
Merge branch 'feature/horizon-Notifications-screen-CLX-2931' into fea…
Ahmed-Naguib93 Oct 10, 2025
c6104be
fix: unit tests
Ahmed-Naguib93 Oct 11, 2025
3d477c0
feat: add skills count card
Ahmed-Naguib93 Oct 12, 2025
1325b8a
feat: add skills count card
Ahmed-Naguib93 Oct 12, 2025
3d7b631
fix: preview Skill Cards
Ahmed-Naguib93 Oct 13, 2025
cd1701c
Edit SkillViewModel initializatio
Ahmed-Naguib93 Oct 13, 2025
6a88c05
Merge branch 'feature/learner-dashboard' into feature/horizon-My-Skil…
Ahmed-Naguib93 Oct 13, 2025
04d5383
fix: naming files
Ahmed-Naguib93 Oct 13, 2025
beabb21
Merge branch 'feature/horizon-My-Skills-widgets-CLX-2933' into featur…
Ahmed-Naguib93 Oct 13, 2025
f975788
Merge branch 'feature/learner-dashboard' into feature/horizon-My-Skil…
Ahmed-Naguib93 Oct 13, 2025
56d8ec4
Addressed review comments
Ahmed-Naguib93 Oct 13, 2025
9892efb
Move skill under career feature
Ahmed-Naguib93 Oct 13, 2025
18cc7fb
Merge branch 'feature/horizon-My-Skills-widgets-CLX-2933' into featur…
Ahmed-Naguib93 Oct 13, 2025
c9357aa
fix linting
Ahmed-Naguib93 Oct 13, 2025
d4dc69d
feat: Add Announcement Widget
Ahmed-Naguib93 Oct 14, 2025
c5580c5
Merge branch 'feature/learner-dashboard' into feature/horizon-announc…
Ahmed-Naguib93 Oct 15, 2025
ab3741d
fix: conflicts
Ahmed-Naguib93 Oct 15, 2025
bff55ba
Merge branch 'feature/learner-dashboard' into feature/horizon-announc…
Ahmed-Naguib93 Oct 15, 2025
ff7324a
fix: accessibility for loading state
Ahmed-Naguib93 Oct 15, 2025
9350094
Merge branch 'feature/learner-dashboard' into feature/horizon-announc…
szabinst Oct 16, 2025
1e794f1
chore: rename skills folder
szabinst Oct 16, 2025
08a81c8
chore: renamings
szabinst Oct 16, 2025
b1964ae
fix: lint
szabinst Oct 16, 2025
e0c766e
feat: change course title to h4
szabinst Oct 16, 2025
566ed12
feat: course pagination logic
szabinst Oct 16, 2025
20d2df9
fix: dataWidget top padding
szabinst Oct 16, 2025
aa9ef37
fix: a11y string
szabinst Oct 16, 2025
d50c238
fix: dashboard trailing bar a11y strings
szabinst Oct 17, 2025
6ddf90a
feat: dashboard a11y
szabinst Oct 20, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ private struct DidAppearViewModifier: ViewModifier {
}

extension View {
func onDidAppear(perform action: @escaping () -> Void) -> some View {
public func onDidAppear(perform action: @escaping () -> Void) -> some View {
modifier(DidAppearViewModifier(action: action))
}
}
53 changes: 52 additions & 1 deletion Horizon/Horizon/Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -24580,6 +24580,10 @@
}
}
},
"Course %@" : {
"comment" : "A string that can be read by VoiceOver that describes the course name. The argument is the course name.",
"isCommentAutoGenerated" : true
},
"Course: %@. " : {

},
Expand Down Expand Up @@ -25356,6 +25360,9 @@
}
}
}
},
"Date %@" : {

},
"Delete draft" : {
"localizations" : {
Expand Down Expand Up @@ -35626,6 +35633,10 @@
}
}
},
"Go to announcement" : {
"comment" : "Button text that links to a specific announcement.",
"isCommentAutoGenerated" : true
},
"Greenland (-02:00/-01:00)" : {
"localizations" : {
"ar" : {
Expand Down Expand Up @@ -46124,6 +46135,18 @@
}
}
},
"Loading announcements" : {
"comment" : "A label displayed while loading announcements.",
"isCommentAutoGenerated" : true
},
"Loading Skill highlights" : {
"comment" : "A label displayed while loading skill highlights.",
"isCommentAutoGenerated" : true
},
"Loading skills earned" : {
"comment" : "A label displayed while loading the number of skills a user has earned.",
"isCommentAutoGenerated" : true
},
"Locked" : {
"localizations" : {
"ar" : {
Expand Down Expand Up @@ -59196,7 +59219,7 @@
}
}
},
"Open learning learning object" : {
"Open learning object" : {

},
"Optional" : {
Expand Down Expand Up @@ -63312,6 +63335,14 @@
}
}
},
"Program" : {
"comment" : "Title of a status chip for a program.",
"isCommentAutoGenerated" : true
},
"Program details" : {
"comment" : "Text on a button that takes the user to the details of a program.",
"isCommentAutoGenerated" : true
},
"Program overview" : {
"localizations" : {
"ar" : {
Expand Down Expand Up @@ -82287,6 +82318,10 @@
}
}
},
"Title %@" : {
"comment" : "A description of a notification's title.",
"isCommentAutoGenerated" : true
},
"Title/subject" : {
"localizations" : {
"ar" : {
Expand Down Expand Up @@ -86647,6 +86682,10 @@
}
}
},
"View your program to enroll in your first course." : {
"comment" : "A phrase encouraging them to view their program to enroll in a course.",
"isCommentAutoGenerated" : true
},
"View your progress and scores on the Learn page." : {
"extractionState" : "stale",
"localizations" : {
Expand Down Expand Up @@ -88706,6 +88745,18 @@
}
}
},
"Welcome to %@ %@" : {
"comment" : "A description text that greets a user with a program name and a call to action to view the program. The first argument is the name of the program. The second argument is the string “View your program to enroll in your first course.”.",
"isCommentAutoGenerated" : true,
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "Welcome to %1$@ %2$@"
}
}
}
},
"Welcome! View your program to enroll in your first course." : {
"extractionState" : "stale",
"localizations" : {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
//
// 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 <https://www.gnu.org/licenses/>.
//

import Core
import SwiftUI

enum AnnouncementsWidgetAssembly {
static func makeViewModel() -> AnnouncementsListWidgetViewModel {
AnnouncementsListWidgetViewModel(interactor: makeInteractor(), router: AppEnvironment.shared.router)
}

static func makeView() -> AnnouncementsListWidgetView {
let viewModel = makeViewModel()
return AnnouncementsListWidgetView(viewModel: viewModel)
}

private static func makeInteractor() -> NotificationInteractor {
let formatter = NotificationFormatterLive()
let interactor = NotificationInteractorLive(
userID: AppEnvironment.shared.currentSession?.userID ?? "",
formatter: formatter
)
return interactor
}

#if DEBUG
static func makePreview() -> AnnouncementsListWidgetView {
let interactor = NotificationInteractorPreview()
let viewModel = AnnouncementsListWidgetViewModel(
interactor: interactor,
router: AppEnvironment.shared.router
)
return AnnouncementsListWidgetView(viewModel: viewModel)
}
#endif
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
//
// 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 <https://www.gnu.org/licenses/>.
//

import HorizonUI
import SwiftUI

struct AnnouncementWidgetView: View {
let announcement: NotificationModel
let onTap: (NotificationModel) -> Void
let focusedAnnouncementID: AccessibilityFocusState<String?>.Binding

init(
announcement: NotificationModel,
focusedAnnouncementID: AccessibilityFocusState<String?>.Binding,
onTap: @escaping ((NotificationModel) -> Void)
) {
self.announcement = announcement
self.focusedAnnouncementID = focusedAnnouncementID
self.onTap = onTap
}

var body: some View {
VStack(alignment: .leading, spacing: .huiSpaces.space4) {
HorizonUI.StatusChip(
title: announcement.type.title,
style: announcement.type.style
)
.padding(.bottom, .huiSpaces.space10)
.padding(.bottom, .huiSpaces.space2)
.skeletonLoadable()

if let courseName = announcement.courseName {
Text(courseName)
.lineLimit(1)
.huiTypography(.p2)
.foregroundStyle(Color.huiColors.text.dataPoint)
.accessibilityLabel(announcement.accessibilityCourseName)
.frame(maxWidth: .infinity, alignment: .leading)
.skeletonLoadable()
}

Text(announcement.dateFormatted)
.huiTypography(.p3)
.foregroundStyle(Color.huiColors.text.timestamp)
.accessibilityLabel(announcement.accessibilityDate)
.padding(.bottom, .huiSpaces.space4)
.frame(maxWidth: .infinity, alignment: .leading)
.skeletonLoadable()

Text(announcement.title)
.huiTypography(.p1)
.multilineTextAlignment(.leading)
.foregroundStyle(Color.huiColors.text.body)
.accessibilityLabel(announcement.accessibilityTitle)
.padding(.bottom, .huiSpaces.space10)
.padding(.bottom, .huiSpaces.space2)
.frame(maxWidth: .infinity, alignment: .leading)
.skeletonLoadable()

buttonView
.skeletonLoadable()
}
.padding(.huiSpaces.space24)
.background(Color.huiColors.surface.pageSecondary)
.huiCornerRadius(level: .level5)
.huiElevation(level: .level4)
}

private var buttonView: some View {
HorizonUI.PrimaryButton(
String(localized: "Go to announcement", bundle: .horizon),
type: .darkOutline,
isSmall: true
) {
onTap(announcement)
}
.accessibilityFocused(focusedAnnouncementID, equals: announcement.id)
}
}

#Preview {
@Previewable @AccessibilityFocusState var focusState: String?

AnnouncementWidgetView(
announcement: .init(
id: "1",
title: "The full announcement could be shown here, or we could truncate it. Lorem ipsum dolor sit amet, consectetur adipiscing elit,",
date: Date(),
isRead: true,
courseName: "Course Name",
type: .announcement
), focusedAnnouncementID: $focusState
) { _ in }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
//
// 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 <https://www.gnu.org/licenses/>.
//

import Core
import HorizonUI
import SwiftUI

struct AnnouncementsListWidgetView: View {
@Environment(\.viewController) private var viewController
@Environment(\.dashboardLastFocusedElement) private var lastFocusedElement
@Environment(\.dashboardRestoreFocusTrigger) private var restoreFocusTrigger
@State private var viewModel: AnnouncementsListWidgetViewModel
@AccessibilityFocusState private var focusedAnnouncementID: String?

init(viewModel: AnnouncementsListWidgetViewModel) {
self.viewModel = viewModel
}

var body: some View {
VStack(spacing: .huiSpaces.space16) {
switch viewModel.state {
case .loading:
AnnouncementWidgetView(
announcement: NotificationModel.mock,
focusedAnnouncementID: $focusedAnnouncementID
) { _ in }
.accessibilityElement(children: .ignore)
.accessibilityLabel(Text(String(localized: "Loading announcements", bundle: .horizon)))
case let .data(announcements: announcements):
ForEach(announcements) { announcement in
AnnouncementWidgetView(
announcement: announcement,
focusedAnnouncementID: $focusedAnnouncementID,
onTap: { seletedAnnouncement in
lastFocusedElement.wrappedValue = .announcement(id: seletedAnnouncement.id)
viewModel.navigateToAnnouncement(
announcement: seletedAnnouncement,
viewController: viewController
)
}
)
}
}
}
.padding(.bottom, .huiSpaces.space16)
.padding(.horizontal, .huiSpaces.space24)
.isSkeletonLoadActive(viewModel.state == .loading)
.onWidgetReload { completion in
viewModel.fetchAnnouncements(ignoreCache: true, completion: completion)
}
.onChange(of: restoreFocusTrigger) { _, _ in
if let lastFocused = lastFocusedElement.wrappedValue,
case let .announcement(id) = lastFocused {
DispatchQueue.main.async {
focusedAnnouncementID = id
}
}
}
}
}

#if DEBUG
#Preview {
let interactor = NotificationInteractorPreview()
let viewModel = AnnouncementsListWidgetViewModel(
interactor: interactor,
router: AppEnvironment.shared.router
)
AnnouncementsListWidgetView(viewModel: viewModel)
}
#endif
Loading