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() + } +}