diff --git a/Student/Student/LearnerDashboard/Container/View/LearnerDashboardAllWidgetsTurnedOffView.swift b/Student/Student/LearnerDashboard/Container/View/LearnerDashboardAllWidgetsTurnedOffView.swift
new file mode 100644
index 0000000000..43da388e6c
--- /dev/null
+++ b/Student/Student/LearnerDashboard/Container/View/LearnerDashboardAllWidgetsTurnedOffView.swift
@@ -0,0 +1,43 @@
+//
+// This file is part of Canvas.
+// Copyright (C) 2026-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 SwiftUI
+
+struct LearnerDashboardAllWidgetsTurnedOffView: View {
+
+ var body: some View {
+ InteractivePanda(
+ scene: SpacePanda(),
+ title: String(localized: "All widgets are turned off", bundle: .student),
+ subtitle: String(localized: "Add widgets using Customize Dashboard or Dashboard Settings.", bundle: .student)
+ )
+ .paddingStyle(.top, .standard)
+ .paddingStyle(.top, .standard)
+ .frame(maxWidth: .infinity)
+ }
+}
+
+#if DEBUG
+
+#Preview {
+ LearnerDashboardAllWidgetsTurnedOffView()
+ .padding()
+}
+
+#endif
diff --git a/Student/Student/LearnerDashboard/Container/View/LearnerDashboardScreen.swift b/Student/Student/LearnerDashboard/Container/View/LearnerDashboardScreen.swift
index 341f480b24..c1b551e1a0 100644
--- a/Student/Student/LearnerDashboard/Container/View/LearnerDashboardScreen.swift
+++ b/Student/Student/LearnerDashboard/Container/View/LearnerDashboardScreen.swift
@@ -26,6 +26,7 @@ struct LearnerDashboardScreen: View {
@State private var isSettingsPresented = false
@Environment(\.viewController) private var viewController
@Environment(\.appEnvironment) private var env
+ @Environment(\.colorScheme) private var colorScheme
private let screenPadding = InstUI.Styles.Padding.standard
@State private var isAnimationEnabled = false
@@ -55,6 +56,11 @@ struct LearnerDashboardScreen: View {
widgetViewModel.makeView()
}
}
+ if viewModel.showWidgetsTurnedOffPanda {
+ LearnerDashboardAllWidgetsTurnedOffView()
+ }
+
+ customizeDashboardButton
}
.paddingStyle(.all, screenPadding)
.animation(isAnimationEnabled ? .dashboardWidget : nil, value: viewModel.widgets.map(\.layoutIdentifier))
@@ -86,6 +92,19 @@ struct LearnerDashboardScreen: View {
}
}
+ private var customizeDashboardButton: some View {
+ Button {
+ isSettingsPresented = true
+ } label: {
+ InstUI.PillContent(
+ title: String(localized: "Customize Dashboard", bundle: .student),
+ leadingIcon: .editLine,
+ size: .height30
+ )
+ }
+ .buttonStyle(.pillTintOutlined)
+ }
+
@available(iOS, introduced: 26, message: "Legacy version exists")
private var profileMenuButton: some View {
Button {
@@ -131,6 +150,7 @@ struct LearnerDashboardScreen: View {
NavigationStack {
LearnerDashboardSettingsScreen(viewModel: viewModel.makeSettingsViewModel())
}
+ .environment(\.colorScheme, colorScheme)
.accentColor(.brandPrimary)
.tint(.brandPrimary)
}
@@ -158,6 +178,7 @@ struct LearnerDashboardScreen: View {
NavigationStack {
LearnerDashboardSettingsScreen(viewModel: viewModel.makeSettingsViewModel())
}
+ .environment(\.colorScheme, colorScheme)
.accentColor(.brandPrimary)
.tint(.brandPrimary)
}
diff --git a/Student/Student/LearnerDashboard/Container/ViewModel/LearnerDashboardViewModel.swift b/Student/Student/LearnerDashboard/Container/ViewModel/LearnerDashboardViewModel.swift
index b0ca1d3116..d04bcf63df 100644
--- a/Student/Student/LearnerDashboard/Container/ViewModel/LearnerDashboardViewModel.swift
+++ b/Student/Student/LearnerDashboard/Container/ViewModel/LearnerDashboardViewModel.swift
@@ -29,19 +29,12 @@ final class LearnerDashboardViewModel {
private(set) var state: InstUI.ScreenState = .loading
private(set) var widgets: [any DashboardWidgetViewModel] = []
private(set) var mainColor: Color
+ private(set) var showWidgetsTurnedOffPanda: Bool = false
let snackBarViewModel: SnackBarViewModel
let screenConfig = InstUI.BaseScreenConfig(
refreshable: true,
showsScrollIndicators: false,
- emptyPandaConfig: .init(
- scene: SpacePanda(),
- title: String(localized: "Welcome to Canvas!", bundle: .student),
- subtitle: String(
- localized: "You don't have any courses yet — so things are a bit quiet here. Once you enroll in a class, your dashboard will start filling up with new activity.",
- bundle: .student
- )
- ),
backgroundColor: .backgroundLight
)
@@ -109,9 +102,8 @@ final class LearnerDashboardViewModel {
.sink { [weak self] result in
guard let self else { return }
widgets = result
- if result.isNotEmpty {
- state = .data
- }
+ showWidgetsTurnedOffPanda = result.allEditableWidgetsTurnedOff
+ state = .data
refresh(ignoreCache: false)
}
.store(in: &subscriptions)
diff --git a/Student/Student/LearnerDashboard/Settings/View/LearnerDashboardColorSelectorView.swift b/Student/Student/LearnerDashboard/Settings/View/LearnerDashboardColorSelectorView.swift
index d5f85afdf4..5cc036e92c 100644
--- a/Student/Student/LearnerDashboard/Settings/View/LearnerDashboardColorSelectorView.swift
+++ b/Student/Student/LearnerDashboard/Settings/View/LearnerDashboardColorSelectorView.swift
@@ -49,11 +49,7 @@ struct LearnerDashboardColorSelectorView: View {
Image.checkLine
.resizable()
.scaledFrame(size: 24)
- .foregroundStyle(
- selectedColor == whiteColor
- ? .textLightest.variantForDarkMode
- : .textLightest.variantForLightMode
- )
+ .foregroundStyle(.textLightest)
}
}
.scaledFrame(size: 40)
diff --git a/Student/Student/LearnerDashboard/Settings/View/LearnerDashboardSettingsWidgetCardView.swift b/Student/Student/LearnerDashboard/Settings/View/LearnerDashboardSettingsWidgetCardView.swift
index 8c5d06542a..8a52d3a7d1 100644
--- a/Student/Student/LearnerDashboard/Settings/View/LearnerDashboardSettingsWidgetCardView.swift
+++ b/Student/Student/LearnerDashboard/Settings/View/LearnerDashboardSettingsWidgetCardView.swift
@@ -48,7 +48,7 @@ struct LearnerDashboardSettingsWidgetCardView: View {
.padding(.bottom, 14)
.accessibilityElement(children: .combine)
- if let subSettings = subSettingsView {
+ if let subSettings = subSettingsView, isVisible {
InstUI.Divider()
.padding(.horizontal, -16)
subSettings
@@ -80,6 +80,7 @@ struct LearnerDashboardSettingsWidgetCardView: View {
localized: "Move \(config.id.settingsTitle(username: username)) widget up",
bundle: .student
))
+ .accessibilityHidden(isMoveUpDisabled)
InstUI.Divider()
.padding(.vertical, 4)
@@ -95,6 +96,7 @@ struct LearnerDashboardSettingsWidgetCardView: View {
localized: "Move \(config.id.settingsTitle(username: username)) widget down",
bundle: .student
))
+ .accessibilityHidden(isMoveDownDisabled)
}
.padding(.horizontal, 8)
.fixedSize(horizontal: false, vertical: true)
diff --git a/Student/Student/LearnerDashboard/Widgets/Common/Model/DashboardWidgetViewModel.swift b/Student/Student/LearnerDashboard/Widgets/Common/Model/DashboardWidgetViewModel.swift
index 62aee9c486..b123596196 100644
--- a/Student/Student/LearnerDashboard/Widgets/Common/Model/DashboardWidgetViewModel.swift
+++ b/Student/Student/LearnerDashboard/Widgets/Common/Model/DashboardWidgetViewModel.swift
@@ -54,3 +54,9 @@ extension DashboardWidgetViewModel {
!isHiddenInEmptyState || state != .empty
}
}
+
+extension [any DashboardWidgetViewModel] {
+ var allEditableWidgetsTurnedOff: Bool {
+ allSatisfy { EditableWidgetIdentifier(rawValue: $0.id) == nil }
+ }
+}
diff --git a/Student/Student/Localizable.xcstrings b/Student/Student/Localizable.xcstrings
index 07551552be..e7d2e4a55a 100644
--- a/Student/Student/Localizable.xcstrings
+++ b/Student/Student/Localizable.xcstrings
@@ -19924,6 +19924,9 @@
}
}
}
+ },
+ "Add widgets using Customize Dashboard or Dashboard Settings." : {
+
},
"All Courses" : {
@@ -20197,6 +20200,9 @@
}
}
}
+ },
+ "All widgets are turned off" : {
+
},
"Allowed Attempts:" : {
"localizations" : {
diff --git a/Student/StudentUnitTests/LearnerDashboard/Container/ViewModel/LearnerDashboardViewModelTests.swift b/Student/StudentUnitTests/LearnerDashboard/Container/ViewModel/LearnerDashboardViewModelTests.swift
index b273af332b..1e12a97c48 100644
--- a/Student/StudentUnitTests/LearnerDashboard/Container/ViewModel/LearnerDashboardViewModelTests.swift
+++ b/Student/StudentUnitTests/LearnerDashboard/Container/ViewModel/LearnerDashboardViewModelTests.swift
@@ -90,16 +90,11 @@ final class LearnerDashboardViewModelTests: StudentTestCase {
XCTAssertEqual(testee.screenConfig.refreshable, true)
XCTAssertEqual(testee.screenConfig.showsScrollIndicators, false)
- XCTAssertEqual(testee.screenConfig.emptyPandaConfig.scene is SpacePanda, true)
- XCTAssertEqual(
- testee.screenConfig.emptyPandaConfig.title,
- String(localized: "Welcome to Canvas!", bundle: .student)
- )
}
// MARK: - State management
- func test_init_withNoWidgets_shouldKeepLoadingState() {
+ func test_init_withNoWidgets_shouldSetDataState() {
testee = LearnerDashboardViewModel(
interactor: interactor,
colorInteractor: colorInteractor,
@@ -111,7 +106,7 @@ final class LearnerDashboardViewModelTests: StudentTestCase {
interactor.loadWidgetsPublisher.send([])
scheduler.advance()
- XCTAssertEqual(testee.state, .loading)
+ XCTAssertEqual(testee.state, .data)
}
func test_init_withWidgets_shouldSetDataState() {
diff --git a/Student/StudentUnitTests/LearnerDashboard/Widgets/Common/Model/DashboardWidgetViewModelTests.swift b/Student/StudentUnitTests/LearnerDashboard/Widgets/Common/Model/DashboardWidgetViewModelTests.swift
new file mode 100644
index 0000000000..172350b43c
--- /dev/null
+++ b/Student/StudentUnitTests/LearnerDashboard/Widgets/Common/Model/DashboardWidgetViewModelTests.swift
@@ -0,0 +1,75 @@
+//
+// This file is part of Canvas.
+// Copyright (C) 2026-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 Combine
+@testable import Core
+@testable import Student
+import SwiftUI
+import XCTest
+
+final class DashboardWidgetViewModelArrayTests: XCTestCase {
+
+ // MARK: - allEditableWidgetsTurnedOff
+
+ func test_allEditableWidgetsTurnedOff_withEmptyArray_shouldBeTrue() {
+ let widgets: [any DashboardWidgetViewModel] = []
+
+ XCTAssertTrue(widgets.allEditableWidgetsTurnedOff)
+ }
+
+ func test_allEditableWidgetsTurnedOff_withOnlySystemWidgets_shouldBeTrue() {
+ let widgets: [any DashboardWidgetViewModel] = [
+ MockDashboardWidgetViewModel(id: SystemWidgetIdentifier.courseInvitations.rawValue)
+ ]
+
+ XCTAssertTrue(widgets.allEditableWidgetsTurnedOff)
+ }
+
+ func test_allEditableWidgetsTurnedOff_withEditableWidget_shouldBeFalse() {
+ let widgets: [any DashboardWidgetViewModel] = [
+ MockDashboardWidgetViewModel(id: EditableWidgetIdentifier.helloWidget.rawValue)
+ ]
+
+ XCTAssertFalse(widgets.allEditableWidgetsTurnedOff)
+ }
+
+ func test_allEditableWidgetsTurnedOff_withSystemAndEditableWidgets_shouldBeFalse() {
+ let widgets: [any DashboardWidgetViewModel] = [
+ MockDashboardWidgetViewModel(id: SystemWidgetIdentifier.courseInvitations.rawValue),
+ MockDashboardWidgetViewModel(id: EditableWidgetIdentifier.coursesAndGroups.rawValue)
+ ]
+
+ XCTAssertFalse(widgets.allEditableWidgetsTurnedOff)
+ }
+}
+
+private final class MockDashboardWidgetViewModel: DashboardWidgetViewModel {
+ let id: String
+ let state: InstUI.ScreenState = .data
+ let isHiddenInEmptyState = false
+
+ init(id: String) {
+ self.id = id
+ }
+
+ func makeView() -> AnyView { AnyView(EmptyView()) }
+
+ func refresh(ignoreCache: Bool) -> AnyPublisher {
+ Just(()).eraseToAnyPublisher()
+ }
+}