diff --git a/Core/Core/Common/CommonUI/InstUI/Styles/Elevation.swift b/Core/Core/Common/CommonUI/InstUI/Styles/Elevation.swift index 870a870284..eb7ab39b4f 100644 --- a/Core/Core/Common/CommonUI/InstUI/Styles/Elevation.swift +++ b/Core/Core/Common/CommonUI/InstUI/Styles/Elevation.swift @@ -43,24 +43,24 @@ extension View { public func elevation( _ shape: InstUI.Styles.Elevation.Shape, background: some ShapeStyle, - shadowColor: Color = .black + isShadowVisible: Bool = true ) -> some View { self.elevation( cornerRadius: shape.cornerRadius, background: background, - shadowColor: shadowColor + isShadowVisible: isShadowVisible ) } public func elevation( cornerRadius: CGFloat, background: some ShapeStyle, - shadowColor: Color = .black + isShadowVisible: Bool = true ) -> some View { self .background(background) .cornerRadius(cornerRadius) - .shadow(color: shadowColor.opacity(0.08), radius: 2, y: 2) - .shadow(color: shadowColor.opacity(0.16), radius: 2, y: 1) + .shadow(color: .black.opacity(isShadowVisible ? 0.08 : 0), radius: 2, y: 2) + .shadow(color: .black.opacity(isShadowVisible ? 0.16 : 0), radius: 2, y: 1) } } diff --git a/Core/Core/Common/CommonUI/InstUI/Views/Cells/ToggleCell.swift b/Core/Core/Common/CommonUI/InstUI/Views/Cells/ToggleCell.swift index 1836c07ed2..c2a6873eca 100644 --- a/Core/Core/Common/CommonUI/InstUI/Views/Cells/ToggleCell.swift +++ b/Core/Core/Common/CommonUI/InstUI/Views/Cells/ToggleCell.swift @@ -25,10 +25,16 @@ extension InstUI { private let label: Label @Binding private var value: Bool + private let dividerStyle: InstUI.Divider.Style - public init(label: Label, value: Binding) { + public init( + label: Label, + value: Binding, + dividerStyle: InstUI.Divider.Style = .full + ) { self.label = label self._value = value + self.dividerStyle = dividerStyle } public var body: some View { @@ -40,7 +46,7 @@ extension InstUI { // best effort estimations to match the height of other cells, correcting for Toggle .padding(.top, 10) .padding(.bottom, 8) - InstUI.Divider() + InstUI.Divider(dividerStyle) } } } diff --git a/Core/Core/Common/CommonUI/Layout/FlexibleGrid.swift b/Core/Core/Common/CommonUI/Layout/FlexibleGrid.swift new file mode 100644 index 0000000000..059ef5fc4a --- /dev/null +++ b/Core/Core/Common/CommonUI/Layout/FlexibleGrid.swift @@ -0,0 +1,139 @@ +// +// 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 SwiftUI + +/// FlexibleGrid places as many subviews in a row as possible. The remaining space is distributed evenly between the row's subviews. +/// If the last row is not complete the subviews are placed from the leading edge. +public struct FlexibleGrid: Layout { + let minimumSpacing: CGFloat + let lineSpacing: CGFloat + + public init(minimumSpacing: CGFloat = 8, lineSpacing: CGFloat = 8) { + self.minimumSpacing = minimumSpacing + self.lineSpacing = lineSpacing + } + + public func sizeThatFits( + proposal: ProposedViewSize, + subviews: Subviews, + cache: inout () + ) -> CGSize { + guard !subviews.isEmpty, let maxWidth = proposal.width else { return .zero } + + let itemWidth = measureItemWidth(subviews: subviews, maxWidth: maxWidth) + let columns = columnCount(itemWidth: itemWidth, maxWidth: maxWidth) + let rowCount = (subviews.count + columns - 1) / columns + let rowHeight = measureRowHeight(subviews: subviews, maxWidth: maxWidth) + + let totalHeight = CGFloat(rowCount) * rowHeight + CGFloat(max(rowCount - 1, 0)) * lineSpacing + + let contentWidth = { + if let proposedWidth = proposal.width { + return proposedWidth + } else { + let gaps = max(columns - 1, 0) + return CGFloat(columns) * itemWidth + CGFloat(gaps) * minimumSpacing + } + }() + + return CGSize(width: contentWidth, height: totalHeight) + } + + public func placeSubviews( + in bounds: CGRect, + proposal: ProposedViewSize, + subviews: Subviews, + cache: inout () + ) { + guard !subviews.isEmpty else { return } + + let itemWidth = measureItemWidth(subviews: subviews, maxWidth: bounds.width) + let columns = columnCount(itemWidth: itemWidth, maxWidth: bounds.width) + let rowHeight = measureRowHeight(subviews: subviews, maxWidth: bounds.width) + + let totalItemWidth = CGFloat(columns) * itemWidth + let gaps = max(columns - 1, 0) + let uniformSpacing = gaps > 0 ? (bounds.width - totalItemWidth) / CGFloat(gaps) : 0 + + for (index, subview) in subviews.enumerated() { + let col = index % columns + let row = index / columns + + let x = bounds.minX + CGFloat(col) * (itemWidth + uniformSpacing) + + let y = bounds.minY + CGFloat(row) * (rowHeight + lineSpacing) + let size = subview.sizeThatFits(ProposedViewSize(width: itemWidth, height: nil)) + let yOffset = (rowHeight - size.height) / 2 + + subview.place( + at: CGPoint(x: x, y: y + yOffset), + anchor: .topLeading, + proposal: ProposedViewSize(width: size.width, height: size.height) + ) + } + } + + private func measureItemWidth(subviews: Subviews, maxWidth: CGFloat) -> CGFloat { + subviews.reduce(CGFloat(0)) { maxSoFar, subview in + let size = subview.sizeThatFits(ProposedViewSize(width: maxWidth, height: nil)) + return max(maxSoFar, size.width) + } + } + + private func measureRowHeight(subviews: Subviews, maxWidth: CGFloat) -> CGFloat { + subviews.reduce(CGFloat(0)) { maxSoFar, subview in + let size = subview.sizeThatFits(ProposedViewSize(width: maxWidth, height: nil)) + return max(maxSoFar, size.height) + } + } + + private func columnCount(itemWidth: CGFloat, maxWidth: CGFloat) -> Int { + guard itemWidth > 0 else { return 1 } + var count = 1 + while CGFloat(count + 1) * itemWidth + CGFloat(count) * minimumSpacing <= maxWidth { + count += 1 + } + return count + } +} + +#Preview("Left to Right") { + let colors: [Color] = [.red, .blue, .green, .yellow, .indigo, .teal, .purple] + + FlexibleGrid { + ForEach(colors, id: \.self) { color in + Circle() + .fill(color) + .scaledFrame(size: 40) + } + } +} + +#Preview("Right to Left") { + let colors: [Color] = [.red, .blue, .green, .yellow, .indigo, .teal, .purple] + + FlexibleGrid { + ForEach(colors, id: \.self) { color in + Circle() + .fill(color) + .scaledFrame(size: 40) + } + } + .environment(\.layoutDirection, .rightToLeft) +} diff --git a/Core/Core/Features/Dashboard/CourseCardList/Model/CourseColorData.swift b/Core/Core/Features/Dashboard/CourseCardList/Model/CourseColorData.swift new file mode 100644 index 0000000000..afc4ef2491 --- /dev/null +++ b/Core/Core/Features/Dashboard/CourseCardList/Model/CourseColorData.swift @@ -0,0 +1,50 @@ +// +// 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 UIKit + +public extension CourseColorData { + static let all: [CourseColorData] = [ + .init(persistentId: "plum", color: .course1, name: String(localized: "Plum", bundle: .core, comment: "This is a name of a color.")), + .init(persistentId: "fuchsia", color: .course2, name: String(localized: "Fuchsia", bundle: .core, comment: "This is a name of a color.")), + .init(persistentId: "violet", color: .course3, name: String(localized: "Violet", bundle: .core, comment: "This is a name of a color.")), + .init(persistentId: "ocean", color: .course4, name: String(localized: "Ocean", bundle: .core, comment: "This is a name of a color.")), + .init(persistentId: "sky", color: .course5, name: String(localized: "Sky", bundle: .core, comment: "This is a name of a color.")), + .init(persistentId: "sea", color: .course6, name: String(localized: "Sea", bundle: .core, comment: "This is a name of a color.")), + .init(persistentId: "aurora", color: .course7, name: String(localized: "Aurora", bundle: .core, comment: "This is a name of a color.")), + .init(persistentId: "forest", color: .course8, name: String(localized: "Forest", bundle: .core, comment: "This is a name of a color.")), + .init(persistentId: "honey", color: .course9, name: String(localized: "Honey", bundle: .core, comment: "This is a name of a color.")), + .init(persistentId: "copper", color: .course10, name: String(localized: "Copper", bundle: .core, comment: "This is a name of a color.")), + .init(persistentId: "rose", color: .course11, name: String(localized: "Rose", bundle: .core, comment: "This is a name of a color.")), + .init(persistentId: "stone", color: .course12, name: String(localized: "Stone", bundle: .core, comment: "This is a name of a color.")) + ] +} + +public struct CourseColorData: Identifiable { + public let persistentId: String + public let color: UIColor + public let name: String + + public var id: String { persistentId } + + public init(persistentId: String, color: UIColor, name: String) { + self.persistentId = persistentId + self.color = color + self.name = name + } +} diff --git a/Core/Core/Features/Dashboard/CourseCardList/Model/CourseColorsInteractor.swift b/Core/Core/Features/Dashboard/CourseCardList/Model/CourseColorsInteractor.swift index 3ad5bb2136..36b55ac8ed 100644 --- a/Core/Core/Features/Dashboard/CourseCardList/Model/CourseColorsInteractor.swift +++ b/Core/Core/Features/Dashboard/CourseCardList/Model/CourseColorsInteractor.swift @@ -19,8 +19,8 @@ import UIKit public protocol CourseColorsInteractor { - /// These are the pre-defined course colors the user can choose from. The values are the color names for accessibility. - var colors: KeyValuePairs { get } + /// These are the pre-defined course colors the user can choose from. + var colors: [CourseColorData] { get } /// - parameters: /// - colorHex: The hex color of the color in light theme. @@ -28,33 +28,16 @@ public protocol CourseColorsInteractor { } public class CourseColorsInteractorLive: CourseColorsInteractor { - // Used elsewhere without the live interactor's logic - public static let colors: KeyValuePairs = [ - .course1: String(localized: "Plum", bundle: .core, comment: "This is a name of a color."), - .course2: String(localized: "Fuchsia", bundle: .core, comment: "This is a name of a color."), - .course3: String(localized: "Violet", bundle: .core, comment: "This is a name of a color."), - .course4: String(localized: "Ocean", bundle: .core, comment: "This is a name of a color."), - .course5: String(localized: "Sky", bundle: .core, comment: "This is a name of a color."), - .course6: String(localized: "Sea", bundle: .core, comment: "This is a name of a color."), - .course7: String(localized: "Aurora", bundle: .core, comment: "This is a name of a color."), - .course8: String(localized: "Forest", bundle: .core, comment: "This is a name of a color."), - .course9: String(localized: "Honey", bundle: .core, comment: "This is a name of a color."), - .course10: String(localized: "Copper", bundle: .core, comment: "This is a name of a color."), - .course11: String(localized: "Rose", bundle: .core, comment: "This is a name of a color."), - .course12: String(localized: "Stone", bundle: .core, comment: "This is a name of a color.") - ] + public let colors: [CourseColorData] - public var colors: KeyValuePairs { - Self.colors - } - - public init() { + public init(colors: [CourseColorData] = CourseColorData.all) { + self.colors = colors } public func courseColorFromAPIColor(_ colorHex: String) -> UIColor { - let predefinedColor = colors.first { (color, _) in - color.variantForLightMode.hexString == colorHex - }?.key + let predefinedColor = colors.first { + $0.color.variantForLightMode.hexString == colorHex + }?.color if let predefinedColor { return predefinedColor diff --git a/Core/Core/Features/Dashboard/CourseCardList/View/CustomizeCourseView.swift b/Core/Core/Features/Dashboard/CourseCardList/View/CustomizeCourseView.swift index 298fd94999..b566dc0d8c 100644 --- a/Core/Core/Features/Dashboard/CourseCardList/View/CustomizeCourseView.swift +++ b/Core/Core/Features/Dashboard/CourseCardList/View/CustomizeCourseView.swift @@ -70,16 +70,15 @@ public struct CustomizeCourseView: View { width: width - 32 // account for padding coming from EditorRow ) { itemIndex in let item = viewModel.colors[itemIndex] - let uiColor = item.key - let isSelected = viewModel.shouldShowCheckmark(for: uiColor) - Button(action: { viewModel.color = uiColor }, label: { + let isSelected = viewModel.shouldShowCheckmark(for: item.color) + Button(action: { viewModel.color = item.color }, label: { Circle() - .fill(Color(uiColor)) + .fill(Color(item.color)) .overlay(isSelected ? Image.checkSolid.foregroundColor(.textLightest) : nil) .animation(.default, value: viewModel.color) }) .accessibility(addTraits: isSelected ? .isSelected : []) - .accessibility(label: Text(item.value)) + .accessibility(label: Text(item.name)) } .padding(.vertical, 12) } diff --git a/Core/Core/Features/Dashboard/CourseCardList/ViewModel/CustomizeCourseViewModel.swift b/Core/Core/Features/Dashboard/CourseCardList/ViewModel/CustomizeCourseViewModel.swift index 77752175a1..7c07b726f2 100644 --- a/Core/Core/Features/Dashboard/CourseCardList/ViewModel/CustomizeCourseViewModel.swift +++ b/Core/Core/Features/Dashboard/CourseCardList/ViewModel/CustomizeCourseViewModel.swift @@ -27,7 +27,7 @@ public class CustomizeCourseViewModel: ObservableObject { // MARK: - Outputs @Published public private(set) var isLoading: Bool = false - public let colors: KeyValuePairs + public let colors: [CourseColorData] public let courseImage: URL? public let hideColorOverlay: Bool public let dismissView = PassthroughSubject() diff --git a/Core/CoreTests/Features/Dashboard/CourseCardList/Model/CourseColorsInteractorLiveTests.swift b/Core/CoreTests/Features/Dashboard/CourseCardList/Model/CourseColorsInteractorLiveTests.swift index 5271a3b8dc..cc152bb44f 100644 --- a/Core/CoreTests/Features/Dashboard/CourseCardList/Model/CourseColorsInteractorLiveTests.swift +++ b/Core/CoreTests/Features/Dashboard/CourseCardList/Model/CourseColorsInteractorLiveTests.swift @@ -32,9 +32,13 @@ class CourseColorsInteractorLiveTests: XCTestCase { super.tearDown() } + func testDefaultColorsAreCourseColorDataAll() { + XCTAssertEqual(testee.colors.map(\.persistentId), CourseColorData.all.map(\.persistentId)) + } + func testColorsSupportDarkMode() { - for (color, _) in testee.colors { - XCTAssertNotEqual(color.variantForLightMode, color.variantForDarkMode) + for colorData in testee.colors { + XCTAssertNotEqual(colorData.color.variantForLightMode, colorData.color.variantForDarkMode) } XCTAssertEqual(testee.colors.count, 12) diff --git a/Core/CoreTests/Features/Dashboard/CourseCardList/ViewModel/CustomizeCourseViewModelTests.swift b/Core/CoreTests/Features/Dashboard/CourseCardList/ViewModel/CustomizeCourseViewModelTests.swift index 6c840f8e3a..45906383fe 100644 --- a/Core/CoreTests/Features/Dashboard/CourseCardList/ViewModel/CustomizeCourseViewModelTests.swift +++ b/Core/CoreTests/Features/Dashboard/CourseCardList/ViewModel/CustomizeCourseViewModelTests.swift @@ -44,7 +44,7 @@ class CustomizeCourseViewModelTests: CoreTestCase { func testInitialPropertiesMapped() { XCTAssertEqual(testee.isLoading, false) XCTAssertTrue(testee.colors.elementsEqual(colorsInteractor.colors) { color1, color2 in - color1.key == color2.key && color1.value == color2.value + color1.persistentId == color2.persistentId }) XCTAssertEqual(testee.courseImage, .make()) XCTAssertEqual(testee.hideColorOverlay, true) diff --git a/Student/Student/LearnerDashboard/Colors/Model/LearnerDashboardColorInteractor.swift b/Student/Student/LearnerDashboard/Colors/Model/LearnerDashboardColorInteractor.swift new file mode 100644 index 0000000000..68d7be8205 --- /dev/null +++ b/Student/Student/LearnerDashboard/Colors/Model/LearnerDashboardColorInteractor.swift @@ -0,0 +1,66 @@ +// +// 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 +import Core +import SwiftUI + +protocol LearnerDashboardColorInteractor: AnyObject { + var availableColors: [CourseColorData] { get } + var dashboardColor: CurrentValueSubject { get } + func selectColor(_ color: Color) +} + +final class LearnerDashboardColorInteractorLive: LearnerDashboardColorInteractor { + var availableColors: [CourseColorData] { Self.allColors } + let dashboardColor: CurrentValueSubject + + private var defaults: SessionDefaults + + init(defaults: SessionDefaults) { + self.defaults = defaults + let savedId = defaults.learnerDashboardColorId + let initialColor = Self.allColors.first(where: { $0.persistentId == savedId })?.color.asColor ?? Self.defaultColor + self.dashboardColor = CurrentValueSubject(initialColor) + } + + func selectColor(_ color: Color) { + guard let colorData = availableColors.first(where: { $0.color.asColor == color }) else { + return + } + + defaults.learnerDashboardColorId = colorData.persistentId + dashboardColor.send(color) + } +} + +extension LearnerDashboardColorInteractorLive { + + private static let defaultColor: Color = CourseColorData.all[0].color.asColor + private static let allColors: [CourseColorData] = { + let courseColors = CourseColorData.all + let additionalColors = [ + CourseColorData( + persistentId: "black", + color: UIColor.backgroundDarkest, + name: String(localized: "Black", bundle: .core, comment: "This is a name of a color.") + ) + ] + return courseColors + additionalColors + }() +} diff --git a/Student/Student/LearnerDashboard/Colors/Model/SessionDefaults+DashboardColor.swift b/Student/Student/LearnerDashboard/Colors/Model/SessionDefaults+DashboardColor.swift new file mode 100644 index 0000000000..b6008f9acd --- /dev/null +++ b/Student/Student/LearnerDashboard/Colors/Model/SessionDefaults+DashboardColor.swift @@ -0,0 +1,30 @@ +// +// This file is part of Canvas. +// Copyright (C) 2024-present Instructure, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +// + +import Core +import Foundation + +extension SessionDefaults { + private static let learnerDashboardColorIdKey = "learnerDashboardColorId" + + /// The `persistentId` of the selected `CourseColorData`. + var learnerDashboardColorId: String? { + get { self[Self.learnerDashboardColorIdKey] as? String } + set { self[Self.learnerDashboardColorIdKey] = newValue } + } +} diff --git a/Student/Student/LearnerDashboard/Container/LearnerDashboardAssembly.swift b/Student/Student/LearnerDashboard/Container/LearnerDashboardAssembly.swift index a694c51ac9..fb533b6440 100644 --- a/Student/Student/LearnerDashboard/Container/LearnerDashboardAssembly.swift +++ b/Student/Student/LearnerDashboard/Container/LearnerDashboardAssembly.swift @@ -22,31 +22,26 @@ enum LearnerDashboardAssembly { static func makeScreen() -> LearnerDashboardScreen { let snackBarViewModel = SnackBarViewModel() - let widgetFactory: (DashboardWidgetConfig) -> any DashboardWidgetViewModel = { config in - LearnerDashboardWidgetAssembly.makeWidgetViewModel( - config: config, - snackBarViewModel: snackBarViewModel - ) + let colorInteractor = LearnerDashboardColorInteractorLive( + defaults: AppEnvironment.shared.userDefaults ?? .fallback + ) + let coursesInteractor = LearnerDashboardWidgetAssembly.makeCoursesInteractor() + let systemFactory: (SystemWidgetIdentifier) -> any DashboardWidgetViewModel = { widgetId in + widgetId.makeViewModel(snackBarViewModel: snackBarViewModel, coursesInteractor: coursesInteractor) } - let interactor = makeInteractor(widgetViewModelFactory: widgetFactory) - let viewModel = makeViewModel(interactor: interactor, snackBarViewModel: snackBarViewModel) - return LearnerDashboardScreen(viewModel: viewModel) - } - - private static func makeInteractor( - widgetViewModelFactory: @escaping (DashboardWidgetConfig) -> any DashboardWidgetViewModel - ) -> LearnerDashboardInteractor { - LearnerDashboardInteractorLive(widgetViewModelFactory: widgetViewModelFactory) - } - - private static func makeViewModel( - interactor: LearnerDashboardInteractor, - snackBarViewModel: SnackBarViewModel - ) -> LearnerDashboardViewModel { - LearnerDashboardViewModel( + let editableFactory: (DashboardWidgetConfig) -> any DashboardWidgetViewModel = { config in + config.id.makeViewModel(config: config, snackBarViewModel: snackBarViewModel, coursesInteractor: coursesInteractor) + } + let interactor = LearnerDashboardInteractorLive( + systemWidgetFactory: systemFactory, + editableWidgetFactory: editableFactory + ) + let viewModel = LearnerDashboardViewModel( interactor: interactor, + colorInteractor: colorInteractor, snackBarViewModel: snackBarViewModel, environment: .shared ) + return LearnerDashboardScreen(viewModel: viewModel) } } diff --git a/Student/Student/LearnerDashboard/Container/Model/LearnerDashboardInteractor.swift b/Student/Student/LearnerDashboard/Container/Model/LearnerDashboardInteractor.swift index 869c4c7b3a..13b33d6a2b 100644 --- a/Student/Student/LearnerDashboard/Container/Model/LearnerDashboardInteractor.swift +++ b/Student/Student/LearnerDashboard/Container/Model/LearnerDashboardInteractor.swift @@ -26,28 +26,38 @@ protocol LearnerDashboardInteractor { final class LearnerDashboardInteractorLive: LearnerDashboardInteractor { private let userDefaults: SessionDefaults - private let widgetViewModelFactory: (DashboardWidgetConfig) -> any DashboardWidgetViewModel + private let systemWidgetFactory: (SystemWidgetIdentifier) -> any DashboardWidgetViewModel + private let editableWidgetFactory: (DashboardWidgetConfig) -> any DashboardWidgetViewModel init( userDefaults: SessionDefaults = AppEnvironment.shared.userDefaults ?? .fallback, - widgetViewModelFactory: @escaping (DashboardWidgetConfig) -> any DashboardWidgetViewModel + systemWidgetFactory: @escaping (SystemWidgetIdentifier) -> any DashboardWidgetViewModel, + editableWidgetFactory: @escaping (DashboardWidgetConfig) -> any DashboardWidgetViewModel ) { self.userDefaults = userDefaults - self.widgetViewModelFactory = widgetViewModelFactory + self.systemWidgetFactory = systemWidgetFactory + self.editableWidgetFactory = editableWidgetFactory } func loadWidgets() -> AnyPublisher<[any DashboardWidgetViewModel], Never> { Just(()) .subscribe(on: DispatchQueue.global(qos: .userInitiated)) - .map { [userDefaults, widgetViewModelFactory] _ in - let configs: [DashboardWidgetConfig] - if let savedWidgets = userDefaults.learnerDashboardWidgetConfigs { - configs = savedWidgets.filter { $0.isVisible }.sorted() - } else { - configs = LearnerDashboardWidgetAssembly.makeDefaultWidgetConfigs().sorted() + .map { [userDefaults, systemWidgetFactory, editableWidgetFactory] _ in + let systemVMs = SystemWidgetIdentifier.allCases.map { systemWidgetFactory($0) } + + let defaultConfigs = EditableWidgetIdentifier.makeDefaultConfigs() + let savedConfigs = userDefaults.learnerDashboardWidgetConfigs ?? [] + // Merge saved and default configs so that widgets added in future app versions + // always appear even when the user already has a saved configuration. + let mergedConfigs = defaultConfigs.map { defaultConfig in + savedConfigs.first { $0.id == defaultConfig.id } ?? defaultConfig } + let editableConfigs = mergedConfigs + .filter { $0.isVisible } + .sorted() + let editableVMs = editableConfigs.map { editableWidgetFactory($0) } - return configs.map { widgetViewModelFactory($0) } + return systemVMs + editableVMs } .eraseToAnyPublisher() } diff --git a/Student/Student/LearnerDashboard/Container/View/LearnerDashboardScreen.swift b/Student/Student/LearnerDashboard/Container/View/LearnerDashboardScreen.swift index d00dc7a0ea..341f480b24 100644 --- a/Student/Student/LearnerDashboard/Container/View/LearnerDashboardScreen.swift +++ b/Student/Student/LearnerDashboard/Container/View/LearnerDashboardScreen.swift @@ -20,7 +20,6 @@ import Core import SwiftUI struct LearnerDashboardScreen: View { - let settingsViewModel: LearnerDashboardSettingsViewModel @State private var viewModel: LearnerDashboardViewModel @StateObject private var offlineModeViewModel: OfflineModeViewModel @State private var isShowingKebabDialog = false @@ -37,7 +36,6 @@ struct LearnerDashboardScreen: View { ) { _viewModel = State(initialValue: viewModel) _offlineModeViewModel = StateObject(wrappedValue: offlineModeViewModel) - settingsViewModel = .init(defaults: viewModel.environment.userDefaults ?? .fallback) } var body: some View { @@ -54,7 +52,7 @@ struct LearnerDashboardScreen: View { VStack(spacing: screenPadding.rawValue) { ForEach(viewModel.widgets, id: \.id) { widgetViewModel in if widgetViewModel.shouldRenderWidget { - LearnerDashboardWidgetAssembly.makeView(for: widgetViewModel) + widgetViewModel.makeView() } } } @@ -73,6 +71,8 @@ struct LearnerDashboardScreen: View { ) ) } + .tint(viewModel.mainColor) + .animation(.dashboardWidget, value: viewModel.mainColor) .snackBar(viewModel: viewModel.snackBarViewModel) .navigationBarDashboard() .toolbar { @@ -129,8 +129,10 @@ struct LearnerDashboardScreen: View { .popover(isPresented: $isSettingsPresented) { // NavigationStack is needed to add content to the toolbar NavigationStack { - LearnerDashboardSettingsView(viewModel: settingsViewModel) + LearnerDashboardSettingsScreen(viewModel: viewModel.makeSettingsViewModel()) } + .accentColor(.brandPrimary) + .tint(.brandPrimary) } } @@ -154,8 +156,10 @@ struct LearnerDashboardScreen: View { .popover(isPresented: $isSettingsPresented) { // NavigationStack is needed to add content to the toolbar NavigationStack { - LearnerDashboardSettingsView(viewModel: settingsViewModel) + LearnerDashboardSettingsScreen(viewModel: viewModel.makeSettingsViewModel()) } + .accentColor(.brandPrimary) + .tint(.brandPrimary) } } } diff --git a/Student/Student/LearnerDashboard/Container/ViewModel/LearnerDashboardViewModel.swift b/Student/Student/LearnerDashboard/Container/ViewModel/LearnerDashboardViewModel.swift index a0dc597542..b0ca1d3116 100644 --- a/Student/Student/LearnerDashboard/Container/ViewModel/LearnerDashboardViewModel.swift +++ b/Student/Student/LearnerDashboard/Container/ViewModel/LearnerDashboardViewModel.swift @@ -21,12 +21,14 @@ import CombineSchedulers import Core import Foundation import Observation +import SwiftUI import UIKit @Observable final class LearnerDashboardViewModel { private(set) var state: InstUI.ScreenState = .loading private(set) var widgets: [any DashboardWidgetViewModel] = [] + private(set) var mainColor: Color let snackBarViewModel: SnackBarViewModel let screenConfig = InstUI.BaseScreenConfig( @@ -44,26 +46,31 @@ final class LearnerDashboardViewModel { ) private let interactor: LearnerDashboardInteractor + private let colorInteractor: LearnerDashboardColorInteractor private let mainScheduler: AnySchedulerOf private var subscriptions = Set() private let courseSyncInteractor: CourseSyncInteractor - let environment: AppEnvironment + private let environment: AppEnvironment init( interactor: LearnerDashboardInteractor, + colorInteractor: LearnerDashboardColorInteractor, snackBarViewModel: SnackBarViewModel, mainScheduler: AnySchedulerOf = DispatchQueue.main.eraseToAnyScheduler(), courseSyncInteractor: CourseSyncInteractor = CourseSyncDownloaderAssembly.makeInteractor(), environment: AppEnvironment ) { self.interactor = interactor + self.colorInteractor = colorInteractor self.snackBarViewModel = snackBarViewModel self.mainScheduler = mainScheduler self.courseSyncInteractor = courseSyncInteractor self.environment = environment + self.mainColor = colorInteractor.dashboardColor.value loadWidgets() setupOfflineSyncHandlers() + observeColorChanges() } func refresh(ignoreCache: Bool, completion: (() -> Void)? = nil) { @@ -79,8 +86,23 @@ final class LearnerDashboardViewModel { .store(in: &subscriptions) } + func makeSettingsViewModel() -> LearnerDashboardSettingsViewModel { + LearnerDashboardSettingsAssembly.makeViewModel( + env: environment, + colorInteractor: colorInteractor, + onConfigsChanged: { [weak self] in self?.loadWidgets() } + ) + } + // MARK: - Private Methods + private func observeColorChanges() { + colorInteractor.dashboardColor + .receive(on: mainScheduler) + .sink { [weak self] color in self?.mainColor = color } + .store(in: &subscriptions) + } + private func loadWidgets() { interactor.loadWidgets() .receive(on: mainScheduler) diff --git a/Student/Student/LearnerDashboard/Settings/LearnerDashboardSettingsAssembly.swift b/Student/Student/LearnerDashboard/Settings/LearnerDashboardSettingsAssembly.swift new file mode 100644 index 0000000000..4a1da47c13 --- /dev/null +++ b/Student/Student/LearnerDashboard/Settings/LearnerDashboardSettingsAssembly.swift @@ -0,0 +1,55 @@ +// +// 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 SwiftUI + +enum LearnerDashboardSettingsAssembly { + + static func makeViewModel( + env: AppEnvironment = .shared, + colorInteractor: LearnerDashboardColorInteractor, + onConfigsChanged: @escaping () -> Void + ) -> LearnerDashboardSettingsViewModel { + let defaults = env.userDefaults ?? .fallback + let username = env.currentSession?.userName ?? "" + let defaultConfigs = EditableWidgetIdentifier.makeDefaultConfigs() + let savedConfigs = defaults.learnerDashboardWidgetConfigs ?? [] + let configs = defaultConfigs.map { defaultConfig in + savedConfigs.first { $0.id == defaultConfig.id } ?? defaultConfig + } + let subSettingsViews = EditableWidgetIdentifier.allCases.reduce(into: [EditableWidgetIdentifier: AnyView]()) { result, id in + result[id] = id.makeSubSettingsView(env: env) + } + + let courseSettingsViewModel = LearnerDashboardSettingsWidgetsSectionViewModel( + userDefaults: defaults, + configs: configs, + username: username, + subSettingsViews: subSettingsViews, + onConfigsChanged: onConfigsChanged + ) + + let viewModel = LearnerDashboardSettingsViewModel( + defaults: defaults, + colorInteractor: colorInteractor, + courseSettingsViewModel: courseSettingsViewModel + ) + return viewModel + } +} diff --git a/Student/Student/LearnerDashboard/Settings/View/LearnerDashboardColorSelectorView.swift b/Student/Student/LearnerDashboard/Settings/View/LearnerDashboardColorSelectorView.swift index 3b6c2d8b4c..d5f85afdf4 100644 --- a/Student/Student/LearnerDashboard/Settings/View/LearnerDashboardColorSelectorView.swift +++ b/Student/Student/LearnerDashboard/Settings/View/LearnerDashboardColorSelectorView.swift @@ -24,21 +24,25 @@ struct LearnerDashboardColorSelectorView: View { @Environment(\.dynamicTypeSize) var dynamicTypeSize @Binding var selectedColor: Color + let colors: [CourseColorData] let whiteColor = Color.backgroundLightest.variantForLightMode - let colors: [ColorData] + + init(selectedColor: Binding, colors: [CourseColorData]) { + self._selectedColor = selectedColor + self.colors = colors + } var body: some View { DisclosureGroup { - // Need to implement our own HFlow, this uses fixed spacing, we need flexible - HorizonUI.HFlow { + FlexibleGrid(minimumSpacing: 16, lineSpacing: 16) { ForEach(colors) { colorData in Button { - selectedColor = colorData.color + selectedColor = colorData.color.asColor } label: { - let isSelected = colorData.color == selectedColor + let isSelected = colorData.color.asColor == selectedColor Circle() - .fill(colorData.color) + .fill(colorData.color.asColor) .stroke(.borderLight, style: .init(lineWidth: 0.5)) .overlay { if isSelected { @@ -55,7 +59,7 @@ struct LearnerDashboardColorSelectorView: View { .scaledFrame(size: 40) .shadow(color: .black.opacity(0.08), radius: 2, y: 2) .shadow(color: .black.opacity(0.16), radius: 2, y: 1) - .accessibilityLabel(colorData.description) + .accessibilityLabel(colorData.name) .accessibilityAddTraits(isSelected ? .isSelected : []) } } @@ -78,36 +82,9 @@ struct LearnerDashboardColorSelectorView: View { } .disclosureGroupStyle(PlainDisclosureGroupStyle()) } - - init(selectedColor: Binding) { - self._selectedColor = selectedColor - - let courseColors = CourseColorsInteractorLive.colors.map { - ColorData(color: $0.key.asColor, description: $0.value) - } - let additionalColors = [ - ColorData( - color: .backgroundLightest.variantForLightMode, - description: String(localized: "White", bundle: .core, comment: "This is a name of a color.") - ), - ColorData( - color: .backgroundLightest.variantForDarkMode, - description: String(localized: "Black", bundle: .core, comment: "This is a name of a color.") - ) - ] - - colors = courseColors + additionalColors - } - - struct ColorData: Identifiable { - let color: Color - let description: String - - var id: String { description } - } } -struct PlainDisclosureGroupStyle: DisclosureGroupStyle { +private struct PlainDisclosureGroupStyle: DisclosureGroupStyle { func makeBody(configuration: Configuration) -> some View { VStack(spacing: 0) { Button { @@ -118,12 +95,7 @@ struct PlainDisclosureGroupStyle: DisclosureGroupStyle { HStack { configuration.label - Image.chevronDown - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 16) - .foregroundStyle(.textDark) - .rotationEffect(configuration.isExpanded ? .degrees(180) : .degrees(0)) + InstUI.CollapseButtonIcon(isExpanded: configuration.$isExpanded) } } @@ -139,7 +111,7 @@ struct PlainDisclosureGroupStyle: DisclosureGroupStyle { @Previewable @State var selectedColor: Color = .course1 VStack { - LearnerDashboardColorSelectorView(selectedColor: $selectedColor) + LearnerDashboardColorSelectorView(selectedColor: $selectedColor, colors: LearnerDashboardColorInteractorLive(defaults: .fallback).availableColors) .padding(.horizontal) Spacer() diff --git a/Student/Student/LearnerDashboard/Settings/View/LearnerDashboardCourseSettingsView.swift b/Student/Student/LearnerDashboard/Settings/View/LearnerDashboardCourseSettingsView.swift deleted file mode 100644 index 9306b6da48..0000000000 --- a/Student/Student/LearnerDashboard/Settings/View/LearnerDashboardCourseSettingsView.swift +++ /dev/null @@ -1,192 +0,0 @@ -// -// 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 SwiftUI -import Core -import HorizonUI - -struct LearnerDashboardCourseSettingsView: View { - @State var viewModel: LearnerDashboardCourseSettingsViewModel - @Environment(\.dynamicTypeSize) var dynamicTypeSize - - var body: some View { - VStack(spacing: 8) { - Text("Widgets", bundle: .core) - .foregroundStyle(.textDarkest) - .font(.regular14, lineHeight: .fit) - .frame(maxWidth: .infinity, alignment: .leading) - .accessibilityAddTraits(.isHeader) - - VStack(spacing: 16) { - ForEach(viewModel.configs) { config in - settingCard(config: config) - } - } - } - } - - @ViewBuilder - private func settingCard(config: Config) -> some View { - let binding = Binding { - config.isVisible - } set: { - viewModel.toggleVisibility(of: config, to: $0) - } - - VStack(spacing: 0) { - HStack(spacing: 8) { - buttons(config: config) - .tint(.accentColor) - - InstUI.Toggle(isOn: binding) { - Text(config.id.title(username: viewModel.username)) - .font(.semibold16, lineHeight: .fit) - .frame(maxWidth: .infinity, alignment: .leading) - } - .accessibilityLabel(String( - localized: "\(config.id.title(username: viewModel.username)) widget visibility", - bundle: .student - )) - } - .padding(.top, 12) - .padding(.bottom, 14) - - InstUI.Divider() - .padding(.horizontal, -16) - - // Example data - ForEach(0..<2) { index in - InstUI.Toggle(isOn: .constant(true)) { - Text("Example setting \(index)", bundle: .core) - .font(.semibold16, lineHeight: .fit) - } - .padding(.top, 12) - .padding(.bottom, 14) - - if index != 1 { - InstUI.Divider() - } - } - } - .padding(.horizontal, 16) - .elevation( - .cardLarge, - background: .backgroundLightest, - shadowColor: config.isVisible ? .black : .clear - ) - } - - @ViewBuilder - private func buttons(config: Config) -> some View { - let isMoveDownDisabled = viewModel.isMoveDownDisabled(of: config) - let isMoveUpDisabled = viewModel.isMoveUpDisabled(of: config) - let allButtonsDisabled = isMoveDownDisabled && isMoveUpDisabled - - HStack(spacing: 4) { - Button { - viewModel.moveUp(config) - } label: { - Image.chevronDown - .resizable() - .scaledFrame(size: 24) - .rotationEffect(.degrees(180)) - } - .disabled(isMoveUpDisabled) - .accessibilityLabel(String( - localized: "Move \(config.id.title(username: viewModel.username)) widget up", - bundle: .student - )) - - InstUI.Divider() - .padding(.vertical, 4) - - Button { - viewModel.moveDown(config) - } label: { - Image.chevronDown - .resizable() - .scaledFrame(size: 24) - } - .disabled(isMoveDownDisabled) - .accessibilityLabel(String( - localized: "Move \(config.id.title(username: viewModel.username)) widget down", - bundle: .student - )) - } - .padding(.horizontal, 8) - .fixedSize(horizontal: false, vertical: true) - .elevation( - .cardSmall, - background: allButtonsDisabled ? .backgroundLight : .backgroundLightest, - shadowColor: allButtonsDisabled ? .clear : .black - ) - } - - @ViewBuilder - private var disabledButtons: some View { - HStack(spacing: 4) { - Image.chevronDown - .resizable() - .scaledFrame(size: 24) - .foregroundStyle(.disabledGray) - .rotationEffect(.degrees(180)) - - InstUI.Divider() - .padding(.vertical, 4) - - Image.chevronDown - .resizable() - .scaledFrame(size: 24) - .foregroundStyle(.disabledGray) - } - .padding(.horizontal, 8) - .fixedSize(horizontal: false, vertical: true) - .background( - .backgroundLight, - in: RoundedRectangle(cornerRadius: InstUI.Styles.Elevation.Shape.cardSmall.cornerRadius) - ) - .accessibilityHidden(true) - } - - init() { - self.viewModel = .init(configs: .preview) - } -} - -extension LearnerDashboardCourseSettingsView { - typealias Config = DashboardWidgetConfig -} - -extension Array where Element == DashboardWidgetConfig { - static let preview: [DashboardWidgetConfig] = [ - .init(id: .helloWidget, order: 0, isVisible: true), - .init(id: .coursesAndGroups, order: 1, isVisible: false), - .init(id: .conferences, order: 2, isVisible: true) - ] -} - -#Preview { - VStack { - LearnerDashboardCourseSettingsView() - - Spacer() - } - .padding() - .background(.backgroundLight) - .tint(.yellow) -} diff --git a/Student/Student/LearnerDashboard/Settings/View/LearnerDashboardSettingsView.swift b/Student/Student/LearnerDashboard/Settings/View/LearnerDashboardSettingsScreen.swift similarity index 52% rename from Student/Student/LearnerDashboard/Settings/View/LearnerDashboardSettingsView.swift rename to Student/Student/LearnerDashboard/Settings/View/LearnerDashboardSettingsScreen.swift index 9afc24365e..169b8c3d16 100644 --- a/Student/Student/LearnerDashboard/Settings/View/LearnerDashboardSettingsView.swift +++ b/Student/Student/LearnerDashboard/Settings/View/LearnerDashboardSettingsScreen.swift @@ -19,11 +19,11 @@ import Core import SwiftUI -struct LearnerDashboardSettingsView: View { - @State private var viewModel: LearnerDashboardSettingsViewModel +struct LearnerDashboardSettingsScreen: View { @Environment(\.viewController) private var viewController - @State private var showSwitchAlert = false @Environment(\.dismiss) private var dismiss + @State private var viewModel: LearnerDashboardSettingsViewModel + @State private var showSwitchAlert = false init(viewModel: LearnerDashboardSettingsViewModel) { _viewModel = State(initialValue: viewModel) @@ -31,40 +31,15 @@ struct LearnerDashboardSettingsView: View { var body: some View { ScrollView { - VStack(alignment: .leading, spacing: 0) { - toggle( - text: Text("New Mobile Dashboard", bundle: .student), - isOn: Binding( - get: { viewModel.useNewLearnerDashboard }, - set: { _ in - showSwitchAlert = true - } - ) - ) - .accessibilityIdentifier("DashboardSettings.newDashboardToggle") - - Spacer() - .frame(height: 16) - - LearnerDashboardColorSelectorView(selectedColor: $viewModel.mainColor) - - InstUI.Divider() - - Spacer() - .frame(height: 16) - - LearnerDashboardCourseSettingsView() - - Spacer() - .frame(height: 16) - - feedback - - Spacer() + VStack(alignment: .leading, spacing: 16) { + newDashboardToggle + dashboardColorSelector + widgetsSection + feedbackSection } .paddingStyle(.horizontal, .standard) + .paddingStyle(.bottom, .standard) } - .tint(viewModel.mainColor) .background(Color.backgroundLight.ignoresSafeArea()) .navigationTitle(String(localized: "Customize Dashboard", bundle: .student), style: .modal) .navigationBarTitleDisplayMode(.inline) @@ -72,38 +47,44 @@ struct LearnerDashboardSettingsView: View { switchDashboardAlert } .toolbar { - let label = Text("Done", bundle: .core) - if #available(iOS 26, *) { - Button(action: dismiss.callAsFunction) { - label - } - .buttonStyle(.borderedProminent) - } else { - Button(action: dismiss.callAsFunction) { - label + ToolbarItem(placement: .topBarTrailing) { + Button(action: { dismiss() }) { + Text("Done", bundle: .student) + .font(.scaledRestrictly(.semibold16)) + .foregroundStyle(.brandPrimary) } + .identifier("DashboardSettings.doneButton") } } } - private func toggle(text: Text, isOn: Binding) -> some View { - InstUI.Toggle(isOn: isOn) { - text + private var newDashboardToggle: some View { + let isOnBinding = Binding( + get: { viewModel.useNewLearnerDashboard }, + set: { _ in showSwitchAlert = true } + ) + return InstUI.Toggle(isOn: isOnBinding) { + Text("New Mobile Dashboard", bundle: .student) .font(.semibold16) .foregroundStyle(Color.textDarkest) } .padding(.vertical, 8) - .testID("DashboardSettings.Switch.NewDashboard", info: ["selected": isOn.wrappedValue]) + .accessibilityIdentifier("DashboardSettings.newDashboardToggle") } - private var switchDashboardAlert: Alert { - DashboardSwitchAlert.makeAlert(isEnabling: false) { - viewModel.switchToClassicDashboard(viewController: viewController.value) + private var dashboardColorSelector: some View { + VStack(alignment: .leading, spacing: 0) { + LearnerDashboardColorSelectorView(selectedColor: $viewModel.mainColor, colors: viewModel.colors) + InstUI.Divider() } } + private var widgetsSection: some View { + LearnerDashboardSettingsWidgetsSectionView(viewModel: viewModel.courseSettingsViewModel) + } + @ViewBuilder - private var feedback: some View { + private var feedbackSection: some View { VStack(spacing: 16) { Text("What do you think of the new dashboard?", bundle: .student) .font(.regular14, lineHeight: .fit) @@ -112,41 +93,57 @@ struct LearnerDashboardSettingsView: View { Button { viewModel.letUsKnow(from: viewController.value) } label: { - HStack(spacing: 6) { - Text("Let us know!", bundle: .student) - .font(.regular14, lineHeight: .normal) - - Image.externalLinkLine - } - .padding(.vertical, 4) - .padding(.leading, 12) - .padding(.trailing, 8) - .foregroundStyle(.tint) + InstUI.PillContent( + title: String(localized: "Let us know!", bundle: .student), + trailingIcon: .externalLinkLine, + size: .height30 + ) } .buttonStyle(.pillTintOutlined) + .tint(viewModel.mainColor) } .frame(maxWidth: .infinity) } + + private var switchDashboardAlert: Alert { + DashboardSwitchAlert.makeAlert(isEnabling: false) { + viewModel.switchToClassicDashboard(viewController: viewController.value) + } + } } #if DEBUG #Preview("New Dashboard Enabled") { - LearnerDashboardSettingsView( + LearnerDashboardSettingsScreen( viewModel: { var defaults = SessionDefaults.fallback defaults.preferNewLearnerDashboard = true - return LearnerDashboardSettingsViewModel(defaults: defaults) + let configs = EditableWidgetIdentifier.makeDefaultConfigs() + let courseSettingsVM = LearnerDashboardSettingsWidgetsSectionViewModel( + userDefaults: defaults, + configs: configs, + username: "Riley", + onConfigsChanged: {} + ) + return LearnerDashboardSettingsViewModel(defaults: defaults, colorInteractor: LearnerDashboardColorInteractorLive(defaults: defaults), courseSettingsViewModel: courseSettingsVM) }() ) } #Preview("New Dashboard Disabled") { - LearnerDashboardSettingsView( + LearnerDashboardSettingsScreen( viewModel: { var defaults = SessionDefaults.fallback defaults.preferNewLearnerDashboard = false - return LearnerDashboardSettingsViewModel(defaults: defaults) + let configs = EditableWidgetIdentifier.makeDefaultConfigs() + let courseSettingsVM = LearnerDashboardSettingsWidgetsSectionViewModel( + userDefaults: defaults, + configs: configs, + username: "Riley", + onConfigsChanged: {} + ) + return LearnerDashboardSettingsViewModel(defaults: defaults, colorInteractor: LearnerDashboardColorInteractorLive(defaults: defaults), courseSettingsViewModel: courseSettingsVM) }() ) } diff --git a/Student/Student/LearnerDashboard/Settings/View/LearnerDashboardSettingsWidgetCardView.swift b/Student/Student/LearnerDashboard/Settings/View/LearnerDashboardSettingsWidgetCardView.swift new file mode 100644 index 0000000000..8c5d06542a --- /dev/null +++ b/Student/Student/LearnerDashboard/Settings/View/LearnerDashboardSettingsWidgetCardView.swift @@ -0,0 +1,107 @@ +// +// 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 SwiftUI +import Core + +struct LearnerDashboardSettingsWidgetCardView: View { + let config: DashboardWidgetConfig + let username: String + @Binding var isVisible: Bool + let isMoveUpDisabled: Bool + let isMoveDownDisabled: Bool + let onMoveUp: () -> Void + let onMoveDown: () -> Void + let subSettingsView: AnyView? + + var body: some View { + VStack(spacing: 0) { + HStack(spacing: 8) { + buttons + + InstUI.Toggle(isOn: $isVisible) { + Text(config.id.settingsTitle(username: username)) + .font(.semibold16, lineHeight: .fit) + .frame(maxWidth: .infinity, alignment: .leading) + } + .accessibilityLabel(String( + localized: "\(config.id.settingsTitle(username: username)) widget visibility", + bundle: .student + )) + } + .padding(.top, 12) + .padding(.bottom, 14) + .accessibilityElement(children: .combine) + + if let subSettings = subSettingsView { + InstUI.Divider() + .padding(.horizontal, -16) + subSettings + .padding(.horizontal, -16) + } + } + .paddingStyle(.horizontal, .standard) + .elevation( + .cardLarge, + background: .backgroundLightest, + isShadowVisible: isVisible + ) + } + + @ViewBuilder + private var buttons: some View { + let allButtonsDisabled = isMoveDownDisabled && isMoveUpDisabled + + HStack(spacing: 4) { + Button { + onMoveUp() + } label: { + Image.chevronDown + .scaledIcon() + .rotationEffect(.degrees(180)) + } + .disabled(isMoveUpDisabled) + .accessibilityLabel(String( + localized: "Move \(config.id.settingsTitle(username: username)) widget up", + bundle: .student + )) + + InstUI.Divider() + .padding(.vertical, 4) + + Button { + onMoveDown() + } label: { + Image.chevronDown + .scaledIcon() + } + .disabled(isMoveDownDisabled) + .accessibilityLabel(String( + localized: "Move \(config.id.settingsTitle(username: username)) widget down", + bundle: .student + )) + } + .padding(.horizontal, 8) + .fixedSize(horizontal: false, vertical: true) + .elevation( + .cardSmall, + background: allButtonsDisabled ? .backgroundLight : .backgroundLightest, + isShadowVisible: !allButtonsDisabled + ) + } +} diff --git a/Student/Student/LearnerDashboard/Settings/View/LearnerDashboardSettingsWidgetsSectionView.swift b/Student/Student/LearnerDashboard/Settings/View/LearnerDashboardSettingsWidgetsSectionView.swift new file mode 100644 index 0000000000..f3b4215f43 --- /dev/null +++ b/Student/Student/LearnerDashboard/Settings/View/LearnerDashboardSettingsWidgetsSectionView.swift @@ -0,0 +1,82 @@ +// +// 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 SwiftUI +import Core +import HorizonUI + +struct LearnerDashboardSettingsWidgetsSectionView: View { + @State var viewModel: LearnerDashboardSettingsWidgetsSectionViewModel + @Environment(\.dynamicTypeSize) var dynamicTypeSize + + var body: some View { + VStack(spacing: 8) { + Text("Widgets", bundle: .student) + .foregroundStyle(.textDarkest) + .font(.regular14, lineHeight: .fit) + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityAddTraits(.isHeader) + + VStack(spacing: 16) { + ForEach(viewModel.configs) { config in + LearnerDashboardSettingsWidgetCardView( + config: config, + username: viewModel.username, + isVisible: Binding { + config.isVisible + } set: { + viewModel.toggleVisibility(of: config, to: $0) + }, + isMoveUpDisabled: viewModel.isMoveUpDisabled(of: config), + isMoveDownDisabled: viewModel.isMoveDownDisabled(of: config), + onMoveUp: { viewModel.moveUp(config) }, + onMoveDown: { viewModel.moveDown(config) }, + subSettingsView: viewModel.subSettingsViews[config.id] + ) + } + } + } + } + + init(viewModel: LearnerDashboardSettingsWidgetsSectionViewModel) { + _viewModel = State(initialValue: viewModel) + } +} + +#Preview { + VStack { + LearnerDashboardSettingsWidgetsSectionView(viewModel: { + let defaults = SessionDefaults.fallback + let configs: [DashboardWidgetConfig] = [ + .init(id: .helloWidget, order: 0, isVisible: true), + .init(id: .coursesAndGroups, order: 1, isVisible: false) + ] + return LearnerDashboardSettingsWidgetsSectionViewModel( + userDefaults: defaults, + configs: configs, + username: "Riley", + onConfigsChanged: {} + ) + }()) + + Spacer() + } + .padding() + .background(.backgroundLight) + .tint(.yellow) +} diff --git a/Student/Student/LearnerDashboard/Settings/ViewModel/LearnerDashboardCourseSettingsViewModel.swift b/Student/Student/LearnerDashboard/Settings/ViewModel/LearnerDashboardCourseSettingsViewModel.swift deleted file mode 100644 index 9c533ff3ba..0000000000 --- a/Student/Student/LearnerDashboard/Settings/ViewModel/LearnerDashboardCourseSettingsViewModel.swift +++ /dev/null @@ -1,84 +0,0 @@ -// -// 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 Observation -import SwiftUI - -@Observable -final class LearnerDashboardCourseSettingsViewModel { - var configs: [Config] - - // Example username for preview and accessibility labels - let username = "Riley" - - init(configs: [Config]) { - let visibleConfigs = configs.filter { $0.isVisible }.sorted() - let hiddenConfigs = configs.filter { !$0.isVisible }.sorted() - - self.configs = visibleConfigs + hiddenConfigs - } - - func toggleVisibility(of config: Config, to isVisible: Bool) { - guard let index = configs.firstIndex(of: config) else { return } - - configs[index].isVisible = isVisible - - let visibleConfigs = configs.filter { $0.isVisible } - let hiddenConfigs = configs.filter { !$0.isVisible } - - withAnimation { - configs = visibleConfigs + hiddenConfigs - } - } - - func moveUp(_ config: Config) { - guard let index = configs.firstIndex(of: config), index > configs.startIndex else { return } - let previousConfigIndex = configs.index(before: index) - - withAnimation { - configs.swapAt(index, previousConfigIndex) - } - } - - func isMoveUpDisabled(of config: Config) -> Bool { - guard let index = configs.firstIndex(of: config), index > configs.startIndex else { return true } - - return !config.isVisible - } - - func moveDown(_ config: Config) { - guard let index = configs.firstIndex(of: config), index < configs.endIndex - 1 else { return } - let nextConfigIndex = configs.index(after: index) - - withAnimation { - configs.swapAt(index, nextConfigIndex) - } - } - - func isMoveDownDisabled(of config: Config) -> Bool { - guard let index = configs.firstIndex(of: config), index < configs.endIndex - 1 else { return true } - let nextConfigIndex = configs.index(after: index) - - return !configs[nextConfigIndex].isVisible || !config.isVisible - } -} - -extension LearnerDashboardCourseSettingsViewModel { - typealias Config = DashboardWidgetConfig -} diff --git a/Student/Student/LearnerDashboard/Settings/ViewModel/LearnerDashboardSettingsViewModel.swift b/Student/Student/LearnerDashboard/Settings/ViewModel/LearnerDashboardSettingsViewModel.swift index 426983e8f5..6257e319c2 100644 --- a/Student/Student/LearnerDashboard/Settings/ViewModel/LearnerDashboardSettingsViewModel.swift +++ b/Student/Student/LearnerDashboard/Settings/ViewModel/LearnerDashboardSettingsViewModel.swift @@ -26,18 +26,43 @@ import UIKit @Observable final class LearnerDashboardSettingsViewModel { var useNewLearnerDashboard: Bool - var mainColor: Color = .course1 + var mainColor: Color { + didSet { colorInteractor.selectColor(mainColor) } + } + var colors: [CourseColorData] { colorInteractor.availableColors } + let courseSettingsViewModel: LearnerDashboardSettingsWidgetsSectionViewModel private var defaults: SessionDefaults private let environment: AppEnvironment + private let colorInteractor: LearnerDashboardColorInteractor init( defaults: SessionDefaults, + colorInteractor: LearnerDashboardColorInteractor, + courseSettingsViewModel: LearnerDashboardSettingsWidgetsSectionViewModel, environment: AppEnvironment = .shared ) { self.defaults = defaults self.environment = environment + self.colorInteractor = colorInteractor + self.courseSettingsViewModel = courseSettingsViewModel self.useNewLearnerDashboard = defaults.preferNewLearnerDashboard + self.mainColor = colorInteractor.dashboardColor.value + } + + /// Switches from the new learner dashboard back to the classic dashboard. + /// Sets both the preference flag and triggers the feedback flow. + /// Note: While `preferNewLearnerDashboard` is also managed by DashboardSettingsInteractorLive, + /// the feedback flag (`shouldShowDashboardFeedback`) is only set here to ensure it's + /// triggered specifically when users actively switch away from the new dashboard. + func switchToClassicDashboard(viewController: UIViewController) { + defaults.preferNewLearnerDashboard = false + defaults.shouldShowDashboardFeedback = true + useNewLearnerDashboard = false + + viewController.dismiss(animated: true) { + NotificationCenter.default.post(name: .dashboardPreferenceChanged, object: nil) + } } func letUsKnow(from viewController: UIViewController) { @@ -58,19 +83,4 @@ final class LearnerDashboardSettingsViewModel { options: .modal(.formSheet, embedInNav: true, addDoneButton: true) ) } - - /// Switches from the new learner dashboard back to the classic dashboard. - /// Sets both the preference flag and triggers the feedback flow. - /// Note: While `preferNewLearnerDashboard` is also managed by DashboardSettingsInteractorLive, - /// the feedback flag (`shouldShowDashboardFeedback`) is only set here to ensure it's - /// triggered specifically when users actively switch away from the new dashboard. - func switchToClassicDashboard(viewController: UIViewController) { - defaults.preferNewLearnerDashboard = false - defaults.shouldShowDashboardFeedback = true - useNewLearnerDashboard = false - - viewController.dismiss(animated: true) { - NotificationCenter.default.post(name: .dashboardPreferenceChanged, object: nil) - } - } } diff --git a/Student/Student/LearnerDashboard/Settings/ViewModel/LearnerDashboardSettingsWidgetsSectionViewModel.swift b/Student/Student/LearnerDashboard/Settings/ViewModel/LearnerDashboardSettingsWidgetsSectionViewModel.swift new file mode 100644 index 0000000000..aa634c915f --- /dev/null +++ b/Student/Student/LearnerDashboard/Settings/ViewModel/LearnerDashboardSettingsWidgetsSectionViewModel.swift @@ -0,0 +1,108 @@ +// +// 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 Foundation +import Observation +import SwiftUI + +@Observable +final class LearnerDashboardSettingsWidgetsSectionViewModel { + var visibleConfigs: [DashboardWidgetConfig] + var hiddenConfigs: [DashboardWidgetConfig] + let username: String + let subSettingsViews: [EditableWidgetIdentifier: AnyView] + + var configs: [DashboardWidgetConfig] { visibleConfigs + hiddenConfigs } + + private var userDefaults: SessionDefaults + private let onConfigsChanged: () -> Void + + init( + userDefaults: SessionDefaults, + configs: [DashboardWidgetConfig], + username: String, + subSettingsViews: [EditableWidgetIdentifier: AnyView] = [:], + onConfigsChanged: @escaping () -> Void + ) { + self.userDefaults = userDefaults + self.username = username + self.subSettingsViews = subSettingsViews + self.onConfigsChanged = onConfigsChanged + visibleConfigs = configs.filter { $0.isVisible }.sorted() + hiddenConfigs = configs.filter { !$0.isVisible }.sorted() + } + + func toggleVisibility(of config: DashboardWidgetConfig, to isVisible: Bool) { + if isVisible { + guard let index = hiddenConfigs.firstIndex(of: config) else { return } + var toggledConfig = hiddenConfigs.remove(at: index) + toggledConfig.isVisible = isVisible + visibleConfigs.append(toggledConfig) + } else { + guard let index = visibleConfigs.firstIndex(of: config) else { return } + var toggledConfig = visibleConfigs.remove(at: index) + toggledConfig.isVisible = isVisible + hiddenConfigs.insert(toggledConfig, at: 0) + } + saveAndNotify() + } + + func moveUp(_ config: DashboardWidgetConfig) { + guard let index = visibleConfigs.firstIndex(of: config), index > visibleConfigs.startIndex else { return } + let previousConfigIndex = visibleConfigs.index(before: index) + withAnimation(.dashboardWidget) { + visibleConfigs.swapAt(index, previousConfigIndex) + } + saveAndNotify() + } + + func isMoveUpDisabled(of config: DashboardWidgetConfig) -> Bool { + guard let index = visibleConfigs.firstIndex(of: config) else { return true } + return index == visibleConfigs.startIndex + } + + func moveDown(_ config: DashboardWidgetConfig) { + guard let index = visibleConfigs.firstIndex(of: config), index < visibleConfigs.endIndex - 1 else { return } + let nextConfigIndex = visibleConfigs.index(after: index) + withAnimation(.dashboardWidget) { + visibleConfigs.swapAt(index, nextConfigIndex) + } + saveAndNotify() + } + + func isMoveDownDisabled(of config: DashboardWidgetConfig) -> Bool { + guard let index = visibleConfigs.firstIndex(of: config) else { return true } + return index == visibleConfigs.endIndex - 1 + } + + private func saveAndNotify() { + let updatedVisible = visibleConfigs.enumerated().map { (index, config) -> DashboardWidgetConfig in + var updated = config + updated.order = index + return updated + } + let updatedHidden = hiddenConfigs.enumerated().map { (index, config) -> DashboardWidgetConfig in + var updated = config + updated.order = visibleConfigs.count + index + return updated + } + userDefaults.learnerDashboardWidgetConfigs = updatedVisible + updatedHidden + onConfigsChanged() + } +} diff --git a/Student/Student/LearnerDashboard/Widgets/Common/Model/DashboardWidgetConfig.swift b/Student/Student/LearnerDashboard/Widgets/Common/Model/DashboardWidgetConfig.swift index f73c767e64..44f322bd52 100644 --- a/Student/Student/LearnerDashboard/Widgets/Common/Model/DashboardWidgetConfig.swift +++ b/Student/Student/LearnerDashboard/Widgets/Common/Model/DashboardWidgetConfig.swift @@ -16,8 +16,10 @@ // along with this program. If not, see . // +// Persisted user preference for a single editable dashboard widget. +// Configs are stored as a list in SessionDefaults and loaded on dashboard startup. struct DashboardWidgetConfig: Codable, Comparable, Identifiable { - let id: DashboardWidgetIdentifier + let id: EditableWidgetIdentifier var order: Int var isVisible: Bool /// Widget-specific settings encoded into a JSON to be persisted. @@ -32,7 +34,7 @@ struct DashboardWidgetConfig: Codable, Comparable, Identifiable { extension DashboardWidgetConfig { static func make( - id: DashboardWidgetIdentifier = .helloWidget, + id: EditableWidgetIdentifier = .helloWidget, order: Int = 0, isVisible: Bool = true, settings: String? = nil diff --git a/Student/Student/LearnerDashboard/Widgets/Common/Model/DashboardWidgetIdentifier.swift b/Student/Student/LearnerDashboard/Widgets/Common/Model/DashboardWidgetIdentifier.swift new file mode 100644 index 0000000000..12c54afaa7 --- /dev/null +++ b/Student/Student/LearnerDashboard/Widgets/Common/Model/DashboardWidgetIdentifier.swift @@ -0,0 +1,117 @@ +// +// 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 Foundation +import SwiftUI + +// Widgets that appear at the top of the dashboard in declaration order when they have content to show. +// The user cannot reorder or hide these. +enum SystemWidgetIdentifier: String, CaseIterable { + case offlineSyncProgress + case fileUploadProgress + case courseInvitations + case globalAnnouncements + case conferences + + func makeViewModel( + snackBarViewModel: SnackBarViewModel, + coursesInteractor: CoursesInteractor + ) -> any DashboardWidgetViewModel { + switch self { + case .offlineSyncProgress: + OfflineSyncProgressWidgetViewModel( + dashboardViewModel: DashboardOfflineSyncProgressCardAssembly.makeViewModel() + ) + case .fileUploadProgress: + FileUploadProgressWidgetViewModel( + router: AppEnvironment.shared.router, + listViewModel: FileUploadNotificationCardListViewModel() + ) + case .courseInvitations: + CourseInvitationsWidgetViewModel( + interactor: coursesInteractor, + snackBarViewModel: snackBarViewModel + ) + case .globalAnnouncements: + GlobalAnnouncementsWidgetViewModel( + interactor: .live(env: .shared) + ) + case .conferences: + ConferencesWidgetViewModel( + interactor: .live(coursesInteractor: coursesInteractor, env: .shared), + snackBarViewModel: snackBarViewModel + ) + } + } +} + +// User-configurable widgets whose visibility and order can be changed in dashboard settings. +// The declaration order defines the default display order when no saved configuration exists. +enum EditableWidgetIdentifier: String, Codable, CaseIterable { + case helloWidget + case coursesAndGroups + case weeklySummary + + func settingsTitle(username: String) -> String { + switch self { + case .helloWidget: String(localized: "Hello \(username)", bundle: .student) + case .coursesAndGroups: String(localized: "Courses & Groups", bundle: .student) + case .weeklySummary: String(localized: "Weekly Summary", bundle: .student) + } + } + + func makeViewModel( + config: DashboardWidgetConfig, + snackBarViewModel: SnackBarViewModel, + coursesInteractor: CoursesInteractor + ) -> any DashboardWidgetViewModel { + switch self { + case .helloWidget: + HelloWidgetViewModel( + config: config, + interactor: .live(), + dayPeriodProvider: .init() + ) + case .coursesAndGroups: + CoursesAndGroupsWidgetViewModel( + config: config, + interactor: .live(coursesInteractor: coursesInteractor, env: .shared) + ) + case .weeklySummary: + WeeklySummaryWidgetViewModel(config: config) + } + } + + func makeSubSettingsView(env: AppEnvironment) -> AnyView? { + switch self { + case .helloWidget, .weeklySummary: + nil + case .coursesAndGroups: + AnyView(CoursesAndGroupsWidgetSettingsView(viewModel: CoursesAndGroupsWidgetSettingsViewModel(env: env))) + } + } +} + +extension EditableWidgetIdentifier { + static func makeDefaultConfigs() -> [DashboardWidgetConfig] { + allCases.enumerated().map { index, id in + DashboardWidgetConfig(id: id, order: index, isVisible: true) + } + } +} diff --git a/Student/Student/LearnerDashboard/Widgets/Common/Model/DashboardWidgetViewModel.swift b/Student/Student/LearnerDashboard/Widgets/Common/Model/DashboardWidgetViewModel.swift index c9276e8e43..62aee9c486 100644 --- a/Student/Student/LearnerDashboard/Widgets/Common/Model/DashboardWidgetViewModel.swift +++ b/Student/Student/LearnerDashboard/Widgets/Common/Model/DashboardWidgetViewModel.swift @@ -20,13 +20,8 @@ import Combine import Core import SwiftUI -protocol DashboardWidgetViewModel: AnyObject, Identifiable where ID == DashboardWidgetIdentifier { - associatedtype ViewType: View - - var id: DashboardWidgetIdentifier { get } - - /// User configurable widget settings. - var config: DashboardWidgetConfig { get } +protocol DashboardWidgetViewModel: AnyObject { + var id: String { get } /// The state helps the dashboard screen to decide if the empty state should be shown or not. var state: InstUI.ScreenState { get } @@ -44,17 +39,13 @@ protocol DashboardWidgetViewModel: AnyObject, Identifiable where ID == Dashboard /// Default implementation returns state. var layoutIdentifier: [AnyHashable] { get } - func makeView() -> ViewType + func makeView() -> AnyView /// When pull to refresh is performed on the dashboard each widget is asked to refresh their content. func refresh(ignoreCache: Bool) -> AnyPublisher } extension DashboardWidgetViewModel { - var id: DashboardWidgetIdentifier { - config.id - } - var layoutIdentifier: [AnyHashable] { [state] } diff --git a/Student/Student/LearnerDashboard/Widgets/Common/Model/SessionDefaults+DashboardWidgetConfig.swift b/Student/Student/LearnerDashboard/Widgets/Common/Model/SessionDefaults+DashboardWidgetConfig.swift index a9dce1f4aa..25f27c06a2 100644 --- a/Student/Student/LearnerDashboard/Widgets/Common/Model/SessionDefaults+DashboardWidgetConfig.swift +++ b/Student/Student/LearnerDashboard/Widgets/Common/Model/SessionDefaults+DashboardWidgetConfig.swift @@ -20,20 +20,18 @@ import Core import Foundation extension SessionDefaults { - private static let dashboardWidgetConfigsKey = "dashboardWidgetConfigs" + private static let widgetConfigsKey = "learnerDashboardWidgetConfigs" var learnerDashboardWidgetConfigs: [DashboardWidgetConfig]? { get { - guard let data = self[Self.dashboardWidgetConfigsKey] as? Data else { - return nil - } + guard let data = self[Self.widgetConfigsKey] as? Data else { return nil } return try? JSONDecoder().decode([DashboardWidgetConfig].self, from: data) } set { if let newValue, let data = try? JSONEncoder().encode(newValue) { - self[Self.dashboardWidgetConfigsKey] = data + self[Self.widgetConfigsKey] = data } else { - self[Self.dashboardWidgetConfigsKey] = nil + self[Self.widgetConfigsKey] = nil } } } diff --git a/Student/Student/LearnerDashboard/Widgets/ConferencesWidget/View/ConferencesWidgetView.swift b/Student/Student/LearnerDashboard/Widgets/ConferencesWidget/View/ConferencesWidgetView.swift index f8196c1c67..f67be50f0e 100644 --- a/Student/Student/LearnerDashboard/Widgets/ConferencesWidget/View/ConferencesWidgetView.swift +++ b/Student/Student/LearnerDashboard/Widgets/ConferencesWidget/View/ConferencesWidgetView.swift @@ -67,7 +67,6 @@ struct ConferencesWidgetView: View { } private func makePreviewViewModel() -> ConferencesWidgetViewModel { - let config = DashboardWidgetConfig(id: .conferences, order: 0, isVisible: true, settings: nil) let interactor = ConferencesWidgetInteractorMock() interactor.getConferencesOutputValue = [ @@ -84,7 +83,6 @@ private func makePreviewViewModel() -> ConferencesWidgetViewModel { ] return ConferencesWidgetViewModel( - config: config, interactor: interactor, snackBarViewModel: SnackBarViewModel(), environment: PreviewEnvironment() diff --git a/Student/Student/LearnerDashboard/Widgets/ConferencesWidget/ViewModel/ConferencesWidgetViewModel.swift b/Student/Student/LearnerDashboard/Widgets/ConferencesWidget/ViewModel/ConferencesWidgetViewModel.swift index 5104c723e3..5a51936106 100644 --- a/Student/Student/LearnerDashboard/Widgets/ConferencesWidget/ViewModel/ConferencesWidgetViewModel.swift +++ b/Student/Student/LearnerDashboard/Widgets/ConferencesWidget/ViewModel/ConferencesWidgetViewModel.swift @@ -18,13 +18,12 @@ import Combine import Core -import Foundation +import SwiftUI @Observable final class ConferencesWidgetViewModel: DashboardWidgetViewModel { - typealias ViewType = ConferencesWidgetView - let config: DashboardWidgetConfig + let id: String = SystemWidgetIdentifier.conferences.rawValue let isHiddenInEmptyState = true private(set) var state: InstUI.ScreenState = .loading @@ -43,20 +42,18 @@ final class ConferencesWidgetViewModel: DashboardWidgetViewModel { private var subscriptions = Set() init( - config: DashboardWidgetConfig, interactor: ConferencesWidgetInteractor, snackBarViewModel: SnackBarViewModel, environment: AppEnvironment = .shared ) { - self.config = config self.interactor = interactor self.environment = environment self.snackBarViewModel = snackBarViewModel updateWidgetTitle() } - func makeView() -> ConferencesWidgetView { - ConferencesWidgetView(viewModel: self) + func makeView() -> AnyView { + AnyView(ConferencesWidgetView(viewModel: self)) } func refresh(ignoreCache: Bool) -> AnyPublisher { diff --git a/Student/Student/LearnerDashboard/Widgets/CourseInvitationsWidget/View/CourseInvitationsWidgetView.swift b/Student/Student/LearnerDashboard/Widgets/CourseInvitationsWidget/View/CourseInvitationsWidgetView.swift index c6682bc970..70b3d4ae6d 100644 --- a/Student/Student/LearnerDashboard/Widgets/CourseInvitationsWidget/View/CourseInvitationsWidgetView.swift +++ b/Student/Student/LearnerDashboard/Widgets/CourseInvitationsWidget/View/CourseInvitationsWidgetView.swift @@ -108,7 +108,6 @@ private func makePreviewViewModel(snackbarViewModel: SnackBarViewModel) -> Cours ) return CourseInvitationsWidgetViewModel( - config: .make(id: .courseInvitations), interactor: coursesInteractor, snackBarViewModel: snackbarViewModel ) diff --git a/Student/Student/LearnerDashboard/Widgets/CourseInvitationsWidget/ViewModel/CourseInvitationsWidgetViewModel.swift b/Student/Student/LearnerDashboard/Widgets/CourseInvitationsWidget/ViewModel/CourseInvitationsWidgetViewModel.swift index 34d25626d5..bb9a1e986e 100644 --- a/Student/Student/LearnerDashboard/Widgets/CourseInvitationsWidget/ViewModel/CourseInvitationsWidgetViewModel.swift +++ b/Student/Student/LearnerDashboard/Widgets/CourseInvitationsWidget/ViewModel/CourseInvitationsWidgetViewModel.swift @@ -18,14 +18,13 @@ import Combine import Core -import Foundation import Observation +import SwiftUI @Observable final class CourseInvitationsWidgetViewModel: DashboardWidgetViewModel { - typealias ViewType = CourseInvitationsWidgetView - let config: DashboardWidgetConfig + let id: String = SystemWidgetIdentifier.courseInvitations.rawValue let isHiddenInEmptyState = true private(set) var invitations: [CourseInvitationCardViewModel] = [] { @@ -44,18 +43,16 @@ final class CourseInvitationsWidgetViewModel: DashboardWidgetViewModel { private var subscriptions = Set() init( - config: DashboardWidgetConfig, interactor: CoursesInteractor, snackBarViewModel: SnackBarViewModel ) { - self.config = config self.interactor = interactor self.snackBarViewModel = snackBarViewModel updateTitles() } - func makeView() -> CourseInvitationsWidgetView { - CourseInvitationsWidgetView(viewModel: self) + func makeView() -> AnyView { + AnyView(CourseInvitationsWidgetView(viewModel: self)) } func refresh(ignoreCache: Bool) -> AnyPublisher { diff --git a/Student/Student/LearnerDashboard/Widgets/CoursesAndGroupsWidget/View/CoursesAndGroupsWidgetSettingsView.swift b/Student/Student/LearnerDashboard/Widgets/CoursesAndGroupsWidget/View/CoursesAndGroupsWidgetSettingsView.swift new file mode 100644 index 0000000000..853be298f9 --- /dev/null +++ b/Student/Student/LearnerDashboard/Widgets/CoursesAndGroupsWidget/View/CoursesAndGroupsWidgetSettingsView.swift @@ -0,0 +1,37 @@ +// +// 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 CoursesAndGroupsWidgetSettingsView: View { + @Bindable var viewModel: CoursesAndGroupsWidgetSettingsViewModel + + var body: some View { + InstUI.ToggleCell( + label: Text("Show Grades", bundle: .student), + value: $viewModel.showGrades, + dividerStyle: .padded + ) + InstUI.ToggleCell( + label: Text("Show Color Overlay", bundle: .student), + value: $viewModel.showColorOverlay, + dividerStyle: .hidden + ) + } +} diff --git a/Student/Student/LearnerDashboard/Widgets/CoursesAndGroupsWidget/ViewModel/CoursesAndGroupsWidgetSettingsViewModel.swift b/Student/Student/LearnerDashboard/Widgets/CoursesAndGroupsWidget/ViewModel/CoursesAndGroupsWidgetSettingsViewModel.swift new file mode 100644 index 0000000000..12081ae2a8 --- /dev/null +++ b/Student/Student/LearnerDashboard/Widgets/CoursesAndGroupsWidget/ViewModel/CoursesAndGroupsWidgetSettingsViewModel.swift @@ -0,0 +1,50 @@ +// +// 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 +import Core +import Observation + +@Observable +final class CoursesAndGroupsWidgetSettingsViewModel { + var showGrades: Bool { + didSet { env.userDefaults?.showGradesOnDashboard = showGrades } + } + + var showColorOverlay: Bool { + didSet { + updateColorOverlayTask = ReactiveStore( + context: env.database.viewContext, + useCase: UpdateUserSettings(hide_dashcard_color_overlays: !showColorOverlay), + environment: env + ) + .getEntities(ignoreCache: true) + .sink(receiveCompletion: { _ in }, receiveValue: { _ in }) + } + } + + private var updateColorOverlayTask: AnyCancellable? + private let env: AppEnvironment + + init(env: AppEnvironment) { + self.env = env + self.showGrades = env.userDefaults?.showGradesOnDashboard ?? false + let settings: [UserSettings] = env.database.viewContext.fetch() + self.showColorOverlay = !(settings.first?.hideDashcardColorOverlays ?? false) + } +} diff --git a/Student/Student/LearnerDashboard/Widgets/CoursesAndGroupsWidget/ViewModel/CoursesAndGroupsWidgetViewModel.swift b/Student/Student/LearnerDashboard/Widgets/CoursesAndGroupsWidget/ViewModel/CoursesAndGroupsWidgetViewModel.swift index 3371dd12ab..17810705b1 100644 --- a/Student/Student/LearnerDashboard/Widgets/CoursesAndGroupsWidget/ViewModel/CoursesAndGroupsWidgetViewModel.swift +++ b/Student/Student/LearnerDashboard/Widgets/CoursesAndGroupsWidget/ViewModel/CoursesAndGroupsWidgetViewModel.swift @@ -18,13 +18,13 @@ import Combine import Core -import Foundation +import SwiftUI @Observable final class CoursesAndGroupsWidgetViewModel: DashboardWidgetViewModel { - typealias ViewType = CoursesAndGroupsWidgetView let config: DashboardWidgetConfig + var id: String { config.id.rawValue } let isHiddenInEmptyState = true private(set) var state: InstUI.ScreenState = .loading @@ -56,8 +56,8 @@ final class CoursesAndGroupsWidgetViewModel: DashboardWidgetViewModel { updateShowColorOverlay(on: interactor.showColorOverlay) } - func makeView() -> CoursesAndGroupsWidgetView { - CoursesAndGroupsWidgetView(viewModel: self) + func makeView() -> AnyView { + AnyView(CoursesAndGroupsWidgetView(viewModel: self)) } func refresh(ignoreCache: Bool) -> AnyPublisher { diff --git a/Student/Student/LearnerDashboard/Widgets/DashboardWidgetIdentifier.swift b/Student/Student/LearnerDashboard/Widgets/DashboardWidgetIdentifier.swift deleted file mode 100644 index 62cd425336..0000000000 --- a/Student/Student/LearnerDashboard/Widgets/DashboardWidgetIdentifier.swift +++ /dev/null @@ -1,51 +0,0 @@ -// -// This file is part of Canvas. -// Copyright (C) 2024-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 - -enum DashboardWidgetIdentifier: String, Codable, CaseIterable { - case offlineSyncProgress - case fileUploadProgress - case conferences - case courseInvitations - case globalAnnouncements - case helloWidget - - case weeklySummary - case coursesAndGroups - - func title(username: String) -> String { - switch self { - case .conferences: return String(localized: "Conferences", bundle: .student) - case .helloWidget: return String(localized: "Hello \(username)", bundle: .student) - case .coursesAndGroups: return String(localized: "Courses & Groups", bundle: .student) - case .offlineSyncProgress, .fileUploadProgress, .globalAnnouncements, .courseInvitations, .weeklySummary: - assertionFailure("\(self) widget should not appear among Dashboard settings") - return rawValue - } - } - - var isEditable: Bool { - switch self { - case .offlineSyncProgress, .fileUploadProgress, .globalAnnouncements, .courseInvitations: - false - case .conferences, .helloWidget, .coursesAndGroups, .weeklySummary: - true - } - } -} diff --git a/Student/Student/LearnerDashboard/Widgets/FileUploadProgressWidget/View/FileUploadProgressWidgetView.swift b/Student/Student/LearnerDashboard/Widgets/FileUploadProgressWidget/View/FileUploadProgressWidgetView.swift index c35a65f20a..af4db8a438 100644 --- a/Student/Student/LearnerDashboard/Widgets/FileUploadProgressWidget/View/FileUploadProgressWidgetView.swift +++ b/Student/Student/LearnerDashboard/Widgets/FileUploadProgressWidget/View/FileUploadProgressWidgetView.swift @@ -87,7 +87,6 @@ struct FileUploadProgressWidgetView: View { try? context.save() let viewModel = FileUploadProgressWidgetViewModel( - config: .init(id: .fileUploadProgress, order: 1, isVisible: true), router: env.router, listViewModel: FileUploadNotificationCardListViewModel(environment: env) ) diff --git a/Student/Student/LearnerDashboard/Widgets/FileUploadProgressWidget/ViewModel/FileUploadProgressWidgetViewModel.swift b/Student/Student/LearnerDashboard/Widgets/FileUploadProgressWidget/ViewModel/FileUploadProgressWidgetViewModel.swift index e1cb8a0890..5296c68ba5 100644 --- a/Student/Student/LearnerDashboard/Widgets/FileUploadProgressWidget/ViewModel/FileUploadProgressWidgetViewModel.swift +++ b/Student/Student/LearnerDashboard/Widgets/FileUploadProgressWidget/ViewModel/FileUploadProgressWidgetViewModel.swift @@ -18,16 +18,14 @@ import Combine import Core -import Foundation import SwiftUI @Observable final class FileUploadProgressWidgetViewModel: DashboardWidgetViewModel { - typealias ViewType = FileUploadProgressWidgetView // MARK: - Protocol Properties - let config: DashboardWidgetConfig + let id: String = SystemWidgetIdentifier.fileUploadProgress.rawValue private(set) var state: InstUI.ScreenState = .empty let isHiddenInEmptyState = true @@ -45,8 +43,10 @@ final class FileUploadProgressWidgetViewModel: DashboardWidgetViewModel { private let listViewModel: FileUploadNotificationCardListViewModel private var subscriptions = Set() - init(config: DashboardWidgetConfig, router: Router, listViewModel: FileUploadNotificationCardListViewModel) { - self.config = config + init( + router: Router, + listViewModel: FileUploadNotificationCardListViewModel + ) { self.router = router self.listViewModel = listViewModel setupObserver() @@ -99,8 +99,8 @@ final class FileUploadProgressWidgetViewModel: DashboardWidgetViewModel { item.hideDidTap() } - func makeView() -> FileUploadProgressWidgetView { - FileUploadProgressWidgetView(model: self) + func makeView() -> AnyView { + AnyView(FileUploadProgressWidgetView(model: self)) } func refresh(ignoreCache: Bool) -> AnyPublisher { diff --git a/Student/Student/LearnerDashboard/Widgets/GlobalAnnouncementsWidget/View/GlobalAnnouncementsWidgetView.swift b/Student/Student/LearnerDashboard/Widgets/GlobalAnnouncementsWidget/View/GlobalAnnouncementsWidgetView.swift index fc291d9b8a..72d82d9dea 100644 --- a/Student/Student/LearnerDashboard/Widgets/GlobalAnnouncementsWidget/View/GlobalAnnouncementsWidgetView.swift +++ b/Student/Student/LearnerDashboard/Widgets/GlobalAnnouncementsWidget/View/GlobalAnnouncementsWidgetView.swift @@ -74,7 +74,6 @@ private func makePreviewViewModel() -> GlobalAnnouncementsWidgetViewModel { ] return GlobalAnnouncementsWidgetViewModel( - config: .make(id: .globalAnnouncements), interactor: interactor, environment: PreviewEnvironment() ) diff --git a/Student/Student/LearnerDashboard/Widgets/GlobalAnnouncementsWidget/ViewModel/GlobalAnnouncementsWidgetViewModel.swift b/Student/Student/LearnerDashboard/Widgets/GlobalAnnouncementsWidget/ViewModel/GlobalAnnouncementsWidgetViewModel.swift index 35e0792c1c..444e4c1963 100644 --- a/Student/Student/LearnerDashboard/Widgets/GlobalAnnouncementsWidget/ViewModel/GlobalAnnouncementsWidgetViewModel.swift +++ b/Student/Student/LearnerDashboard/Widgets/GlobalAnnouncementsWidget/ViewModel/GlobalAnnouncementsWidgetViewModel.swift @@ -18,13 +18,11 @@ import Combine import Core -import Foundation +import SwiftUI @Observable final class GlobalAnnouncementsWidgetViewModel: DashboardWidgetViewModel { - typealias ViewType = GlobalAnnouncementsWidgetView - - let config: DashboardWidgetConfig + let id: String = SystemWidgetIdentifier.globalAnnouncements.rawValue let isHiddenInEmptyState = true private(set) var state: InstUI.ScreenState = .loading @@ -42,11 +40,9 @@ final class GlobalAnnouncementsWidgetViewModel: DashboardWidgetViewModel { private var subscriptions = Set() init( - config: DashboardWidgetConfig, interactor: GlobalAnnouncementsWidgetInteractor, environment: AppEnvironment = .shared ) { - self.config = config self.interactor = interactor self.environment = environment @@ -54,8 +50,8 @@ final class GlobalAnnouncementsWidgetViewModel: DashboardWidgetViewModel { updateWidgetTitle() } - func makeView() -> GlobalAnnouncementsWidgetView { - GlobalAnnouncementsWidgetView(viewModel: self) + func makeView() -> AnyView { + AnyView(GlobalAnnouncementsWidgetView(viewModel: self)) } func refresh(ignoreCache: Bool) -> AnyPublisher { diff --git a/Student/Student/LearnerDashboard/Widgets/HelloWidget/ViewModel/HelloWidgetViewModel.swift b/Student/Student/LearnerDashboard/Widgets/HelloWidget/ViewModel/HelloWidgetViewModel.swift index 7fec089845..b7d378badb 100644 --- a/Student/Student/LearnerDashboard/Widgets/HelloWidget/ViewModel/HelloWidgetViewModel.swift +++ b/Student/Student/LearnerDashboard/Widgets/HelloWidget/ViewModel/HelloWidgetViewModel.swift @@ -16,16 +16,15 @@ // along with this program. If not, see . // -import Foundation -import Core import Combine +import Core +import SwiftUI import UIKit @Observable final class HelloWidgetViewModel: DashboardWidgetViewModel { - typealias ViewType = HelloWidgetView - let config: DashboardWidgetConfig + var id: String { config.id.rawValue } let isHiddenInEmptyState = true var layoutIdentifier: [AnyHashable] { @@ -54,8 +53,8 @@ final class HelloWidgetViewModel: DashboardWidgetViewModel { subscribeToNotification() } - func makeView() -> HelloWidgetView { - HelloWidgetView(viewModel: self) + func makeView() -> AnyView { + AnyView(HelloWidgetView(viewModel: self)) } public func refresh(ignoreCache: Bool) -> AnyPublisher { diff --git a/Student/Student/LearnerDashboard/Widgets/LearnerDashboardWidgetAssembly.swift b/Student/Student/LearnerDashboard/Widgets/LearnerDashboardWidgetAssembly.swift index a28a7c73df..cb62fba13f 100644 --- a/Student/Student/LearnerDashboard/Widgets/LearnerDashboardWidgetAssembly.swift +++ b/Student/Student/LearnerDashboard/Widgets/LearnerDashboardWidgetAssembly.swift @@ -17,102 +17,9 @@ // import Core -import SwiftUI +import Foundation enum LearnerDashboardWidgetAssembly { - static func makeDefaultWidgetConfigs() -> [DashboardWidgetConfig] { - let identifiers: [DashboardWidgetIdentifier] = [ - .offlineSyncProgress, - .fileUploadProgress, - .conferences, - .courseInvitations, - .globalAnnouncements, - .helloWidget, - .weeklySummary, - .coursesAndGroups - ] - - return identifiers.enumerated().map { (index, id) in - DashboardWidgetConfig(id: id, order: index, isVisible: true) - } - } - - static func makeWidgetViewModel( - config: DashboardWidgetConfig, - snackBarViewModel: SnackBarViewModel, - coursesInteractor: CoursesInteractor = makeCoursesInteractor() - ) -> any DashboardWidgetViewModel { - switch config.id { - case .offlineSyncProgress: - OfflineSyncProgressWidgetViewModel( - config: config, - dashboardViewModel: DashboardOfflineSyncProgressCardAssembly.makeViewModel() - ) - case .fileUploadProgress: - FileUploadProgressWidgetViewModel( - config: config, - router: AppEnvironment.shared.router, - listViewModel: FileUploadNotificationCardListViewModel() - ) - case .conferences: - ConferencesWidgetViewModel( - config: config, - interactor: .live(coursesInteractor: coursesInteractor, env: .shared), - snackBarViewModel: snackBarViewModel - ) - case .courseInvitations: - CourseInvitationsWidgetViewModel( - config: config, - interactor: coursesInteractor, - snackBarViewModel: snackBarViewModel - ) - case .globalAnnouncements: - GlobalAnnouncementsWidgetViewModel( - config: config, - interactor: .live(env: .shared) - ) - case .helloWidget: - HelloWidgetViewModel( - config: config, - interactor: .live(), - dayPeriodProvider: .init() - ) - case .coursesAndGroups: - CoursesAndGroupsWidgetViewModel( - config: config, - interactor: .live(coursesInteractor: coursesInteractor, env: .shared) - ) - case .weeklySummary: - WeeklySummaryWidgetViewModel(config: config) - } - } - - @ViewBuilder - static func makeView(for viewModel: any DashboardWidgetViewModel) -> some View { - switch viewModel { - case let vm as OfflineSyncProgressWidgetViewModel: - vm.makeView() - case let vm as FileUploadProgressWidgetViewModel: - vm.makeView() - case let vm as ConferencesWidgetViewModel: - vm.makeView() - case let vm as CourseInvitationsWidgetViewModel: - vm.makeView() - case let vm as GlobalAnnouncementsWidgetViewModel: - vm.makeView() - case let vm as HelloWidgetViewModel: - vm.makeView() - case let vm as CoursesAndGroupsWidgetViewModel: - vm.makeView() - case let vm as WeeklySummaryWidgetViewModel: - vm.makeView() - default: - SwiftUI.EmptyView() - .onAppear { - assertionFailure("Unknown widget view model type") - } - } - } // MARK: - Cached Interactor Instance diff --git a/Student/Student/LearnerDashboard/Widgets/OfflineSyncProgressWidget/View/OfflineSyncProgressWidgetView.swift b/Student/Student/LearnerDashboard/Widgets/OfflineSyncProgressWidget/View/OfflineSyncProgressWidgetView.swift index 2bac1791b9..478bd4c19e 100644 --- a/Student/Student/LearnerDashboard/Widgets/OfflineSyncProgressWidget/View/OfflineSyncProgressWidgetView.swift +++ b/Student/Student/LearnerDashboard/Widgets/OfflineSyncProgressWidget/View/OfflineSyncProgressWidgetView.swift @@ -103,7 +103,6 @@ struct OfflineSyncProgressWidgetView: View { #Preview("Syncing") { OfflineSyncProgressWidgetView( model: OfflineSyncProgressWidgetViewModel( - config: .init(id: .offlineSyncProgress, order: 1, isVisible: true), dashboardViewModel: DashboardOfflineSyncProgressCardViewModel( progressObserverInteractor: DashboardOfflineSyncInteractorPreview(), progressWriterInteractor: DashboardOfflineSyncProgressWriterInteractorPreview(), diff --git a/Student/Student/LearnerDashboard/Widgets/OfflineSyncProgressWidget/ViewModel/OfflineSyncProgressWidgetViewModel.swift b/Student/Student/LearnerDashboard/Widgets/OfflineSyncProgressWidget/ViewModel/OfflineSyncProgressWidgetViewModel.swift index 5bbf29def8..0515f1bf33 100644 --- a/Student/Student/LearnerDashboard/Widgets/OfflineSyncProgressWidget/ViewModel/OfflineSyncProgressWidgetViewModel.swift +++ b/Student/Student/LearnerDashboard/Widgets/OfflineSyncProgressWidget/ViewModel/OfflineSyncProgressWidgetViewModel.swift @@ -18,16 +18,14 @@ import Combine import Core -import Foundation import SwiftUI @Observable final class OfflineSyncProgressWidgetViewModel: DashboardWidgetViewModel { - typealias ViewType = OfflineSyncProgressWidgetView // MARK: - Protocol Properties - let config: DashboardWidgetConfig + let id: String = SystemWidgetIdentifier.offlineSyncProgress.rawValue private(set) var state: InstUI.ScreenState = .empty let isHiddenInEmptyState = true @@ -49,10 +47,8 @@ final class OfflineSyncProgressWidgetViewModel: DashboardWidgetViewModel { private var subscriptions = Set() init( - config: DashboardWidgetConfig, dashboardViewModel: DashboardOfflineSyncProgressCardViewModel ) { - self.config = config self.dashboardViewModel = dashboardViewModel setupObserver() } @@ -65,8 +61,8 @@ final class OfflineSyncProgressWidgetViewModel: DashboardWidgetViewModel { dashboardViewModel.cardDidTap.accept(viewController) } - func makeView() -> OfflineSyncProgressWidgetView { - OfflineSyncProgressWidgetView(model: self) + func makeView() -> AnyView { + AnyView(OfflineSyncProgressWidgetView(model: self)) } func refresh(ignoreCache: Bool) -> AnyPublisher { diff --git a/Student/Student/LearnerDashboard/Widgets/WeeklySummaryWidget/ViewModel/WeeklySummaryWidgetViewModel.swift b/Student/Student/LearnerDashboard/Widgets/WeeklySummaryWidget/ViewModel/WeeklySummaryWidgetViewModel.swift index d8ea2e7acf..bcb0e01df8 100644 --- a/Student/Student/LearnerDashboard/Widgets/WeeklySummaryWidget/ViewModel/WeeklySummaryWidgetViewModel.swift +++ b/Student/Student/LearnerDashboard/Widgets/WeeklySummaryWidget/ViewModel/WeeklySummaryWidgetViewModel.swift @@ -19,10 +19,11 @@ import Combine import Core import Foundation +import SwiftUI @Observable final class WeeklySummaryWidgetViewModel: DashboardWidgetViewModel { - typealias ViewType = WeeklySummaryWidgetView + let id: String = EditableWidgetIdentifier.weeklySummary.rawValue private(set) var state: InstUI.ScreenState = .loading let config: DashboardWidgetConfig @@ -72,8 +73,8 @@ final class WeeklySummaryWidgetViewModel: DashboardWidgetViewModel { self.newGradesFilter = .newGrades(assignments: []) } - func makeView() -> WeeklySummaryWidgetView { - WeeklySummaryWidgetView(viewModel: self) + func makeView() -> AnyView { + AnyView(WeeklySummaryWidgetView(viewModel: self)) } func refresh(ignoreCache: Bool) -> AnyPublisher { diff --git a/Student/Student/Localizable.xcstrings b/Student/Student/Localizable.xcstrings index afe8d6c087..07551552be 100644 --- a/Student/Student/Localizable.xcstrings +++ b/Student/Student/Localizable.xcstrings @@ -62069,9 +62069,6 @@ } } } - }, - "Example setting %lld" : { - }, "Existing Attachments..." : { "extractionState" : "manual", @@ -132773,6 +132770,9 @@ } } } + }, + "Show Color Overlay" : { + }, "Show file" : { "comment" : "Title for button confirming showing a file", @@ -175169,9 +175169,6 @@ } } }, - "White" : { - "comment" : "This is a name of a color." - }, "Whoops!" : { "comment" : "Error Title", "extractionState" : "manual", diff --git a/Student/StudentUnitTests/LearnerDashboard/Colors/Model/LearnerDashboardColorInteractorTests.swift b/Student/StudentUnitTests/LearnerDashboard/Colors/Model/LearnerDashboardColorInteractorTests.swift new file mode 100644 index 0000000000..1665347f51 --- /dev/null +++ b/Student/StudentUnitTests/LearnerDashboard/Colors/Model/LearnerDashboardColorInteractorTests.swift @@ -0,0 +1,109 @@ +// +// 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 . +// + +@testable import Core +@testable import Student +import XCTest + +final class LearnerDashboardColorInteractorTests: XCTestCase { + + private var testee: LearnerDashboardColorInteractorLive! + private var userDefaults: SessionDefaults! + + override func setUp() { + super.setUp() + userDefaults = SessionDefaults(sessionID: "test-session") + userDefaults.reset() + } + + override func tearDown() { + testee = nil + userDefaults.reset() + userDefaults = nil + super.tearDown() + } + + // MARK: - Initialization + + func test_init_withNoSavedId_defaultsToFirstColor() { + userDefaults.learnerDashboardColorId = nil + + testee = LearnerDashboardColorInteractorLive(defaults: userDefaults) + + let expectedColor = testee.availableColors[0].color.asColor + XCTAssertEqual(testee.dashboardColor.value, expectedColor) + } + + func test_init_withSavedId_restoresColor() { + let targetColor = LearnerDashboardColorInteractorLive(defaults: userDefaults).availableColors[3] + userDefaults.learnerDashboardColorId = targetColor.persistentId + + testee = LearnerDashboardColorInteractorLive(defaults: userDefaults) + + XCTAssertEqual(testee.dashboardColor.value, targetColor.color.asColor) + } + + func test_init_withUnknownId_defaultsToFirstColor() { + userDefaults.learnerDashboardColorId = "not-a-real-color-id" + + testee = LearnerDashboardColorInteractorLive(defaults: userDefaults) + + let expectedColor = testee.availableColors[0].color.asColor + XCTAssertEqual(testee.dashboardColor.value, expectedColor) + } + + // MARK: - Available Colors + + func test_availableColors_containsExpectedCount() { + testee = LearnerDashboardColorInteractorLive(defaults: userDefaults) + + let courseColorCount = CourseColorData.all.count + let additionalColorCount = 1 // black + XCTAssertEqual(testee.availableColors.count, courseColorCount + additionalColorCount) + } + + // MARK: - selectColor + + func test_selectColor_updatesSubject() { + testee = LearnerDashboardColorInteractorLive(defaults: userDefaults) + let colorToSelect = testee.availableColors[2].color.asColor + + testee.selectColor(colorToSelect) + + XCTAssertEqual(testee.dashboardColor.value, colorToSelect) + } + + func test_selectColor_persistsIdToDefaults() { + testee = LearnerDashboardColorInteractorLive(defaults: userDefaults) + let targetColor = testee.availableColors[4] + + testee.selectColor(targetColor.color.asColor) + + XCTAssertEqual(userDefaults.learnerDashboardColorId, targetColor.persistentId) + } + + func test_selectColor_withUnknownColor_doesNotChange() { + userDefaults.learnerDashboardColorId = testee?.availableColors[0].persistentId + testee = LearnerDashboardColorInteractorLive(defaults: userDefaults) + let originalColor = testee.dashboardColor.value + + testee.selectColor(.purple) + + XCTAssertEqual(testee.dashboardColor.value, originalColor) + } +} diff --git a/Student/StudentUnitTests/LearnerDashboard/Container/Model/LearnerDashboardInteractorTests.swift b/Student/StudentUnitTests/LearnerDashboard/Container/Model/LearnerDashboardInteractorTests.swift index 928d0f1ef1..cb36c8d54f 100644 --- a/Student/StudentUnitTests/LearnerDashboard/Container/Model/LearnerDashboardInteractorTests.swift +++ b/Student/StudentUnitTests/LearnerDashboard/Container/Model/LearnerDashboardInteractorTests.swift @@ -19,6 +19,7 @@ import Combine @testable import Core @testable import Student +import SwiftUI import XCTest final class LearnerDashboardInteractorLiveTests: StudentTestCase { @@ -46,7 +47,8 @@ final class LearnerDashboardInteractorLiveTests: StudentTestCase { func test_loadWidgets_withNoSavedConfigs_shouldUseDefaultConfigs() { testee = LearnerDashboardInteractorLive( userDefaults: userDefaults, - widgetViewModelFactory: makeViewModelFactory() + systemWidgetFactory: makeSystemFactory(), + editableWidgetFactory: makeEditableFactory() ) let expectation = expectation(description: "loadWidgets") @@ -62,27 +64,27 @@ final class LearnerDashboardInteractorLiveTests: StudentTestCase { wait(for: [expectation], timeout: 5) XCTAssertEqual(received?.count, 8) - XCTAssertEqual(received?[0].id, .offlineSyncProgress) - XCTAssertEqual(received?[1].id, .fileUploadProgress) - XCTAssertEqual(received?[2].id, .conferences) - XCTAssertEqual(received?[3].id, .courseInvitations) - XCTAssertEqual(received?[4].id, .globalAnnouncements) - XCTAssertEqual(received?[5].id, .helloWidget) - XCTAssertEqual(received?[6].id, .weeklySummary) - XCTAssertEqual(received?[7].id, .coursesAndGroups) + XCTAssertEqual(received?[0].id, SystemWidgetIdentifier.offlineSyncProgress.rawValue) + XCTAssertEqual(received?[1].id, SystemWidgetIdentifier.fileUploadProgress.rawValue) + XCTAssertEqual(received?[2].id, SystemWidgetIdentifier.courseInvitations.rawValue) + XCTAssertEqual(received?[3].id, SystemWidgetIdentifier.globalAnnouncements.rawValue) + XCTAssertEqual(received?[4].id, SystemWidgetIdentifier.conferences.rawValue) + XCTAssertEqual(received?[5].id, EditableWidgetIdentifier.helloWidget.rawValue) + XCTAssertEqual(received?[6].id, EditableWidgetIdentifier.coursesAndGroups.rawValue) + XCTAssertEqual(received?[7].id, EditableWidgetIdentifier.weeklySummary.rawValue) } // MARK: - Load widgets with saved configs - func test_loadWidgets_withSavedConfigs_shouldFilterVisibleAndSort() { + func test_loadWidgets_withSavedConfigs_shouldIncludeAllSystemAndFilterVisibleEditable() { userDefaults.learnerDashboardWidgetConfigs = [ DashboardWidgetConfig(id: .helloWidget, order: 10, isVisible: true), - DashboardWidgetConfig(id: .conferences, order: 20, isVisible: false), DashboardWidgetConfig(id: .coursesAndGroups, order: 5, isVisible: true) ] testee = LearnerDashboardInteractorLive( userDefaults: userDefaults, - widgetViewModelFactory: makeViewModelFactory() + systemWidgetFactory: makeSystemFactory(), + editableWidgetFactory: makeEditableFactory() ) let expectation = expectation(description: "loadWidgets") @@ -97,20 +99,26 @@ final class LearnerDashboardInteractorLiveTests: StudentTestCase { wait(for: [expectation], timeout: 5) - XCTAssertEqual(received?.count, 2) - XCTAssertEqual(received?[0].id, .coursesAndGroups) - XCTAssertEqual(received?[1].id, .helloWidget) + XCTAssertEqual(received?.count, 8) + XCTAssertEqual(received?[0].id, SystemWidgetIdentifier.offlineSyncProgress.rawValue) + XCTAssertEqual(received?[1].id, SystemWidgetIdentifier.fileUploadProgress.rawValue) + XCTAssertEqual(received?[2].id, SystemWidgetIdentifier.courseInvitations.rawValue) + XCTAssertEqual(received?[3].id, SystemWidgetIdentifier.globalAnnouncements.rawValue) + XCTAssertEqual(received?[4].id, SystemWidgetIdentifier.conferences.rawValue) + XCTAssertEqual(received?[5].id, EditableWidgetIdentifier.weeklySummary.rawValue) + XCTAssertEqual(received?[6].id, EditableWidgetIdentifier.coursesAndGroups.rawValue) + XCTAssertEqual(received?[7].id, EditableWidgetIdentifier.helloWidget.rawValue) } - func test_loadWidgets_shouldReturnWidgetsInOrder() { + func test_loadWidgets_shouldReturnEditableWidgetsInOrder() { userDefaults.learnerDashboardWidgetConfigs = [ DashboardWidgetConfig(id: .helloWidget, order: 20, isVisible: true), - DashboardWidgetConfig(id: .courseInvitations, order: 5, isVisible: true), DashboardWidgetConfig(id: .coursesAndGroups, order: 10, isVisible: true) ] testee = LearnerDashboardInteractorLive( userDefaults: userDefaults, - widgetViewModelFactory: makeViewModelFactory() + systemWidgetFactory: makeSystemFactory(), + editableWidgetFactory: makeEditableFactory() ) let expectation = expectation(description: "loadWidgets") @@ -125,34 +133,35 @@ final class LearnerDashboardInteractorLiveTests: StudentTestCase { wait(for: [expectation], timeout: 5) - XCTAssertEqual(received?.count, 3) - XCTAssertEqual(received?[0].id, .courseInvitations) - XCTAssertEqual(received?[1].id, .coursesAndGroups) - XCTAssertEqual(received?[2].id, .helloWidget) + XCTAssertEqual(received?.count, 8) + XCTAssertEqual(received?[4].id, SystemWidgetIdentifier.conferences.rawValue) + XCTAssertEqual(received?[5].id, EditableWidgetIdentifier.weeklySummary.rawValue) + XCTAssertEqual(received?[6].id, EditableWidgetIdentifier.coursesAndGroups.rawValue) + XCTAssertEqual(received?[7].id, EditableWidgetIdentifier.helloWidget.rawValue) } // MARK: - Private helpers - private func makeViewModelFactory() -> (DashboardWidgetConfig) -> any DashboardWidgetViewModel { - return { config in - DashboardWidgetViewModelMock(config: config) - } + private func makeSystemFactory() -> (SystemWidgetIdentifier) -> any DashboardWidgetViewModel { + return { id in DashboardWidgetViewModelMock(id: id.rawValue) } + } + + private func makeEditableFactory() -> (DashboardWidgetConfig) -> any DashboardWidgetViewModel { + return { config in DashboardWidgetViewModelMock(id: config.id.rawValue) } } } private final class DashboardWidgetViewModelMock: DashboardWidgetViewModel { - typealias ViewType = Never - - let config: DashboardWidgetConfig + let id: String let isHiddenInEmptyState = false let state: InstUI.ScreenState = .data - init(config: DashboardWidgetConfig) { - self.config = config + init(id: String) { + self.id = id } - func makeView() -> Never { - fatalError("Not implemented") + func makeView() -> AnyView { + AnyView(EmptyView()) } func refresh(ignoreCache: Bool) -> AnyPublisher { diff --git a/Student/StudentUnitTests/LearnerDashboard/Container/ViewModel/LearnerDashboardViewModelTests.swift b/Student/StudentUnitTests/LearnerDashboard/Container/ViewModel/LearnerDashboardViewModelTests.swift index 3d30b25c14..b273af332b 100644 --- a/Student/StudentUnitTests/LearnerDashboard/Container/ViewModel/LearnerDashboardViewModelTests.swift +++ b/Student/StudentUnitTests/LearnerDashboard/Container/ViewModel/LearnerDashboardViewModelTests.swift @@ -20,6 +20,7 @@ import Combine import CombineSchedulers @testable import Core @testable import Student +import SwiftUI import TestsFoundation import XCTest @@ -27,32 +28,41 @@ final class LearnerDashboardViewModelTests: StudentTestCase { private var testee: LearnerDashboardViewModel! private var interactor: LearnerDashboardInteractorMock! + private var colorInteractor: LearnerDashboardColorInteractorLive! private var courseSyncInteractor: CourseSyncInteractorMock! private var scheduler: TestSchedulerOf! + private var testDefaults: SessionDefaults! override func setUp() { super.setUp() scheduler = DispatchQueue.test interactor = LearnerDashboardInteractorMock() courseSyncInteractor = CourseSyncInteractorMock() + testDefaults = SessionDefaults(sessionID: "test-session") + testDefaults.reset() + colorInteractor = LearnerDashboardColorInteractorLive(defaults: testDefaults) } override func tearDown() { testee = nil interactor = nil + colorInteractor = nil courseSyncInteractor = nil scheduler = nil + testDefaults.reset() + testDefaults = nil super.tearDown() } // MARK: - Initialization func test_init_shouldLoadWidgets() { - let widget1 = MockWidgetViewModel(id: .courseInvitations) - let widget2 = MockWidgetViewModel(id: .helloWidget) + let widget1 = MockWidgetViewModel(id: SystemWidgetIdentifier.courseInvitations.rawValue) + let widget2 = MockWidgetViewModel(id: EditableWidgetIdentifier.helloWidget.rawValue) testee = LearnerDashboardViewModel( interactor: interactor, + colorInteractor: colorInteractor, snackBarViewModel: SnackBarViewModel(scheduler: scheduler.eraseToAnyScheduler()), mainScheduler: scheduler.eraseToAnyScheduler(), courseSyncInteractor: courseSyncInteractor, @@ -62,8 +72,8 @@ final class LearnerDashboardViewModelTests: StudentTestCase { scheduler.advance() XCTAssertEqual(testee.widgets.count, 2) - XCTAssertEqual(testee.widgets[0].id, .courseInvitations) - XCTAssertEqual(testee.widgets[1].id, .helloWidget) + XCTAssertEqual(testee.widgets[0].id, SystemWidgetIdentifier.courseInvitations.rawValue) + XCTAssertEqual(testee.widgets[1].id, EditableWidgetIdentifier.helloWidget.rawValue) } // MARK: - Screen config @@ -71,6 +81,7 @@ final class LearnerDashboardViewModelTests: StudentTestCase { func test_screenConfig_shouldBeConfiguredCorrectly() { testee = LearnerDashboardViewModel( interactor: interactor, + colorInteractor: colorInteractor, snackBarViewModel: SnackBarViewModel(scheduler: scheduler.eraseToAnyScheduler()), mainScheduler: scheduler.eraseToAnyScheduler(), courseSyncInteractor: courseSyncInteractor, @@ -91,6 +102,7 @@ final class LearnerDashboardViewModelTests: StudentTestCase { func test_init_withNoWidgets_shouldKeepLoadingState() { testee = LearnerDashboardViewModel( interactor: interactor, + colorInteractor: colorInteractor, snackBarViewModel: SnackBarViewModel(scheduler: scheduler.eraseToAnyScheduler()), mainScheduler: scheduler.eraseToAnyScheduler(), courseSyncInteractor: courseSyncInteractor, @@ -103,10 +115,11 @@ final class LearnerDashboardViewModelTests: StudentTestCase { } func test_init_withWidgets_shouldSetDataState() { - let widget = MockWidgetViewModel(id: .helloWidget) + let widget = MockWidgetViewModel(id: EditableWidgetIdentifier.helloWidget.rawValue) testee = LearnerDashboardViewModel( interactor: interactor, + colorInteractor: colorInteractor, snackBarViewModel: SnackBarViewModel(scheduler: scheduler.eraseToAnyScheduler()), mainScheduler: scheduler.eraseToAnyScheduler(), courseSyncInteractor: courseSyncInteractor, @@ -121,12 +134,13 @@ final class LearnerDashboardViewModelTests: StudentTestCase { // MARK: - Refresh func test_refresh_shouldCallRefreshOnAllWidgets() { - let widget1 = MockWidgetViewModel(id: .helloWidget) - let widget2 = MockWidgetViewModel(id: .coursesAndGroups) - let widget3 = MockWidgetViewModel(id: .courseInvitations) + let widget1 = MockWidgetViewModel(id: EditableWidgetIdentifier.helloWidget.rawValue) + let widget2 = MockWidgetViewModel(id: EditableWidgetIdentifier.coursesAndGroups.rawValue) + let widget3 = MockWidgetViewModel(id: SystemWidgetIdentifier.courseInvitations.rawValue) testee = LearnerDashboardViewModel( interactor: interactor, + colorInteractor: colorInteractor, snackBarViewModel: SnackBarViewModel(scheduler: scheduler.eraseToAnyScheduler()), mainScheduler: scheduler.eraseToAnyScheduler(), courseSyncInteractor: courseSyncInteractor, @@ -147,10 +161,11 @@ final class LearnerDashboardViewModelTests: StudentTestCase { } func test_refresh_shouldCallCompletionWhenAllWidgetsFinish() { - let widget = MockWidgetViewModel(id: .helloWidget) + let widget = MockWidgetViewModel(id: EditableWidgetIdentifier.helloWidget.rawValue) testee = LearnerDashboardViewModel( interactor: interactor, + colorInteractor: colorInteractor, snackBarViewModel: SnackBarViewModel(scheduler: scheduler.eraseToAnyScheduler()), mainScheduler: scheduler.eraseToAnyScheduler(), courseSyncInteractor: courseSyncInteractor, @@ -175,6 +190,7 @@ final class LearnerDashboardViewModelTests: StudentTestCase { func test_offlineSyncTriggered_shouldStartDownload() { testee = LearnerDashboardViewModel( interactor: interactor, + colorInteractor: colorInteractor, snackBarViewModel: SnackBarViewModel(scheduler: scheduler.eraseToAnyScheduler()), mainScheduler: scheduler.eraseToAnyScheduler(), courseSyncInteractor: courseSyncInteractor, @@ -194,6 +210,7 @@ final class LearnerDashboardViewModelTests: StudentTestCase { func test_offlineSyncCleanTriggered_shouldCleanContent() { testee = LearnerDashboardViewModel( interactor: interactor, + colorInteractor: colorInteractor, snackBarViewModel: SnackBarViewModel(scheduler: scheduler.eraseToAnyScheduler()), mainScheduler: scheduler.eraseToAnyScheduler(), courseSyncInteractor: courseSyncInteractor, @@ -212,21 +229,19 @@ final class LearnerDashboardViewModelTests: StudentTestCase { } private final class MockWidgetViewModel: DashboardWidgetViewModel { - typealias ViewType = Never - - let config: DashboardWidgetConfig + let id: String let isHiddenInEmptyState = false let state: InstUI.ScreenState = .data var refreshCalled = false var refreshIgnoreCache: Bool? - init(id: DashboardWidgetIdentifier) { - self.config = .make(id: id, order: 7) + init(id: String) { + self.id = id } - func makeView() -> Never { - fatalError("Not implemented") + func makeView() -> AnyView { + AnyView(EmptyView()) } func refresh(ignoreCache: Bool) -> AnyPublisher { diff --git a/Student/StudentUnitTests/LearnerDashboard/Settings/ViewModel/LearnerDashboardSettingsViewModelTests.swift b/Student/StudentUnitTests/LearnerDashboard/Settings/ViewModel/LearnerDashboardSettingsViewModelTests.swift index 1875bbdeb0..8d53818ea4 100644 --- a/Student/StudentUnitTests/LearnerDashboard/Settings/ViewModel/LearnerDashboardSettingsViewModelTests.swift +++ b/Student/StudentUnitTests/LearnerDashboard/Settings/ViewModel/LearnerDashboardSettingsViewModelTests.swift @@ -43,7 +43,7 @@ final class LearnerDashboardSettingsViewModelTests: StudentTestCase { func test_init_shouldSetUseNewLearnerDashboardFromDefaults() { testDefaults.preferNewLearnerDashboard = true - testee = LearnerDashboardSettingsViewModel(defaults: testDefaults) + testee = makeTestee() XCTAssertEqual(testee.useNewLearnerDashboard, true) } @@ -51,7 +51,7 @@ final class LearnerDashboardSettingsViewModelTests: StudentTestCase { func test_init_withFalseDefault_shouldSetUseNewLearnerDashboardToFalse() { testDefaults.preferNewLearnerDashboard = false - testee = LearnerDashboardSettingsViewModel(defaults: testDefaults) + testee = makeTestee() XCTAssertEqual(testee.useNewLearnerDashboard, false) } @@ -60,7 +60,7 @@ final class LearnerDashboardSettingsViewModelTests: StudentTestCase { func test_switchToClassicDashboard_shouldUpdateDefaults() { testDefaults.preferNewLearnerDashboard = true - testee = LearnerDashboardSettingsViewModel(defaults: testDefaults) + testee = makeTestee() let viewController = UIViewController() testee.switchToClassicDashboard(viewController: viewController) @@ -70,7 +70,7 @@ final class LearnerDashboardSettingsViewModelTests: StudentTestCase { func test_switchToClassicDashboard_shouldUpdateLocalState() { testDefaults.preferNewLearnerDashboard = true - testee = LearnerDashboardSettingsViewModel(defaults: testDefaults) + testee = makeTestee() let viewController = UIViewController() testee.switchToClassicDashboard(viewController: viewController) @@ -79,7 +79,7 @@ final class LearnerDashboardSettingsViewModelTests: StudentTestCase { } func test_switchToClassicDashboard_shouldDismissViewController() { - testee = LearnerDashboardSettingsViewModel(defaults: testDefaults) + testee = makeTestee() let expectation = expectation(description: "dismiss called") let mockViewController = MockViewController() @@ -92,7 +92,7 @@ final class LearnerDashboardSettingsViewModelTests: StudentTestCase { } func test_switchToClassicDashboard_shouldPostNotificationAfterDismiss() { - testee = LearnerDashboardSettingsViewModel(defaults: testDefaults, environment: env) + testee = makeTestee() let expectation = expectation(forNotification: .dashboardPreferenceChanged, object: nil) let mockViewController = MockViewController() @@ -106,13 +106,34 @@ final class LearnerDashboardSettingsViewModelTests: StudentTestCase { func test_switchToClassicDashboard_shouldSetFeedbackFlag() { testDefaults.preferNewLearnerDashboard = true testDefaults.shouldShowDashboardFeedback = false - testee = LearnerDashboardSettingsViewModel(defaults: testDefaults) + testee = makeTestee() let viewController = UIViewController() testee.switchToClassicDashboard(viewController: viewController) XCTAssertEqual(testDefaults.shouldShowDashboardFeedback, true) } + + // MARK: - Private helpers + + private func makeTestee() -> LearnerDashboardSettingsViewModel { + let colorInteractor = LearnerDashboardColorInteractorLive(defaults: testDefaults) + return LearnerDashboardSettingsViewModel( + defaults: testDefaults, + colorInteractor: colorInteractor, + courseSettingsViewModel: makeCourseSettingsViewModel(), + environment: env + ) + } + + private func makeCourseSettingsViewModel() -> LearnerDashboardSettingsWidgetsSectionViewModel { + LearnerDashboardSettingsWidgetsSectionViewModel( + userDefaults: testDefaults, + configs: EditableWidgetIdentifier.makeDefaultConfigs(), + username: "", + onConfigsChanged: {} + ) + } } private final class MockViewController: UIViewController { diff --git a/Student/StudentUnitTests/LearnerDashboard/Settings/ViewModel/LearnerDashboardSettingsWidgetsSectionVMTests.swift b/Student/StudentUnitTests/LearnerDashboard/Settings/ViewModel/LearnerDashboardSettingsWidgetsSectionVMTests.swift new file mode 100644 index 0000000000..c5ab572a89 --- /dev/null +++ b/Student/StudentUnitTests/LearnerDashboard/Settings/ViewModel/LearnerDashboardSettingsWidgetsSectionVMTests.swift @@ -0,0 +1,258 @@ +// +// 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 . +// + +@testable import Core +@testable import Student +import XCTest + +final class LearnerDashboardSettingsWidgetsSectionVMTests: XCTestCase { + + private var testee: LearnerDashboardSettingsWidgetsSectionViewModel! + private var userDefaults: SessionDefaults! + private var onConfigsChangedCallCount = 0 + + override func setUp() { + super.setUp() + userDefaults = SessionDefaults(sessionID: "test-session") + userDefaults.reset() + onConfigsChangedCallCount = 0 + } + + override func tearDown() { + testee = nil + userDefaults.reset() + userDefaults = nil + super.tearDown() + } + + // MARK: - Initialization + + func test_init_splitsConfigsByVisibility() { + let configs = [ + DashboardWidgetConfig.make(id: .helloWidget, order: 0, isVisible: true), + DashboardWidgetConfig.make(id: .coursesAndGroups, order: 1, isVisible: false) + ] + + testee = makeTestee(configs: configs) + + XCTAssertEqual(testee.visibleConfigs.count, 1) + XCTAssertEqual(testee.visibleConfigs[0].id, .helloWidget) + XCTAssertEqual(testee.hiddenConfigs.count, 1) + XCTAssertEqual(testee.hiddenConfigs[0].id, .coursesAndGroups) + } + + func test_init_visibleConfigsSortedByOrder() { + let configs = [ + DashboardWidgetConfig.make(id: .coursesAndGroups, order: 0, isVisible: true), + DashboardWidgetConfig.make(id: .helloWidget, order: 1, isVisible: true) + ] + + testee = makeTestee(configs: configs) + + XCTAssertEqual(testee.visibleConfigs[0].id, .coursesAndGroups) + XCTAssertEqual(testee.visibleConfigs[1].id, .helloWidget) + } + + // MARK: - toggleVisibility + + func test_toggleVisibility_hidingVisibleWidget_movesToHidden() { + let configs = [ + DashboardWidgetConfig.make(id: .helloWidget, order: 0, isVisible: true), + DashboardWidgetConfig.make(id: .coursesAndGroups, order: 1, isVisible: true) + ] + testee = makeTestee(configs: configs) + + testee.toggleVisibility(of: testee.visibleConfigs[0], to: false) + + XCTAssertEqual(testee.visibleConfigs.count, 1) + XCTAssertEqual(testee.hiddenConfigs.count, 1) + XCTAssertEqual(testee.hiddenConfigs[0].id, .helloWidget) + } + + func test_toggleVisibility_showingHiddenWidget_movesToVisible() { + let configs = [ + DashboardWidgetConfig.make(id: .helloWidget, order: 0, isVisible: false), + DashboardWidgetConfig.make(id: .coursesAndGroups, order: 1, isVisible: true) + ] + testee = makeTestee(configs: configs) + + testee.toggleVisibility(of: testee.hiddenConfigs[0], to: true) + + XCTAssertEqual(testee.visibleConfigs.count, 2) + XCTAssertEqual(testee.hiddenConfigs.count, 0) + XCTAssertTrue(testee.visibleConfigs.contains { $0.id == .helloWidget }) + } + + func test_toggleVisibility_hidingVisibleWidget_insertsAtTopOfHidden() { + let configs = [ + DashboardWidgetConfig.make(id: .helloWidget, order: 0, isVisible: true), + DashboardWidgetConfig.make(id: .coursesAndGroups, order: 1, isVisible: false) + ] + testee = makeTestee(configs: configs) + + testee.toggleVisibility(of: testee.visibleConfigs[0], to: false) + + XCTAssertEqual(testee.hiddenConfigs[0].id, .helloWidget) + XCTAssertEqual(testee.hiddenConfigs[1].id, .coursesAndGroups) + } + + func test_toggleVisibility_savesToDefaultsAndCallsCallback() { + let configs = [ + DashboardWidgetConfig.make(id: .helloWidget, order: 0, isVisible: true), + DashboardWidgetConfig.make(id: .coursesAndGroups, order: 1, isVisible: true) + ] + testee = makeTestee(configs: configs) + + testee.toggleVisibility(of: testee.visibleConfigs[0], to: false) + + XCTAssertNotNil(userDefaults.learnerDashboardWidgetConfigs) + XCTAssertEqual(onConfigsChangedCallCount, 1) + } + + // MARK: - moveUp + + func test_moveUp_swapsWithPreviousConfig() { + let configs = [ + DashboardWidgetConfig.make(id: .helloWidget, order: 0, isVisible: true), + DashboardWidgetConfig.make(id: .coursesAndGroups, order: 1, isVisible: true) + ] + testee = makeTestee(configs: configs) + + testee.moveUp(testee.visibleConfigs[1]) + + XCTAssertEqual(testee.visibleConfigs[0].id, .coursesAndGroups) + XCTAssertEqual(testee.visibleConfigs[1].id, .helloWidget) + } + + func test_moveUp_firstElement_doesNothing() { + let configs = [ + DashboardWidgetConfig.make(id: .helloWidget, order: 0, isVisible: true), + DashboardWidgetConfig.make(id: .coursesAndGroups, order: 1, isVisible: true) + ] + testee = makeTestee(configs: configs) + + testee.moveUp(testee.visibleConfigs[0]) + + XCTAssertEqual(testee.visibleConfigs[0].id, .helloWidget) + XCTAssertEqual(testee.visibleConfigs[1].id, .coursesAndGroups) + XCTAssertEqual(onConfigsChangedCallCount, 0) + } + + // MARK: - moveDown + + func test_moveDown_swapsWithNextConfig() { + let configs = [ + DashboardWidgetConfig.make(id: .helloWidget, order: 0, isVisible: true), + DashboardWidgetConfig.make(id: .coursesAndGroups, order: 1, isVisible: true) + ] + testee = makeTestee(configs: configs) + + testee.moveDown(testee.visibleConfigs[0]) + + XCTAssertEqual(testee.visibleConfigs[0].id, .coursesAndGroups) + XCTAssertEqual(testee.visibleConfigs[1].id, .helloWidget) + } + + func test_moveDown_lastElement_doesNothing() { + let configs = [ + DashboardWidgetConfig.make(id: .helloWidget, order: 0, isVisible: true), + DashboardWidgetConfig.make(id: .coursesAndGroups, order: 1, isVisible: true) + ] + testee = makeTestee(configs: configs) + + testee.moveDown(testee.visibleConfigs[1]) + + XCTAssertEqual(testee.visibleConfigs[0].id, .helloWidget) + XCTAssertEqual(testee.visibleConfigs[1].id, .coursesAndGroups) + XCTAssertEqual(onConfigsChangedCallCount, 0) + } + + // MARK: - isMoveUpDisabled + + func test_isMoveUpDisabled_trueForFirstElement() { + let configs = [ + DashboardWidgetConfig.make(id: .helloWidget, order: 0, isVisible: true), + DashboardWidgetConfig.make(id: .coursesAndGroups, order: 1, isVisible: true) + ] + testee = makeTestee(configs: configs) + + XCTAssertTrue(testee.isMoveUpDisabled(of: testee.visibleConfigs[0])) + } + + func test_isMoveUpDisabled_falseForOtherElements() { + let configs = [ + DashboardWidgetConfig.make(id: .helloWidget, order: 0, isVisible: true), + DashboardWidgetConfig.make(id: .coursesAndGroups, order: 1, isVisible: true) + ] + testee = makeTestee(configs: configs) + + XCTAssertFalse(testee.isMoveUpDisabled(of: testee.visibleConfigs[1])) + } + + // MARK: - isMoveDownDisabled + + func test_isMoveDownDisabled_trueForLastElement() { + let configs = [ + DashboardWidgetConfig.make(id: .helloWidget, order: 0, isVisible: true), + DashboardWidgetConfig.make(id: .coursesAndGroups, order: 1, isVisible: true) + ] + testee = makeTestee(configs: configs) + + XCTAssertTrue(testee.isMoveDownDisabled(of: testee.visibleConfigs[1])) + } + + func test_isMoveDownDisabled_falseForOtherElements() { + let configs = [ + DashboardWidgetConfig.make(id: .helloWidget, order: 0, isVisible: true), + DashboardWidgetConfig.make(id: .coursesAndGroups, order: 1, isVisible: true) + ] + testee = makeTestee(configs: configs) + + XCTAssertFalse(testee.isMoveDownDisabled(of: testee.visibleConfigs[0])) + } + + // MARK: - saveAndNotify + + func test_saveAndNotify_updatesOrderAndPersists() { + let configs = [ + DashboardWidgetConfig.make(id: .helloWidget, order: 0, isVisible: true), + DashboardWidgetConfig.make(id: .coursesAndGroups, order: 1, isVisible: true) + ] + testee = makeTestee(configs: configs) + + testee.moveDown(testee.visibleConfigs[0]) + + let saved = userDefaults.learnerDashboardWidgetConfigs + XCTAssertNotNil(saved) + let coursesAndGroups = saved?.first { $0.id == .coursesAndGroups } + let helloWidget = saved?.first { $0.id == .helloWidget } + XCTAssertEqual(coursesAndGroups?.order, 0) + XCTAssertEqual(helloWidget?.order, 1) + } + + // MARK: - Private helpers + + private func makeTestee(configs: [DashboardWidgetConfig]) -> LearnerDashboardSettingsWidgetsSectionViewModel { + LearnerDashboardSettingsWidgetsSectionViewModel( + userDefaults: userDefaults, + configs: configs, + username: "Test User", + onConfigsChanged: { [weak self] in self?.onConfigsChangedCallCount += 1 } + ) + } +} diff --git a/Student/StudentUnitTests/LearnerDashboard/Widgets/Common/Model/DashboardWidgetIdentifierTests.swift b/Student/StudentUnitTests/LearnerDashboard/Widgets/Common/Model/DashboardWidgetIdentifierTests.swift new file mode 100644 index 0000000000..bbf1556cdb --- /dev/null +++ b/Student/StudentUnitTests/LearnerDashboard/Widgets/Common/Model/DashboardWidgetIdentifierTests.swift @@ -0,0 +1,148 @@ +// +// 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 . +// + +@testable import Core +@testable import Student +import XCTest + +final class DashboardWidgetIdentifierTests: StudentTestCase { + + // MARK: - EditableWidgetIdentifier.makeDefaultConfigs + + func test_makeDefaultConfigs_returnsOneConfigPerCase() { + let configs = EditableWidgetIdentifier.makeDefaultConfigs() + XCTAssertEqual(configs.count, EditableWidgetIdentifier.allCases.count) + } + + func test_makeDefaultConfigs_configsAreAllVisible() { + let configs = EditableWidgetIdentifier.makeDefaultConfigs() + XCTAssertTrue(configs.allSatisfy { $0.isVisible }) + } + + func test_makeDefaultConfigs_orderMatchesDeclarationOrder() { + let configs = EditableWidgetIdentifier.makeDefaultConfigs() + let expectedIDs = EditableWidgetIdentifier.allCases + + for (index, config) in configs.enumerated() { + XCTAssertEqual(config.id, expectedIDs[index]) + XCTAssertEqual(config.order, index) + } + } + + // MARK: - SystemWidgetIdentifier.makeViewModel + + func test_systemMakeViewModel_returnsViewModelWithMatchingID() { + let snackBarViewModel = SnackBarViewModel() + let coursesInteractor = CoursesInteractorLive(env: env) + + for identifier in SystemWidgetIdentifier.allCases { + let viewModel = identifier.makeViewModel( + snackBarViewModel: snackBarViewModel, + coursesInteractor: coursesInteractor + ) + XCTAssertEqual(viewModel.id, identifier.rawValue, "id mismatch for \(identifier)") + } + } + + func test_systemMakeViewModel_offlineSyncProgress_returnsCorrectType() { + let viewModel = SystemWidgetIdentifier.offlineSyncProgress.makeViewModel( + snackBarViewModel: SnackBarViewModel(), + coursesInteractor: CoursesInteractorLive(env: env) + ) + XCTAssertTrue(viewModel is OfflineSyncProgressWidgetViewModel) + } + + func test_systemMakeViewModel_fileUploadProgress_returnsCorrectType() { + let viewModel = SystemWidgetIdentifier.fileUploadProgress.makeViewModel( + snackBarViewModel: SnackBarViewModel(), + coursesInteractor: CoursesInteractorLive(env: env) + ) + XCTAssertTrue(viewModel is FileUploadProgressWidgetViewModel) + } + + func test_systemMakeViewModel_courseInvitations_returnsCorrectType() { + let viewModel = SystemWidgetIdentifier.courseInvitations.makeViewModel( + snackBarViewModel: SnackBarViewModel(), + coursesInteractor: CoursesInteractorLive(env: env) + ) + XCTAssertTrue(viewModel is CourseInvitationsWidgetViewModel) + } + + func test_systemMakeViewModel_globalAnnouncements_returnsCorrectType() { + let viewModel = SystemWidgetIdentifier.globalAnnouncements.makeViewModel( + snackBarViewModel: SnackBarViewModel(), + coursesInteractor: CoursesInteractorLive(env: env) + ) + XCTAssertTrue(viewModel is GlobalAnnouncementsWidgetViewModel) + } + + func test_systemMakeViewModel_conferences_returnsCorrectType() { + let viewModel = SystemWidgetIdentifier.conferences.makeViewModel( + snackBarViewModel: SnackBarViewModel(), + coursesInteractor: CoursesInteractorLive(env: env) + ) + XCTAssertTrue(viewModel is ConferencesWidgetViewModel) + } + + // MARK: - EditableWidgetIdentifier.makeViewModel + + func test_editableMakeViewModel_returnsViewModelWithMatchingID() { + let snackBarViewModel = SnackBarViewModel() + let coursesInteractor = CoursesInteractorLive(env: env) + + for identifier in EditableWidgetIdentifier.allCases { + let config = DashboardWidgetConfig(id: identifier, order: 0, isVisible: true) + let viewModel = identifier.makeViewModel( + config: config, + snackBarViewModel: snackBarViewModel, + coursesInteractor: coursesInteractor + ) + XCTAssertEqual(viewModel.id, identifier.rawValue, "id mismatch for \(identifier)") + } + } + + func test_editableMakeViewModel_helloWidget_returnsCorrectType() { + let config = DashboardWidgetConfig(id: .helloWidget, order: 0, isVisible: true) + let viewModel = EditableWidgetIdentifier.helloWidget.makeViewModel( + config: config, + snackBarViewModel: SnackBarViewModel(), + coursesInteractor: CoursesInteractorLive(env: env) + ) + XCTAssertTrue(viewModel is HelloWidgetViewModel) + } + + func test_editableMakeViewModel_coursesAndGroups_returnsCorrectType() { + let config = DashboardWidgetConfig(id: .coursesAndGroups, order: 0, isVisible: true) + let viewModel = EditableWidgetIdentifier.coursesAndGroups.makeViewModel( + config: config, + snackBarViewModel: SnackBarViewModel(), + coursesInteractor: CoursesInteractorLive(env: env) + ) + XCTAssertTrue(viewModel is CoursesAndGroupsWidgetViewModel) + } + + // MARK: - EditableWidgetIdentifier.makeSubSettingsView + + func test_makeSubSettingsView_helloWidget_returnsNil() { + XCTAssertNil(EditableWidgetIdentifier.helloWidget.makeSubSettingsView(env: env)) + } + + func test_makeSubSettingsView_coursesAndGroups_returnsView() { + XCTAssertNotNil(EditableWidgetIdentifier.coursesAndGroups.makeSubSettingsView(env: env)) + } +} diff --git a/Student/StudentUnitTests/LearnerDashboard/Widgets/Common/Model/SessionDefaults+DashboardWidgetConfigTests.swift b/Student/StudentUnitTests/LearnerDashboard/Widgets/Common/Model/SessionDefaults+DashboardWidgetConfigTests.swift index 58c5dd0f86..6e3ce83694 100644 --- a/Student/StudentUnitTests/LearnerDashboard/Widgets/Common/Model/SessionDefaults+DashboardWidgetConfigTests.swift +++ b/Student/StudentUnitTests/LearnerDashboard/Widgets/Common/Model/SessionDefaults+DashboardWidgetConfigTests.swift @@ -42,7 +42,7 @@ final class SessionDefaultsDashboardWidgetConfigTests: XCTestCase { } func test_getter_whenInvalidDataStored_shouldReturnNil() { - testee["dashboardWidgetConfigs"] = Data("invalid json".utf8) + testee["learnerDashboardWidgetConfigs"] = Data("invalid json".utf8) XCTAssertEqual(testee.learnerDashboardWidgetConfigs, nil) } @@ -53,7 +53,7 @@ final class SessionDefaultsDashboardWidgetConfigTests: XCTestCase { DashboardWidgetConfig(id: .coursesAndGroups, order: 42, isVisible: false, settings: nil) ] let data = try! JSONEncoder().encode(configs) - testee["dashboardWidgetConfigs"] = data + testee["learnerDashboardWidgetConfigs"] = data let result = testee.learnerDashboardWidgetConfigs @@ -78,7 +78,7 @@ final class SessionDefaultsDashboardWidgetConfigTests: XCTestCase { testee.learnerDashboardWidgetConfigs = configs - let storedData = testee["dashboardWidgetConfigs"] as? Data + let storedData = testee["learnerDashboardWidgetConfigs"] as? Data XCTAssertNotEqual(storedData, nil) let decoded = try! JSONDecoder().decode([DashboardWidgetConfig].self, from: storedData!) @@ -92,10 +92,10 @@ final class SessionDefaultsDashboardWidgetConfigTests: XCTestCase { func test_setter_withNil_shouldRemoveStoredData() { let configs = [DashboardWidgetConfig(id: .helloWidget, order: 7, isVisible: true)] testee.learnerDashboardWidgetConfigs = configs - XCTAssertNotNil(testee["dashboardWidgetConfigs"]) + XCTAssertNotNil(testee["learnerDashboardWidgetConfigs"]) testee.learnerDashboardWidgetConfigs = nil - XCTAssertNil(testee["dashboardWidgetConfigs"]) + XCTAssertNil(testee["learnerDashboardWidgetConfigs"]) } } diff --git a/Student/StudentUnitTests/LearnerDashboard/Widgets/ConferencesWidget/ViewModel/ConferencesWidgetViewModelTests.swift b/Student/StudentUnitTests/LearnerDashboard/Widgets/ConferencesWidget/ViewModel/ConferencesWidgetViewModelTests.swift index a3298e9571..c03e6f96ea 100644 --- a/Student/StudentUnitTests/LearnerDashboard/Widgets/ConferencesWidget/ViewModel/ConferencesWidgetViewModelTests.swift +++ b/Student/StudentUnitTests/LearnerDashboard/Widgets/ConferencesWidget/ViewModel/ConferencesWidgetViewModelTests.swift @@ -54,11 +54,9 @@ final class ConferencesWidgetViewModelTests: StudentTestCase { // MARK: - Initialization func test_init_shouldSetupCorrectly() { - let config = DashboardWidgetConfig(id: .conferences, order: 42, isVisible: true) - testee = makeViewModel(config: config) + testee = makeViewModel() - XCTAssertEqual(testee.config.id, .conferences) - XCTAssertEqual(testee.config.order, 42) + XCTAssertEqual(testee.id, SystemWidgetIdentifier.conferences.rawValue) XCTAssertEqual(testee.state, .loading) XCTAssertEqual(testee.conferences.isEmpty, true) } @@ -199,11 +197,8 @@ final class ConferencesWidgetViewModelTests: StudentTestCase { // MARK: - Private helpers - private func makeViewModel( - config: DashboardWidgetConfig = .make(id: .conferences) - ) -> ConferencesWidgetViewModel { + private func makeViewModel() -> ConferencesWidgetViewModel { ConferencesWidgetViewModel( - config: config, interactor: interactor, snackBarViewModel: snackBarViewModel, environment: env diff --git a/Student/StudentUnitTests/LearnerDashboard/Widgets/CourseInvitationsWidget/ViewModel/CourseInvitationsWidgetViewModelTests.swift b/Student/StudentUnitTests/LearnerDashboard/Widgets/CourseInvitationsWidget/ViewModel/CourseInvitationsWidgetViewModelTests.swift index 4cddcb90b9..0c81bc3e88 100644 --- a/Student/StudentUnitTests/LearnerDashboard/Widgets/CourseInvitationsWidget/ViewModel/CourseInvitationsWidgetViewModelTests.swift +++ b/Student/StudentUnitTests/LearnerDashboard/Widgets/CourseInvitationsWidget/ViewModel/CourseInvitationsWidgetViewModelTests.swift @@ -48,9 +48,7 @@ final class CourseInvitationsWidgetViewModelTests: StudentTestCase { // MARK: - Initialization Tests func testInit_stateIsLoading() { - let config = DashboardWidgetConfig(id: .courseInvitations, order: 0, isVisible: true) testee = CourseInvitationsWidgetViewModel( - config: config, interactor: mockInteractor, snackBarViewModel: snackBarViewModel ) @@ -59,9 +57,7 @@ final class CourseInvitationsWidgetViewModelTests: StudentTestCase { } func testInit_invitationsAreEmpty() { - let config = DashboardWidgetConfig(id: .courseInvitations, order: 0, isVisible: true) testee = CourseInvitationsWidgetViewModel( - config: config, interactor: mockInteractor, snackBarViewModel: snackBarViewModel ) @@ -72,9 +68,7 @@ final class CourseInvitationsWidgetViewModelTests: StudentTestCase { // MARK: - Refresh Success Cases func testRefresh_withNoInvitations_stateBecomesEmpty() { - let config = DashboardWidgetConfig(id: .courseInvitations, order: 0, isVisible: true) testee = CourseInvitationsWidgetViewModel( - config: config, interactor: mockInteractor, snackBarViewModel: snackBarViewModel ) @@ -86,9 +80,7 @@ final class CourseInvitationsWidgetViewModelTests: StudentTestCase { } func testRefresh_withInvitations_stateBecomesData() { - let config = DashboardWidgetConfig(id: .courseInvitations, order: 0, isVisible: true) testee = CourseInvitationsWidgetViewModel( - config: config, interactor: mockInteractor, snackBarViewModel: snackBarViewModel ) @@ -109,9 +101,7 @@ final class CourseInvitationsWidgetViewModelTests: StudentTestCase { } func testRefresh_createsCorrectNumberOfCardViewModels() { - let config = DashboardWidgetConfig(id: .courseInvitations, order: 0, isVisible: true) testee = CourseInvitationsWidgetViewModel( - config: config, interactor: mockInteractor, snackBarViewModel: snackBarViewModel ) @@ -128,9 +118,7 @@ final class CourseInvitationsWidgetViewModelTests: StudentTestCase { } func testRefresh_cardViewModelsHaveCorrectProperties() { - let config = DashboardWidgetConfig(id: .courseInvitations, order: 0, isVisible: true) testee = CourseInvitationsWidgetViewModel( - config: config, interactor: mockInteractor, snackBarViewModel: snackBarViewModel ) @@ -154,9 +142,7 @@ final class CourseInvitationsWidgetViewModelTests: StudentTestCase { } func testRefresh_passesIgnoreCacheParameter() { - let config = DashboardWidgetConfig(id: .courseInvitations, order: 0, isVisible: true) testee = CourseInvitationsWidgetViewModel( - config: config, interactor: mockInteractor, snackBarViewModel: snackBarViewModel ) @@ -169,9 +155,7 @@ final class CourseInvitationsWidgetViewModelTests: StudentTestCase { // MARK: - Refresh Filtering func testRefresh_onlyIncludesCoursesWithInvitedEnrollments() { - let config = DashboardWidgetConfig(id: .courseInvitations, order: 0, isVisible: true) testee = CourseInvitationsWidgetViewModel( - config: config, interactor: mockInteractor, snackBarViewModel: snackBarViewModel ) @@ -194,9 +178,7 @@ final class CourseInvitationsWidgetViewModelTests: StudentTestCase { } func testRefresh_skipsEnrollmentsWithoutIds() { - let config = DashboardWidgetConfig(id: .courseInvitations, order: 0, isVisible: true) testee = CourseInvitationsWidgetViewModel( - config: config, interactor: mockInteractor, snackBarViewModel: snackBarViewModel ) @@ -218,9 +200,7 @@ final class CourseInvitationsWidgetViewModelTests: StudentTestCase { } func testRefresh_mapsSectionNameFromCourseSectionID() { - let config = DashboardWidgetConfig(id: .courseInvitations, order: 0, isVisible: true) testee = CourseInvitationsWidgetViewModel( - config: config, interactor: mockInteractor, snackBarViewModel: snackBarViewModel ) @@ -245,9 +225,7 @@ final class CourseInvitationsWidgetViewModelTests: StudentTestCase { // MARK: - Refresh Failure func testRefresh_onError_stateBecomesError() { - let config = DashboardWidgetConfig(id: .courseInvitations, order: 0, isVisible: true) testee = CourseInvitationsWidgetViewModel( - config: config, interactor: mockInteractor, snackBarViewModel: snackBarViewModel ) @@ -260,9 +238,7 @@ final class CourseInvitationsWidgetViewModelTests: StudentTestCase { // MARK: - Titles func testTitle_showsCorrectCount() { - let config = DashboardWidgetConfig(id: .courseInvitations, order: 0, isVisible: true) testee = CourseInvitationsWidgetViewModel( - config: config, interactor: mockInteractor, snackBarViewModel: snackBarViewModel ) @@ -279,9 +255,7 @@ final class CourseInvitationsWidgetViewModelTests: StudentTestCase { } func testAccessibilityTitle_includesFormattedCount() { - let config = DashboardWidgetConfig(id: .courseInvitations, order: 0, isVisible: true) testee = CourseInvitationsWidgetViewModel( - config: config, interactor: mockInteractor, snackBarViewModel: snackBarViewModel ) @@ -300,9 +274,7 @@ final class CourseInvitationsWidgetViewModelTests: StudentTestCase { // MARK: - Invitation Removal func testRemoveInvitation_removesFromList() { - let config = DashboardWidgetConfig(id: .courseInvitations, order: 0, isVisible: true) testee = CourseInvitationsWidgetViewModel( - config: config, interactor: mockInteractor, snackBarViewModel: snackBarViewModel ) @@ -330,9 +302,7 @@ final class CourseInvitationsWidgetViewModelTests: StudentTestCase { } func testRemoveInvitation_lastRemoval_stateBecomesEmpty() { - let config = DashboardWidgetConfig(id: .courseInvitations, order: 0, isVisible: true) testee = CourseInvitationsWidgetViewModel( - config: config, interactor: mockInteractor, snackBarViewModel: snackBarViewModel ) @@ -359,9 +329,7 @@ final class CourseInvitationsWidgetViewModelTests: StudentTestCase { } func testRemoveInvitation_nonLastRemoval_stateStaysData() { - let config = DashboardWidgetConfig(id: .courseInvitations, order: 0, isVisible: true) testee = CourseInvitationsWidgetViewModel( - config: config, interactor: mockInteractor, snackBarViewModel: snackBarViewModel ) @@ -384,9 +352,7 @@ final class CourseInvitationsWidgetViewModelTests: StudentTestCase { // MARK: - Layout Identifier func testLayoutIdentifier_changesWithStateAndCount() { - let config = DashboardWidgetConfig(id: .courseInvitations, order: 0, isVisible: true) testee = CourseInvitationsWidgetViewModel( - config: config, interactor: mockInteractor, snackBarViewModel: snackBarViewModel ) diff --git a/Student/StudentUnitTests/LearnerDashboard/Widgets/CoursesAndGroupsWidget/ViewModel/CoursesAndGroupsWidgetSettingsViewModelTests.swift b/Student/StudentUnitTests/LearnerDashboard/Widgets/CoursesAndGroupsWidget/ViewModel/CoursesAndGroupsWidgetSettingsViewModelTests.swift new file mode 100644 index 0000000000..f9dd77292f --- /dev/null +++ b/Student/StudentUnitTests/LearnerDashboard/Widgets/CoursesAndGroupsWidget/ViewModel/CoursesAndGroupsWidgetSettingsViewModelTests.swift @@ -0,0 +1,93 @@ +// +// 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 . +// + +@testable import Core +@testable import Student +import TestsFoundation +import XCTest + +final class CoursesAndGroupsWidgetSettingsViewModelTests: StudentTestCase { + + private var testee: CoursesAndGroupsWidgetSettingsViewModel! + + override func tearDown() { + testee = nil + super.tearDown() + } + + // MARK: - Initialization - showGrades + + func test_init_showGrades_readsFromUserDefaults_true() { + env.userDefaults?.showGradesOnDashboard = true + + testee = CoursesAndGroupsWidgetSettingsViewModel(env: env) + + XCTAssertEqual(testee.showGrades, true) + } + + func test_init_showGrades_readsFromUserDefaults_false() { + env.userDefaults?.showGradesOnDashboard = false + + testee = CoursesAndGroupsWidgetSettingsViewModel(env: env) + + XCTAssertEqual(testee.showGrades, false) + } + + // MARK: - Initialization - showColorOverlay + + func test_init_showColorOverlay_readsFromCoreData() { + _ = UserSettings.save(.make(hide_dashcard_color_overlays: true), in: databaseClient) + + testee = CoursesAndGroupsWidgetSettingsViewModel(env: env) + + XCTAssertEqual(testee.showColorOverlay, false) + } + + // MARK: - setShowGrades + + func test_setShowGrades_persistsToUserDefaults() { + env.userDefaults?.showGradesOnDashboard = false + testee = CoursesAndGroupsWidgetSettingsViewModel(env: env) + + testee.showGrades = true + + XCTAssertEqual(env.userDefaults?.showGradesOnDashboard, true) + } + + // MARK: - setShowColorOverlay + + func test_setShowColorOverlay_makesApiCall() { + testee = CoursesAndGroupsWidgetSettingsViewModel(env: env) + + let apiExpectation = expectation(description: "PUT users/self/settings called") + let request = PutUserSettingsRequest( + manual_mark_as_read: nil, + collapse_global_nav: nil, + hide_dashcard_color_overlays: true, + comment_library_suggestions_enabled: nil + ) + api.mock(request) { _ in + apiExpectation.fulfill() + return (nil, nil, nil) + } + + testee.showColorOverlay = false + + waitForExpectations(timeout: 1) + } +} diff --git a/Student/StudentUnitTests/LearnerDashboard/Widgets/FileUploadProgressWidget/ViewModel/FileUploadProgressWidgetViewModelTests.swift b/Student/StudentUnitTests/LearnerDashboard/Widgets/FileUploadProgressWidget/ViewModel/FileUploadProgressWidgetViewModelTests.swift index e63e4f27cd..7194bd1d1e 100644 --- a/Student/StudentUnitTests/LearnerDashboard/Widgets/FileUploadProgressWidget/ViewModel/FileUploadProgressWidgetViewModelTests.swift +++ b/Student/StudentUnitTests/LearnerDashboard/Widgets/FileUploadProgressWidget/ViewModel/FileUploadProgressWidgetViewModelTests.swift @@ -51,7 +51,6 @@ final class FileUploadProgressWidgetViewModelTests: StudentTestCase { func testInit_setsInitialState() { testee = FileUploadProgressWidgetViewModel( - config: .init(id: .fileUploadProgress, order: 1, isVisible: true), router: widgetRouter, listViewModel: listViewModel ) @@ -64,7 +63,6 @@ final class FileUploadProgressWidgetViewModelTests: StudentTestCase { func testUploadCards_emptyWhenNoSubmissions() { testee = FileUploadProgressWidgetViewModel( - config: .init(id: .fileUploadProgress, order: 1, isVisible: true), router: widgetRouter, listViewModel: listViewModel ) @@ -90,7 +88,6 @@ final class FileUploadProgressWidgetViewModelTests: StudentTestCase { try? context.save() testee = FileUploadProgressWidgetViewModel( - config: .init(id: .fileUploadProgress, order: 1, isVisible: true), router: widgetRouter, listViewModel: listViewModel ) @@ -120,7 +117,6 @@ final class FileUploadProgressWidgetViewModelTests: StudentTestCase { try? context.save() testee = FileUploadProgressWidgetViewModel( - config: .init(id: .fileUploadProgress, order: 1, isVisible: true), router: widgetRouter, listViewModel: listViewModel ) @@ -146,7 +142,6 @@ final class FileUploadProgressWidgetViewModelTests: StudentTestCase { try? context.save() testee = FileUploadProgressWidgetViewModel( - config: .init(id: .fileUploadProgress, order: 1, isVisible: true), router: widgetRouter, listViewModel: listViewModel ) @@ -174,7 +169,6 @@ final class FileUploadProgressWidgetViewModelTests: StudentTestCase { try? context.save() testee = FileUploadProgressWidgetViewModel( - config: .init(id: .fileUploadProgress, order: 1, isVisible: true), router: widgetRouter, listViewModel: listViewModel ) @@ -203,7 +197,6 @@ final class FileUploadProgressWidgetViewModelTests: StudentTestCase { try? context.save() testee = FileUploadProgressWidgetViewModel( - config: .init(id: .fileUploadProgress, order: 1, isVisible: true), router: widgetRouter, listViewModel: listViewModel ) @@ -225,7 +218,6 @@ final class FileUploadProgressWidgetViewModelTests: StudentTestCase { func testRefresh_returnsImmediately() { testee = FileUploadProgressWidgetViewModel( - config: .init(id: .fileUploadProgress, order: 1, isVisible: true), router: widgetRouter, listViewModel: listViewModel ) diff --git a/Student/StudentUnitTests/LearnerDashboard/Widgets/GlobalAnnouncementsWidget/ViewModel/GlobalAnnouncementsWidgetViewModelTests.swift b/Student/StudentUnitTests/LearnerDashboard/Widgets/GlobalAnnouncementsWidget/ViewModel/GlobalAnnouncementsWidgetViewModelTests.swift index ace054db5e..8a51f1b4e7 100644 --- a/Student/StudentUnitTests/LearnerDashboard/Widgets/GlobalAnnouncementsWidget/ViewModel/GlobalAnnouncementsWidgetViewModelTests.swift +++ b/Student/StudentUnitTests/LearnerDashboard/Widgets/GlobalAnnouncementsWidget/ViewModel/GlobalAnnouncementsWidgetViewModelTests.swift @@ -57,12 +57,9 @@ final class GlobalAnnouncementsWidgetViewModelTests: StudentTestCase { // MARK: - Initialization func test_init_shouldSetupCorrectly() { - testee = makeViewModel( - config: .make(id: .globalAnnouncements, order: 42) - ) + testee = makeViewModel() - XCTAssertEqual(testee.config.id, .globalAnnouncements) - XCTAssertEqual(testee.config.order, 42) + XCTAssertEqual(testee.id, SystemWidgetIdentifier.globalAnnouncements.rawValue) XCTAssertEqual(testee.state, .loading) XCTAssertEqual(testee.announcements, []) @@ -217,11 +214,8 @@ final class GlobalAnnouncementsWidgetViewModelTests: StudentTestCase { // MARK: - Private helpers - private func makeViewModel( - config: DashboardWidgetConfig = .make(id: .globalAnnouncements) - ) -> GlobalAnnouncementsWidgetViewModel { + private func makeViewModel() -> GlobalAnnouncementsWidgetViewModel { GlobalAnnouncementsWidgetViewModel( - config: config, interactor: interactor, environment: env ) diff --git a/Student/StudentUnitTests/LearnerDashboard/Widgets/OfflineSyncProgressWidget/ViewModel/OfflineSyncProgressWidgetViewModelTests.swift b/Student/StudentUnitTests/LearnerDashboard/Widgets/OfflineSyncProgressWidget/ViewModel/OfflineSyncProgressWidgetViewModelTests.swift index 0296437e24..73f9286fe4 100644 --- a/Student/StudentUnitTests/LearnerDashboard/Widgets/OfflineSyncProgressWidget/ViewModel/OfflineSyncProgressWidgetViewModelTests.swift +++ b/Student/StudentUnitTests/LearnerDashboard/Widgets/OfflineSyncProgressWidget/ViewModel/OfflineSyncProgressWidgetViewModelTests.swift @@ -53,7 +53,6 @@ final class OfflineSyncProgressWidgetViewModelTests: StudentTestCase { func testInit_setsInitialState() { testee = OfflineSyncProgressWidgetViewModel( - config: .init(id: .offlineSyncProgress, order: 1, isVisible: true), dashboardViewModel: dashboardViewModel ) @@ -66,7 +65,6 @@ final class OfflineSyncProgressWidgetViewModelTests: StudentTestCase { func testInit_subscribesToDashboardViewModel() { testee = OfflineSyncProgressWidgetViewModel( - config: .init(id: .offlineSyncProgress, order: 1, isVisible: true), dashboardViewModel: dashboardViewModel ) @@ -78,7 +76,6 @@ final class OfflineSyncProgressWidgetViewModelTests: StudentTestCase { func testStateMapping_hiddenMapsToEmpty() { testee = OfflineSyncProgressWidgetViewModel( - config: .init(id: .offlineSyncProgress, order: 1, isVisible: true), dashboardViewModel: dashboardViewModel ) @@ -94,7 +91,6 @@ final class OfflineSyncProgressWidgetViewModelTests: StudentTestCase { func testDismiss_callsDashboardViewModelDismiss() { testee = OfflineSyncProgressWidgetViewModel( - config: .init(id: .offlineSyncProgress, order: 1, isVisible: true), dashboardViewModel: dashboardViewModel ) @@ -113,7 +109,6 @@ final class OfflineSyncProgressWidgetViewModelTests: StudentTestCase { func testCardTapped_callsDashboardViewModelCardTap() { testee = OfflineSyncProgressWidgetViewModel( - config: .init(id: .offlineSyncProgress, order: 1, isVisible: true), dashboardViewModel: dashboardViewModel ) @@ -133,7 +128,6 @@ final class OfflineSyncProgressWidgetViewModelTests: StudentTestCase { func testRefresh_returnsImmediately() { testee = OfflineSyncProgressWidgetViewModel( - config: .init(id: .offlineSyncProgress, order: 1, isVisible: true), dashboardViewModel: dashboardViewModel )