diff --git a/Core/Core/Common/CommonModels/Analytics/PendoAnalyticsTracker.swift b/Core/Core/Common/CommonModels/Analytics/PendoAnalyticsTracker.swift
index c5847fd29c..cad27d0d15 100644
--- a/Core/Core/Common/CommonModels/Analytics/PendoAnalyticsTracker.swift
+++ b/Core/Core/Common/CommonModels/Analytics/PendoAnalyticsTracker.swift
@@ -44,10 +44,20 @@ public final class PendoAnalyticsTracker {
// MARK: Initialization
+ public final class PendoManagerMock: PendoManagerWrapper {
+ public init() {}
+ public func initWith(_ url: URL) { }
+ public func setup(_ appKey: String) { }
+ public func startSession(_ visitorId: String?, accountId: String?, visitorData: [AnyHashable: Any]?, accountData: [AnyHashable: Any]?) { }
+ public func endSession() { }
+ public func track(_ event: String, properties: [AnyHashable: Any]?) { }
+ }
+
public init(
environment: AppEnvironment,
interactor: AnalyticsMetadataInteractor = AnalyticsMetadataInteractorLive(),
- pendoManager: PendoManagerWrapper = PendoManager.shared(),
+// pendoManager: PendoManagerWrapper = PendoManager.shared(),
+ pendoManager: PendoManagerWrapper = PendoManagerMock(),
pendoApiKey: String? = nil
) {
self.environment = environment
diff --git a/Core/Core/Common/CommonModels/AppEnvironment/SessionDefaults.swift b/Core/Core/Common/CommonModels/AppEnvironment/SessionDefaults.swift
index 4f163a273e..78627797c3 100644
--- a/Core/Core/Common/CommonModels/AppEnvironment/SessionDefaults.swift
+++ b/Core/Core/Common/CommonModels/AppEnvironment/SessionDefaults.swift
@@ -19,6 +19,9 @@
import UIKit
public struct SessionDefaults: Equatable {
+
+ // MARK: - Public Interface
+
/**
This is a shared session storage with an empty string as `sessionID`.
Can be used for testing/preview/fallback purposes.
@@ -26,6 +29,40 @@ public struct SessionDefaults: Equatable {
public static let fallback = SessionDefaults(sessionID: "")
public let sessionID: String
+ /// The underlying UserDefaults instance used for storage.
+ /// Automatically configured to use the app group suite for sharing data between app and extensions.
+ public var userDefaults: UserDefaults {
+ UserDefaults(suiteName: Bundle.main.appGroupID()) ?? .standard
+ }
+
+ /// The session-specific storage dictionary, keyed by the current session ID.
+ /// All session data is stored under this dictionary to ensure proper user isolation.
+ public var sessionDefaults: [String: Any]? {
+ get { userDefaults.dictionary(forKey: sessionID) }
+ set { userDefaults.set(newValue, forKey: sessionID) }
+ }
+
+ public mutating func reset() {
+ sessionDefaults = nil
+ }
+
+ /// Provides direct access to session-specific storage using a key-value pattern.
+ /// Values are automatically scoped to the current user session.
+ public subscript(key: String) -> Any? {
+ get { return sessionDefaults?[key] }
+ set {
+ var defaults = sessionDefaults ?? [:]
+ if let value = newValue {
+ defaults[key] = value
+ } else {
+ defaults.removeValue(forKey: key)
+ }
+ sessionDefaults = defaults
+ }
+ }
+
+ // MARK: - Mixed Feature Settings
+
/** This property is used by the file share extension to automatically select the course of the last viewed file in the app. The use-case is that the user views the assignment's file in the app, saves it to iOS Photos app, annotates it there and shares it back to the assignment. */
public var submitAssignmentCourseID: String? {
get { return self["submitAssignmentCourseID"] as? String }
@@ -241,32 +278,6 @@ public struct SessionDefaults: Equatable {
}
}
- public mutating func reset() {
- sessionDefaults = nil
- }
-
- private subscript(key: String) -> Any? {
- get { return sessionDefaults?[key] }
- set {
- var defaults = sessionDefaults ?? [:]
- if let value = newValue {
- defaults[key] = value
- } else {
- defaults.removeValue(forKey: key)
- }
- sessionDefaults = defaults
- }
- }
-
- private var userDefaults: UserDefaults {
- return UserDefaults(suiteName: Bundle.main.appGroupID()) ?? .standard
- }
-
- private var sessionDefaults: [String: Any]? {
- get { return userDefaults.dictionary(forKey: sessionID) }
- set { userDefaults.set(newValue, forKey: sessionID) }
- }
-
// MARK: - Grades
public var selectedSortByOptionIDs: [String: String]? {
get { self["selectedSortByOptionIDs"] as? [String: String] }
diff --git a/Core/Core/Resources/Assets.xcassets/Secrets/Contents.json b/Core/Core/Resources/Assets.xcassets/Secrets/Contents.json
index da4a164c91..73c00596a7 100644
--- a/Core/Core/Resources/Assets.xcassets/Secrets/Contents.json
+++ b/Core/Core/Resources/Assets.xcassets/Secrets/Contents.json
@@ -1,6 +1,6 @@
{
"info" : {
- "version" : 1,
- "author" : "xcode"
+ "author" : "xcode",
+ "version" : 1
}
-}
\ No newline at end of file
+}
diff --git a/Student/Student/LearnerDashboard/LearnerDashboardAssembly.swift b/Student/Student/LearnerDashboard/Container/LearnerDashboardAssembly.swift
similarity index 90%
rename from Student/Student/LearnerDashboard/LearnerDashboardAssembly.swift
rename to Student/Student/LearnerDashboard/Container/LearnerDashboardAssembly.swift
index fb16ce5b4c..6d51fabf8c 100644
--- a/Student/Student/LearnerDashboard/LearnerDashboardAssembly.swift
+++ b/Student/Student/LearnerDashboard/Container/LearnerDashboardAssembly.swift
@@ -19,7 +19,9 @@
enum LearnerDashboardAssembly {
static func makeInteractor() -> LearnerDashboardInteractor {
- LearnerDashboardInteractorLive()
+ LearnerDashboardInteractorLive(
+ widgetViewModelFactory: LearnerDashboardWidgetAssembly.makeWidgetViewModel
+ )
}
static func makeViewModel(interactor: LearnerDashboardInteractor) -> LearnerDashboardViewModel {
diff --git a/Student/Student/LearnerDashboard/Container/Model/LearnerDashboardInteractor.swift b/Student/Student/LearnerDashboard/Container/Model/LearnerDashboardInteractor.swift
new file mode 100644
index 0000000000..b6ebd17135
--- /dev/null
+++ b/Student/Student/LearnerDashboard/Container/Model/LearnerDashboardInteractor.swift
@@ -0,0 +1,58 @@
+//
+// 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 Combine
+import Core
+import Foundation
+
+protocol LearnerDashboardInteractor {
+ func loadWidgets() -> AnyPublisher<(fullWidth: [any LearnerWidgetViewModel], grid: [any LearnerWidgetViewModel]), Never>
+}
+
+final class LearnerDashboardInteractorLive: LearnerDashboardInteractor {
+ private let userDefaults: SessionDefaults
+ private let widgetViewModelFactory: (WidgetConfig) -> any LearnerWidgetViewModel
+
+ init(
+ userDefaults: SessionDefaults = AppEnvironment.shared.userDefaults ?? .fallback,
+ widgetViewModelFactory: @escaping (WidgetConfig) -> any LearnerWidgetViewModel
+ ) {
+ self.userDefaults = userDefaults
+ self.widgetViewModelFactory = widgetViewModelFactory
+ }
+
+ func loadWidgets() -> AnyPublisher<(fullWidth: [any LearnerWidgetViewModel], grid: [any LearnerWidgetViewModel]), Never> {
+ Just(())
+ .subscribe(on: DispatchQueue.global(qos: .userInitiated))
+ .map { [userDefaults, widgetViewModelFactory] _ in
+ let configs: [WidgetConfig]
+ if let savedWidgets = userDefaults.learnerDashboardWidgetConfigs {
+ configs = savedWidgets.filter { $0.isVisible }.sorted()
+ } else {
+ configs = LearnerDashboardWidgetAssembly.makeDefaultWidgetConfigs()
+ }
+
+ let viewModels = configs.map { widgetViewModelFactory($0) }
+ let fullWidth = viewModels.filter { $0.isFullWidth }
+ let grid = viewModels.filter { !$0.isFullWidth }
+
+ return (fullWidth, grid)
+ }
+ .eraseToAnyPublisher()
+ }
+}
diff --git a/Student/Student/LearnerDashboard/View/LearnerDashboardScreen.swift b/Student/Student/LearnerDashboard/Container/View/LearnerDashboardScreen.swift
similarity index 85%
rename from Student/Student/LearnerDashboard/View/LearnerDashboardScreen.swift
rename to Student/Student/LearnerDashboard/Container/View/LearnerDashboardScreen.swift
index 01af95357f..95128b2b0b 100644
--- a/Student/Student/LearnerDashboard/View/LearnerDashboardScreen.swift
+++ b/Student/Student/LearnerDashboard/Container/View/LearnerDashboardScreen.swift
@@ -33,7 +33,10 @@ struct LearnerDashboardScreen: View {
state: viewModel.state,
config: viewModel.screenConfig,
refreshAction: { completion in
- viewModel.refresh(ignoreCache: true, completion: completion)
+ viewModel.refresh(
+ ignoreCache: true,
+ completion: completion
+ )
}
) { _ in
content
@@ -57,7 +60,11 @@ struct LearnerDashboardScreen: View {
@ViewBuilder
private var content: some View {
- SwiftUI.EmptyView()
+ LearnerDashboardWidgetLayout(
+ fullWidthWidgets: viewModel.fullWidthWidgets,
+ gridWidgets: viewModel.gridWidgets
+ )
+ .paddingStyle(.all, .standard)
}
}
@@ -65,11 +72,7 @@ struct LearnerDashboardScreen: View {
#Preview {
let controller = CoreHostingController(
- LearnerDashboardScreen(
- viewModel: LearnerDashboardViewModel(
- interactor: LearnerDashboardInteractorLive()
- )
- )
+ LearnerDashboardAssembly.makeScreen()
)
CoreNavigationController(rootViewController: controller)
}
diff --git a/Student/Student/LearnerDashboard/ViewModel/LearnerDashboardViewModel.swift b/Student/Student/LearnerDashboard/Container/ViewModel/LearnerDashboardViewModel.swift
similarity index 69%
rename from Student/Student/LearnerDashboard/ViewModel/LearnerDashboardViewModel.swift
rename to Student/Student/LearnerDashboard/Container/ViewModel/LearnerDashboardViewModel.swift
index 3ccbc089dd..bd3b4e2a4a 100644
--- a/Student/Student/LearnerDashboard/ViewModel/LearnerDashboardViewModel.swift
+++ b/Student/Student/LearnerDashboard/Container/ViewModel/LearnerDashboardViewModel.swift
@@ -25,9 +25,12 @@ import Observation
@Observable
final class LearnerDashboardViewModel {
private(set) var state: InstUI.ScreenState = .loading
+ private(set) var fullWidthWidgets: [any LearnerWidgetViewModel] = []
+ private(set) var gridWidgets: [any LearnerWidgetViewModel] = []
let screenConfig = InstUI.BaseScreenConfig(
refreshable: true,
+ showsScrollIndicators: false,
emptyPandaConfig: .init(
scene: SpacePanda(),
title: String(localized: "Welcome to Canvas!", bundle: .student),
@@ -48,17 +51,35 @@ final class LearnerDashboardViewModel {
) {
self.interactor = interactor
self.mainScheduler = mainScheduler
- refresh(ignoreCache: false)
+
+ loadWidgets()
+ }
+
+ private func loadWidgets() {
+ interactor.loadWidgets()
+ .receive(on: mainScheduler)
+ .sink { [weak self] result in
+ guard let self else { return }
+ self.fullWidthWidgets = result.fullWidth
+ self.gridWidgets = result.grid
+ if !result.fullWidth.isEmpty || !result.grid.isEmpty {
+ self.state = .data
+ }
+ self.refresh(ignoreCache: false)
+ }
+ .store(in: &subscriptions)
}
func refresh(ignoreCache: Bool, completion: (() -> Void)? = nil) {
- interactor.refresh(ignoreCache: ignoreCache)
+ let allWidgets = fullWidthWidgets + gridWidgets
+ let publishers = allWidgets.map { $0.refresh(ignoreCache: ignoreCache) }
+
+ Publishers.MergeMany(publishers)
+ .collect()
.receive(on: mainScheduler)
.sink { [weak self] _ in
- guard let self else { return }
- self.state = .empty
+ guard self != nil else { return }
completion?()
- } receiveValue: { _ in
}
.store(in: &subscriptions)
}
diff --git a/Student/Student/LearnerDashboard/Widgets/Common/Model/LearnerWidgetViewModel.swift b/Student/Student/LearnerDashboard/Widgets/Common/Model/LearnerWidgetViewModel.swift
new file mode 100644
index 0000000000..6b1ee75253
--- /dev/null
+++ b/Student/Student/LearnerDashboard/Widgets/Common/Model/LearnerWidgetViewModel.swift
@@ -0,0 +1,57 @@
+//
+// 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 Combine
+import Core
+import SwiftUI
+
+protocol LearnerWidgetViewModel: AnyObject, Identifiable where ID == LearnerDashboardWidgetIdentifier {
+ associatedtype ViewType: View
+
+ var id: LearnerDashboardWidgetIdentifier { get }
+
+ /// User configurable widget settings.
+ var config: WidgetConfig { get }
+
+ /// Non-editable, widget specific property used for layouting.
+ /// Full width widgets are put at the top of the screen outside of the widget grid.
+ var isFullWidth: Bool { get }
+
+ var isEditable: Bool { get }
+
+ /// The state helps the dashboard screen to decide if the empty state should be shown or not.
+ var state: InstUI.ScreenState { get }
+
+ /// Used by the layout to detect when widget size might change and trigger smooth animations.
+ /// Override this property to include any size-affecting properties (e.g., text.count).
+ /// This is required because widget view models are stored in an array and SwiftUI can't observe
+ /// individual view model changes in the array.
+ /// Default implementation returns state.
+ var layoutIdentifier: AnyHashable { get }
+
+ func makeView() -> ViewType
+
+ /// When pull to refresh is performed on the dashboard each widget is asked to refresh their content.
+ func refresh(ignoreCache: Bool) -> AnyPublisher
+}
+
+extension LearnerWidgetViewModel {
+ var layoutIdentifier: AnyHashable {
+ AnyHashable(state)
+ }
+}
diff --git a/Student/Student/LearnerDashboard/Widgets/Common/Model/SessionDefaults+WidgetConfig.swift b/Student/Student/LearnerDashboard/Widgets/Common/Model/SessionDefaults+WidgetConfig.swift
new file mode 100644
index 0000000000..1abc715035
--- /dev/null
+++ b/Student/Student/LearnerDashboard/Widgets/Common/Model/SessionDefaults+WidgetConfig.swift
@@ -0,0 +1,40 @@
+//
+// 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 var learnerDashboardWidgetConfigsKey: String { "learnerDashboardWidgetConfigs" }
+
+ var learnerDashboardWidgetConfigs: [WidgetConfig]? {
+ get {
+ guard let data = self[learnerDashboardWidgetConfigsKey] as? Data else {
+ return nil
+ }
+ return try? JSONDecoder().decode([WidgetConfig].self, from: data)
+ }
+ set {
+ if let newValue, let data = try? JSONEncoder().encode(newValue) {
+ self[learnerDashboardWidgetConfigsKey] = data
+ } else {
+ self[learnerDashboardWidgetConfigsKey] = nil
+ }
+ }
+ }
+}
diff --git a/Student/Student/LearnerDashboard/Widgets/Common/Model/WidgetConfig.swift b/Student/Student/LearnerDashboard/Widgets/Common/Model/WidgetConfig.swift
new file mode 100644
index 0000000000..cfd44e555c
--- /dev/null
+++ b/Student/Student/LearnerDashboard/Widgets/Common/Model/WidgetConfig.swift
@@ -0,0 +1,39 @@
+//
+// 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 .
+//
+
+struct WidgetConfig: Codable, Comparable, Identifiable {
+ let id: LearnerDashboardWidgetIdentifier
+ var order: Int
+ var isVisible: Bool
+ /// Widget-specific settings encoded into a JSON to be persisted.
+ var settings: String?
+
+ static func < (lhs: WidgetConfig, rhs: WidgetConfig) -> Bool {
+ lhs.order < rhs.order
+ }
+}
+
+extension Array where Element == WidgetConfig {
+ func partitionedByLayout(
+ isFullWidth: (WidgetConfig) -> Bool
+ ) -> (fullWidth: [WidgetConfig], grid: [WidgetConfig]) {
+ let fullWidth = filter { isFullWidth($0) }.sorted()
+ let grid = filter { !isFullWidth($0) }.sorted()
+ return (fullWidth, grid)
+ }
+}
diff --git a/Student/Student/LearnerDashboard/Widgets/Common/Model/WidgetPlaceholderData.swift b/Student/Student/LearnerDashboard/Widgets/Common/Model/WidgetPlaceholderData.swift
new file mode 100644
index 0000000000..17365aa9c0
--- /dev/null
+++ b/Student/Student/LearnerDashboard/Widgets/Common/Model/WidgetPlaceholderData.swift
@@ -0,0 +1,38 @@
+//
+// 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 WidgetPlaceholderData {
+ static let short = "Lorem ipsum dolor sit amet"
+ static let medium = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Etiam tincidunt rhoncus"
+ static let long = """
+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Etiam tincidunt rhoncus\
+ rutrum. Donec tempus vulputate posuere. Aenean blandit nunc vitae tempus sodales.\
+ In vehicula venenatis tempus. In pharetra aliquet neque, non viverra massa sodales eget.\
+ Etiam hendrerit tincidunt placerat. Suspendisse et lacus a metus tempor gravida.
+ New line!
+ """
+
+ static func long(_ multiplier: Int) -> String {
+ guard multiplier > 0 else { return long }
+
+ return Array(repeating: long, count: multiplier)
+ .joined(separator: "\n")
+ }
+}
diff --git a/Student/Student/LearnerDashboard/Widgets/Common/View/LearnerDashboardCard.swift b/Student/Student/LearnerDashboard/Widgets/Common/View/LearnerDashboardCard.swift
new file mode 100644
index 0000000000..97efff2b89
--- /dev/null
+++ b/Student/Student/LearnerDashboard/Widgets/Common/View/LearnerDashboardCard.swift
@@ -0,0 +1,50 @@
+//
+// 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 SwiftUI
+import Core
+
+struct LearnerDashboardCard: View {
+ let content: Content
+
+ init(@ViewBuilder content: () -> Content) {
+ self.content = content()
+ }
+
+ var body: some View {
+ // The purpose of this layout is to keep the widget's border
+ // on screen while the content's size changes. Instead of the border
+ // fading with the states, it stays on screen and just resizes
+ // to the new content's size.
+ ZStack {
+ content
+ }
+ .elevation(.cardLarge, background: .backgroundLightest)
+ }
+}
+
+#if DEBUG
+
+#Preview {
+ LearnerDashboardCard {
+ Text(verbatim: "Hello")
+ .padding(50)
+ }
+}
+
+#endif
diff --git a/Student/Student/LearnerDashboard/Widgets/Common/View/LearnerDashboardTitledWidget.swift b/Student/Student/LearnerDashboard/Widgets/Common/View/LearnerDashboardTitledWidget.swift
new file mode 100644
index 0000000000..a7fd4dc03f
--- /dev/null
+++ b/Student/Student/LearnerDashboard/Widgets/Common/View/LearnerDashboardTitledWidget.swift
@@ -0,0 +1,52 @@
+//
+// 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 SwiftUI
+
+struct LearnerDashboardTitledWidget: View {
+ let title: String
+ let content: Content
+
+ init(_ title: String, @ViewBuilder content: () -> Content) {
+ self.title = title
+ self.content = content()
+ }
+
+ var body: some View {
+ VStack(
+ alignment: .leading,
+ spacing: InstUI.Styles.Padding.sectionHeaderVertical.rawValue
+ ) {
+ Text(title)
+ .font(.regular14, lineHeight: .fit)
+ .foregroundColor(.textDarkest)
+ content
+ }
+ }
+}
+
+#if DEBUG
+
+#Preview {
+ LearnerDashboardTitledWidget("Weekly Summary") {
+ Text(verbatim: InstUI.PreviewData.loremIpsumShort)
+ }
+}
+
+#endif
diff --git a/Student/Student/LearnerDashboard/Widgets/Common/View/LearnerDashboardWidgetErrorView.swift b/Student/Student/LearnerDashboard/Widgets/Common/View/LearnerDashboardWidgetErrorView.swift
new file mode 100644
index 0000000000..2cf5b5e105
--- /dev/null
+++ b/Student/Student/LearnerDashboard/Widgets/Common/View/LearnerDashboardWidgetErrorView.swift
@@ -0,0 +1,70 @@
+//
+// This file is part of Canvas.
+// Copyright (C) 2025-present Instructure, Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+//
+
+import SwiftUI
+import Core
+
+struct LearnerDashboardWidgetErrorView: View {
+ let onRetry: () -> Void
+
+ var body: some View {
+ HStack(
+ alignment: .top,
+ spacing: InstUI.Styles.Padding.standard.rawValue
+ ) {
+ Image("PandaUnsupported", bundle: .core)
+ .scaledIcon(size: 40)
+
+ VStack(alignment: .leading, spacing: 2) {
+ Text("Oops, Something went wrong", bundle: .student)
+ .font(.semibold16, lineHeight: .fit)
+ .foregroundColor(.textDarkest)
+
+ Text("We weren't able to load this content.\nTry again, or come back later.", bundle: .student)
+ .font(.regular14, lineHeight: .fit)
+ .foregroundColor(.textDark)
+ .paddingStyle(.bottom, .cellAccessoryPadding)
+
+ Button(action: onRetry) {
+ HStack(spacing: 6) {
+ Text("Refresh", bundle: .student)
+ .font(.semibold14, lineHeight: .fit)
+ Image.refreshSolid
+ .scaledIcon(size: 16)
+ }
+ .foregroundColor(.textLightest)
+ .padding(.horizontal, 12)
+ .padding(.vertical, 6)
+ .background(Color(Brand.shared.primary))
+ .cornerRadius(100)
+ }
+ }
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .paddingStyle(.standard)
+ }
+}
+
+#if DEBUG
+
+#Preview {
+ LearnerDashboardWidgetErrorView(onRetry: {})
+ .border(Color.black)
+}
+
+#endif
diff --git a/Student/Student/LearnerDashboard/Widgets/Common/View/LearnerDashboardWidgetLayout.swift b/Student/Student/LearnerDashboard/Widgets/Common/View/LearnerDashboardWidgetLayout.swift
new file mode 100644
index 0000000000..8460943a6a
--- /dev/null
+++ b/Student/Student/LearnerDashboard/Widgets/Common/View/LearnerDashboardWidgetLayout.swift
@@ -0,0 +1,109 @@
+//
+// 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 SwiftUI
+
+struct LearnerDashboardWidgetLayout: View {
+ let fullWidthWidgets: [any LearnerWidgetViewModel]
+ let gridWidgets: [any LearnerWidgetViewModel]
+ @State private var containerWidth: CGFloat = 0
+
+ var body: some View {
+ VStack(spacing: InstUI.Styles.Padding.standard.rawValue) {
+ fullWidthSection()
+ gridSection(columnCount: columns(for: containerWidth))
+ }
+ .animation(.smooth, value: fullWidthWidgets.map { $0.layoutIdentifier })
+ .animation(.smooth, value: gridWidgets.map { $0.layoutIdentifier })
+ .onWidthChange { width in
+ // Don't animate the first appearance
+ withAnimation(containerWidth == 0 ? .none : .smooth) {
+ containerWidth = width
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func fullWidthSection() -> some View {
+ ForEach(fullWidthWidgets, id: \.id) { viewModel in
+ LearnerDashboardWidgetAssembly.makeView(for: viewModel)
+ }
+ }
+
+ @ViewBuilder
+ private func gridSection(columnCount: Int) -> some View {
+ if !gridWidgets.isEmpty {
+ HStack(alignment: .top, spacing: InstUI.Styles.Padding.standard.rawValue) {
+ ForEach(0.. some View {
+ LazyVStack(spacing: InstUI.Styles.Padding.standard.rawValue) {
+ ForEach(Array(gridWidgets.enumerated()), id: \.offset) { index, viewModel in
+ if index % columnCount == columnIndex {
+ LearnerDashboardWidgetAssembly.makeView(for: viewModel)
+ }
+ }
+ }
+ }
+
+ private func columns(for width: CGFloat) -> Int {
+ switch width {
+ case ..<600: 1
+ case 600..<840: 2
+ default: 3
+ }
+ }
+}
+
+#if DEBUG
+
+#Preview {
+ let fullWidthWidget = LearnerDashboardWidgetAssembly.makeWidgetViewModel(
+ config: WidgetConfig(id: .fullWidthWidget, order: 0, isVisible: true, settings: nil)
+ )
+ let widget1 = LearnerDashboardWidgetAssembly.makeWidgetViewModel(
+ config: WidgetConfig(id: .widget1, order: 1, isVisible: true, settings: nil)
+ )
+ let widget2 = LearnerDashboardWidgetAssembly.makeWidgetViewModel(
+ config: WidgetConfig(id: .widget2, order: 2, isVisible: true, settings: nil)
+ )
+ let widget3 = LearnerDashboardWidgetAssembly.makeWidgetViewModel(
+ config: WidgetConfig(id: .widget3, order: 3, isVisible: true, settings: nil)
+ )
+
+ _ = fullWidthWidget.refresh(ignoreCache: false)
+ _ = widget1.refresh(ignoreCache: false)
+ _ = widget2.refresh(ignoreCache: false)
+ _ = widget3.refresh(ignoreCache: false)
+
+ return ScrollView {
+ LearnerDashboardWidgetLayout(
+ fullWidthWidgets: [fullWidthWidget],
+ gridWidgets: [widget1, widget2, widget3]
+ )
+ .paddingStyle(.horizontal, .standard)
+ }
+}
+
+#endif
diff --git a/Student/Student/LearnerDashboard/Widgets/FullWidthWidget/View/FullWidthWidgetView.swift b/Student/Student/LearnerDashboard/Widgets/FullWidthWidget/View/FullWidthWidgetView.swift
new file mode 100644
index 0000000000..ec8b8902d4
--- /dev/null
+++ b/Student/Student/LearnerDashboard/Widgets/FullWidthWidget/View/FullWidthWidgetView.swift
@@ -0,0 +1,38 @@
+//
+// 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 SwiftUI
+
+struct FullWidthWidgetView: View {
+ @State var viewModel: FullWidthWidgetViewModel
+
+ var body: some View {
+ if viewModel.state != .loading {
+ LearnerDashboardTitledWidget("Full Width Widget") {
+ LearnerDashboardCard {
+ Text(verbatim: WidgetPlaceholderData.medium)
+ .foregroundColor(.secondary)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .paddingStyle(.standard)
+ }
+ }
+ .transition(.move(edge: .top).combined(with: .opacity))
+ }
+ }
+}
diff --git a/Student/Student/LearnerDashboard/Widgets/FullWidthWidget/ViewModel/FullWidthWidgetViewModel.swift b/Student/Student/LearnerDashboard/Widgets/FullWidthWidget/ViewModel/FullWidthWidgetViewModel.swift
new file mode 100644
index 0000000000..5ff045fcf6
--- /dev/null
+++ b/Student/Student/LearnerDashboard/Widgets/FullWidthWidget/ViewModel/FullWidthWidgetViewModel.swift
@@ -0,0 +1,59 @@
+//
+// 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 Combine
+import Core
+import SwiftUI
+
+@Observable
+final class FullWidthWidgetViewModel: LearnerWidgetViewModel {
+ typealias ViewType = FullWidthWidgetView
+
+ let config: WidgetConfig
+ var id: LearnerDashboardWidgetIdentifier { config.id }
+ let isFullWidth = true
+ let isEditable = false
+ var state: InstUI.ScreenState = .loading
+
+ init(config: WidgetConfig) {
+ self.config = config
+ }
+
+ func makeView() -> FullWidthWidgetView {
+ FullWidthWidgetView(viewModel: self)
+ }
+
+ func refresh(ignoreCache: Bool) -> AnyPublisher {
+ Future { [weak self] promise in
+ guard let self else {
+ promise(.success(()))
+ return
+ }
+
+ let delay: TimeInterval = self.state == .loading ? 2.0 : 0.0
+
+ DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
+ withAnimation(.smooth) {
+ self.state = .data
+ }
+ promise(.success(()))
+ }
+ }
+ .eraseToAnyPublisher()
+ }
+}
diff --git a/Student/Student/LearnerDashboard/Widgets/LearnerDashboardWidgetAssembly.swift b/Student/Student/LearnerDashboard/Widgets/LearnerDashboardWidgetAssembly.swift
new file mode 100644
index 0000000000..13a35c3527
--- /dev/null
+++ b/Student/Student/LearnerDashboard/Widgets/LearnerDashboardWidgetAssembly.swift
@@ -0,0 +1,59 @@
+//
+// 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 SwiftUI
+
+enum LearnerDashboardWidgetAssembly {
+
+ static func makeDefaultWidgetConfigs() -> [WidgetConfig] {
+ [
+ WidgetConfig(id: .fullWidthWidget, order: 0, isVisible: true, settings: nil),
+ WidgetConfig(id: .widget1, order: 1, isVisible: true, settings: nil),
+ WidgetConfig(id: .widget3, order: 3, isVisible: true, settings: nil),
+ WidgetConfig(id: .widget2, order: 2, isVisible: true, settings: nil)
+ ]
+ }
+
+ static func makeWidgetViewModel(config: WidgetConfig) -> any LearnerWidgetViewModel {
+ switch config.id {
+ case .fullWidthWidget: FullWidthWidgetViewModel(config: config)
+ case .widget1: Widget1ViewModel(config: config)
+ case .widget2: Widget2ViewModel(config: config)
+ case .widget3: Widget3ViewModel(config: config)
+ }
+ }
+
+ @ViewBuilder
+ static func makeView(for viewModel: any LearnerWidgetViewModel) -> some View {
+ switch viewModel {
+ case let vm as FullWidthWidgetViewModel:
+ vm.makeView()
+ case let vm as Widget1ViewModel:
+ vm.makeView()
+ case let vm as Widget2ViewModel:
+ vm.makeView()
+ case let vm as Widget3ViewModel:
+ vm.makeView()
+ default:
+ SwiftUI.EmptyView()
+ .onAppear {
+ assertionFailure("Unknown widget view model type")
+ }
+ }
+ }
+}
diff --git a/Student/Student/LearnerDashboard/Model/LearnerDashboardInteractor.swift b/Student/Student/LearnerDashboard/Widgets/LearnerDashboardWidgetIdentifier.swift
similarity index 61%
rename from Student/Student/LearnerDashboard/Model/LearnerDashboardInteractor.swift
rename to Student/Student/LearnerDashboard/Widgets/LearnerDashboardWidgetIdentifier.swift
index 917cd84524..f250f94326 100644
--- a/Student/Student/LearnerDashboard/Model/LearnerDashboardInteractor.swift
+++ b/Student/Student/LearnerDashboard/Widgets/LearnerDashboardWidgetIdentifier.swift
@@ -16,20 +16,9 @@
// along with this program. If not, see .
//
-import Combine
-import Core
-import Foundation
-
-protocol LearnerDashboardInteractor {
- func refresh(ignoreCache: Bool) -> AnyPublisher
-}
-
-final class LearnerDashboardInteractorLive: LearnerDashboardInteractor {
-
- func refresh(ignoreCache: Bool) -> AnyPublisher {
- Just(())
- .delay(for: .seconds(2), scheduler: DispatchQueue.global(qos: .userInitiated))
- .setFailureType(to: Error.self)
- .eraseToAnyPublisher()
- }
+enum LearnerDashboardWidgetIdentifier: String, Codable, CaseIterable {
+ case fullWidthWidget
+ case widget1
+ case widget2
+ case widget3
}
diff --git a/Student/Student/LearnerDashboard/Widgets/Widget1/View/Widget1View.swift b/Student/Student/LearnerDashboard/Widgets/Widget1/View/Widget1View.swift
new file mode 100644
index 0000000000..4454769c13
--- /dev/null
+++ b/Student/Student/LearnerDashboard/Widgets/Widget1/View/Widget1View.swift
@@ -0,0 +1,40 @@
+//
+// 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 SwiftUI
+
+struct Widget1View: View {
+ @State var viewModel: Widget1ViewModel
+
+ var body: some View {
+ LearnerDashboardTitledWidget("Widget 1") {
+ LearnerDashboardCard {
+ VStack(alignment: .leading) {
+ Text(viewModel.text)
+ .foregroundColor(.secondary)
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .paddingStyle(.standard)
+ .redacted(reason: viewModel.state == .data ? [] : .placeholder)
+ }
+ }
+ .animation(.smooth, value: viewModel.state)
+ .animation(.smooth, value: viewModel.text)
+ }
+}
diff --git a/Student/Student/LearnerDashboard/Widgets/Widget1/ViewModel/Widget1ViewModel.swift b/Student/Student/LearnerDashboard/Widgets/Widget1/ViewModel/Widget1ViewModel.swift
new file mode 100644
index 0000000000..f3d93bde3c
--- /dev/null
+++ b/Student/Student/LearnerDashboard/Widgets/Widget1/ViewModel/Widget1ViewModel.swift
@@ -0,0 +1,75 @@
+//
+// 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 Combine
+import Core
+import SwiftUI
+
+@Observable
+final class Widget1ViewModel: LearnerWidgetViewModel {
+ typealias ViewType = Widget1View
+
+ var text = WidgetPlaceholderData.long(1)
+ let config: WidgetConfig
+ var id: LearnerDashboardWidgetIdentifier { config.id }
+ let isFullWidth = false
+ let isEditable = false
+ var state: InstUI.ScreenState = .loading
+
+ var layoutIdentifier: AnyHashable {
+ struct Identifier: Hashable {
+ let state: InstUI.ScreenState
+ let textCount: Int
+ }
+ return AnyHashable(Identifier(state: state, textCount: text.count))
+ }
+
+ private var timerCancellable: AnyCancellable?
+
+ init(config: WidgetConfig) {
+ self.config = config
+ startTextTimer()
+ }
+
+ private func startTextTimer() {
+ timerCancellable = Timer.publish(every: 2, on: .main, in: .common)
+ .autoconnect()
+ .sink { [weak self] _ in
+ guard let self = self else { return }
+ var newText: String
+ repeat {
+ newText = WidgetPlaceholderData.long(Int.random(in: 1...4))
+ } while newText.count == self.text.count
+ self.text = newText
+ }
+ }
+
+ func makeView() -> Widget1View {
+ Widget1View(viewModel: self)
+ }
+
+ func refresh(ignoreCache: Bool) -> AnyPublisher {
+ Just(())
+ .delay(for: 3, scheduler: RunLoop.main)
+ .map { [weak self] in
+ self?.state = .data
+ return ()
+ }
+ .eraseToAnyPublisher()
+ }
+}
diff --git a/Student/Student/LearnerDashboard/Widgets/Widget2/View/Widget2View.swift b/Student/Student/LearnerDashboard/Widgets/Widget2/View/Widget2View.swift
new file mode 100644
index 0000000000..6d574ac1aa
--- /dev/null
+++ b/Student/Student/LearnerDashboard/Widgets/Widget2/View/Widget2View.swift
@@ -0,0 +1,47 @@
+//
+// 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 SwiftUI
+
+struct Widget2View: View {
+ @State var viewModel: Widget2ViewModel
+
+ var body: some View {
+ LearnerDashboardTitledWidget("Widget 2") {
+ LearnerDashboardCard {
+ switch viewModel.state {
+ case .empty, .data, .loading:
+ dataView
+ case .error:
+ LearnerDashboardWidgetErrorView(onRetry: viewModel.refresh)
+ }
+ }
+ }
+ .animation(.smooth, value: viewModel.state)
+ }
+
+ private var dataView: some View {
+ VStack(alignment: .leading) {
+ Text(verbatim: WidgetPlaceholderData.long(3))
+ .foregroundColor(.secondary)
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .paddingStyle(.standard)
+ }
+}
diff --git a/Student/Student/LearnerDashboard/Widgets/Widget2/ViewModel/Widget2ViewModel.swift b/Student/Student/LearnerDashboard/Widgets/Widget2/ViewModel/Widget2ViewModel.swift
new file mode 100644
index 0000000000..3c3878bdb4
--- /dev/null
+++ b/Student/Student/LearnerDashboard/Widgets/Widget2/ViewModel/Widget2ViewModel.swift
@@ -0,0 +1,57 @@
+//
+// 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 Combine
+import Core
+import SwiftUI
+
+@Observable
+final class Widget2ViewModel: LearnerWidgetViewModel {
+ typealias ViewType = Widget2View
+
+ let config: WidgetConfig
+ var id: LearnerDashboardWidgetIdentifier { config.id }
+ let isFullWidth = false
+ let isEditable = false
+ var state: InstUI.ScreenState = .data
+ private var subscriptions = Set()
+
+ init(config: WidgetConfig) {
+ self.config = config
+ }
+
+ func makeView() -> Widget2View {
+ Widget2View(viewModel: self)
+ }
+
+ func refresh() {
+ state = .loading
+ refresh(ignoreCache: true)
+ .sink()
+ .store(in: &subscriptions)
+ }
+
+ func refresh(ignoreCache: Bool) -> AnyPublisher {
+ Just(())
+ .delay(for: 2, scheduler: RunLoop.main)
+ .map { [weak self] in
+ self?.state = .error
+ }
+ .eraseToAnyPublisher()
+ }
+}
diff --git a/Student/StudentUnitTests/LearnerDashboard/Model/LearnerDashboardInteractorLiveTests.swift b/Student/Student/LearnerDashboard/Widgets/Widget3/View/Widget3View.swift
similarity index 59%
rename from Student/StudentUnitTests/LearnerDashboard/Model/LearnerDashboardInteractorLiveTests.swift
rename to Student/Student/LearnerDashboard/Widgets/Widget3/View/Widget3View.swift
index 4058e689a2..30ae733256 100644
--- a/Student/StudentUnitTests/LearnerDashboard/Model/LearnerDashboardInteractorLiveTests.swift
+++ b/Student/Student/LearnerDashboard/Widgets/Widget3/View/Widget3View.swift
@@ -16,20 +16,23 @@
// along with this program. If not, see .
//
-@testable import Student
-import XCTest
+import Core
+import SwiftUI
-final class LearnerDashboardInteractorLiveTests: StudentTestCase {
+struct Widget3View: View {
- func test_refresh_whenIgnoreCacheIsTrue_shouldFinish() {
- let testee = LearnerDashboardInteractorLive()
+ @State var viewModel: Widget3ViewModel
- XCTAssertFinish(testee.refresh(ignoreCache: true), timeout: 3)
- }
-
- func test_refresh_whenIgnoreCacheIsFalse_shouldFinish() {
- let testee = LearnerDashboardInteractorLive()
-
- XCTAssertFinish(testee.refresh(ignoreCache: false), timeout: 3)
+ var body: some View {
+ LearnerDashboardTitledWidget("Widget 3") {
+ LearnerDashboardCard {
+ VStack(alignment: .leading) {
+ Text(verbatim: WidgetPlaceholderData.long(2))
+ .foregroundColor(.secondary)
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .paddingStyle(.standard)
+ }
+ }
}
}
diff --git a/Student/Student/LearnerDashboard/Widgets/Widget3/ViewModel/Widget3ViewModel.swift b/Student/Student/LearnerDashboard/Widgets/Widget3/ViewModel/Widget3ViewModel.swift
new file mode 100644
index 0000000000..34cc767ea4
--- /dev/null
+++ b/Student/Student/LearnerDashboard/Widgets/Widget3/ViewModel/Widget3ViewModel.swift
@@ -0,0 +1,44 @@
+//
+// 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 Combine
+import Core
+import SwiftUI
+
+@Observable
+final class Widget3ViewModel: LearnerWidgetViewModel {
+ typealias ViewType = Widget3View
+
+ let config: WidgetConfig
+ var id: LearnerDashboardWidgetIdentifier { config.id }
+ let isFullWidth = false
+ let isEditable = false
+ var state: InstUI.ScreenState = .data
+
+ init(config: WidgetConfig) {
+ self.config = config
+ }
+
+ func makeView() -> Widget3View {
+ Widget3View(viewModel: self)
+ }
+
+ func refresh(ignoreCache: Bool) -> AnyPublisher {
+ Just(()).eraseToAnyPublisher()
+ }
+}
diff --git a/Student/Student/Localizable.xcstrings b/Student/Student/Localizable.xcstrings
index b25efa8118..a9245a3a24 100644
--- a/Student/Student/Localizable.xcstrings
+++ b/Student/Student/Localizable.xcstrings
@@ -89128,6 +89128,9 @@
}
}
}
+ },
+ "Oops, Something went wrong" : {
+
},
"Oops!" : {
"comment" : "Error message title\n Error title\n Masquerade error title",
@@ -100927,6 +100930,9 @@
}
}
}
+ },
+ "Refresh" : {
+
},
"Refresh Button" : {
"extractionState" : "manual",
@@ -143002,6 +143008,9 @@
}
}
}
+ },
+ "We weren't able to load this content.\nTry again, or come back later." : {
+
},
"Website Address" : {
"localizations" : {
diff --git a/Student/StudentUnitTests/LearnerDashboard/Model/LearnerDashboardInteractorMock.swift b/Student/StudentUnitTests/LearnerDashboard/Container/Model/LearnerDashboardInteractorMock.swift
similarity index 73%
rename from Student/StudentUnitTests/LearnerDashboard/Model/LearnerDashboardInteractorMock.swift
rename to Student/StudentUnitTests/LearnerDashboard/Container/Model/LearnerDashboardInteractorMock.swift
index 11e2c1f0f2..742f6d3151 100644
--- a/Student/StudentUnitTests/LearnerDashboard/Model/LearnerDashboardInteractorMock.swift
+++ b/Student/StudentUnitTests/LearnerDashboard/Container/Model/LearnerDashboardInteractorMock.swift
@@ -20,11 +20,9 @@ import Combine
@testable import Student
final class LearnerDashboardInteractorMock: LearnerDashboardInteractor {
- var refreshIgnoreCacheValue: Bool?
- var refreshPublisher = PassthroughSubject()
+ var loadWidgetsPublisher = PassthroughSubject<(fullWidth: [any LearnerWidgetViewModel], grid: [any LearnerWidgetViewModel]), Never>()
- func refresh(ignoreCache: Bool) -> AnyPublisher {
- refreshIgnoreCacheValue = ignoreCache
- return refreshPublisher.eraseToAnyPublisher()
+ func loadWidgets() -> AnyPublisher<(fullWidth: [any LearnerWidgetViewModel], grid: [any LearnerWidgetViewModel]), Never> {
+ loadWidgetsPublisher.eraseToAnyPublisher()
}
}
diff --git a/Student/StudentUnitTests/LearnerDashboard/Container/Model/LearnerDashboardInteractorTests.swift b/Student/StudentUnitTests/LearnerDashboard/Container/Model/LearnerDashboardInteractorTests.swift
new file mode 100644
index 0000000000..6802114e5c
--- /dev/null
+++ b/Student/StudentUnitTests/LearnerDashboard/Container/Model/LearnerDashboardInteractorTests.swift
@@ -0,0 +1,170 @@
+//
+// 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 Combine
+@testable import Core
+@testable import Student
+import XCTest
+
+final class LearnerDashboardInteractorLiveTests: StudentTestCase {
+
+ private var testee: LearnerDashboardInteractorLive!
+ private var userDefaults: SessionDefaults!
+ private var subscriptions: Set!
+
+ override func setUp() {
+ super.setUp()
+ userDefaults = SessionDefaults(sessionID: "test-session")
+ subscriptions = []
+ }
+
+ override func tearDown() {
+ userDefaults.reset()
+ userDefaults = nil
+ testee = nil
+ subscriptions = nil
+ super.tearDown()
+ }
+
+ // MARK: - Load widgets with no saved configs
+
+ func test_loadWidgets_withNoSavedConfigs_shouldUseDefaultConfigs() {
+ testee = LearnerDashboardInteractorLive(
+ userDefaults: userDefaults,
+ widgetViewModelFactory: makeViewModelFactory()
+ )
+
+ let expectation = expectation(description: "loadWidgets")
+ var receivedFullWidth: [any LearnerWidgetViewModel]?
+ var receivedGrid: [any LearnerWidgetViewModel]?
+
+ testee.loadWidgets()
+ .sink { result in
+ receivedFullWidth = result.fullWidth
+ receivedGrid = result.grid
+ expectation.fulfill()
+ }
+ .store(in: &subscriptions)
+
+ wait(for: [expectation], timeout: 5)
+
+ XCTAssertEqual(receivedFullWidth?.count, 1)
+ XCTAssertEqual(receivedFullWidth?.first?.id, .fullWidthWidget)
+ XCTAssertEqual(receivedGrid?.count, 3)
+ XCTAssertEqual(receivedGrid?[0].id, .widget1)
+ XCTAssertEqual(receivedGrid?[1].id, .widget3)
+ XCTAssertEqual(receivedGrid?[2].id, .widget2)
+ }
+
+ // MARK: - Load widgets with saved configs
+
+ func test_loadWidgets_withSavedConfigs_shouldFilterVisibleAndSort() {
+ userDefaults.learnerDashboardWidgetConfigs = [
+ WidgetConfig(id: .widget3, order: 5, isVisible: true),
+ WidgetConfig(id: .widget1, order: 20, isVisible: false),
+ WidgetConfig(id: .widget2, order: 10, isVisible: true)
+ ]
+ testee = LearnerDashboardInteractorLive(
+ userDefaults: userDefaults,
+ widgetViewModelFactory: makeViewModelFactory()
+ )
+
+ let expectation = expectation(description: "loadWidgets")
+ var receivedFullWidth: [any LearnerWidgetViewModel]?
+ var receivedGrid: [any LearnerWidgetViewModel]?
+
+ testee.loadWidgets()
+ .sink { result in
+ receivedFullWidth = result.fullWidth
+ receivedGrid = result.grid
+ expectation.fulfill()
+ }
+ .store(in: &subscriptions)
+
+ wait(for: [expectation], timeout: 5)
+
+ XCTAssertEqual(receivedFullWidth?.count, 0)
+ XCTAssertEqual(receivedGrid?.count, 2)
+ XCTAssertEqual(receivedGrid?[0].id, .widget3)
+ XCTAssertEqual(receivedGrid?[1].id, .widget2)
+ }
+
+ func test_loadWidgets_shouldSeparateFullWidthFromGridWidgets() {
+ userDefaults.learnerDashboardWidgetConfigs = [
+ WidgetConfig(id: .widget1, order: 20, isVisible: true),
+ WidgetConfig(id: .fullWidthWidget, order: 5, isVisible: true),
+ WidgetConfig(id: .widget2, order: 10, isVisible: true)
+ ]
+ testee = LearnerDashboardInteractorLive(
+ userDefaults: userDefaults,
+ widgetViewModelFactory: makeViewModelFactory()
+ )
+
+ let expectation = expectation(description: "loadWidgets")
+ var receivedFullWidth: [any LearnerWidgetViewModel]?
+ var receivedGrid: [any LearnerWidgetViewModel]?
+
+ testee.loadWidgets()
+ .sink { result in
+ receivedFullWidth = result.fullWidth
+ receivedGrid = result.grid
+ expectation.fulfill()
+ }
+ .store(in: &subscriptions)
+
+ wait(for: [expectation], timeout: 5)
+
+ XCTAssertEqual(receivedFullWidth?.count, 1)
+ XCTAssertEqual(receivedFullWidth?.first?.id, .fullWidthWidget)
+ XCTAssertEqual(receivedGrid?.count, 2)
+ XCTAssertEqual(receivedGrid?[0].id, .widget2)
+ XCTAssertEqual(receivedGrid?[1].id, .widget1)
+ }
+
+ // MARK: - Private helpers
+
+ private func makeViewModelFactory() -> (WidgetConfig) -> any LearnerWidgetViewModel {
+ return { config in
+ MockWidgetViewModel(config: config)
+ }
+ }
+}
+
+private final class MockWidgetViewModel: LearnerWidgetViewModel {
+ typealias ViewType = Never
+
+ let id: LearnerDashboardWidgetIdentifier
+ let config: WidgetConfig
+ let isFullWidth: Bool
+ let isEditable = false
+ let state: InstUI.ScreenState = .data
+
+ init(config: WidgetConfig) {
+ self.config = config
+ self.id = config.id
+ self.isFullWidth = config.id == .fullWidthWidget
+ }
+
+ func makeView() -> Never {
+ fatalError("Not implemented")
+ }
+
+ func refresh(ignoreCache: Bool) -> AnyPublisher {
+ Just(()).eraseToAnyPublisher()
+ }
+}
diff --git a/Student/StudentUnitTests/LearnerDashboard/Container/ViewModel/LearnerDashboardViewModelTests.swift b/Student/StudentUnitTests/LearnerDashboard/Container/ViewModel/LearnerDashboardViewModelTests.swift
new file mode 100644
index 0000000000..fb5e8427aa
--- /dev/null
+++ b/Student/StudentUnitTests/LearnerDashboard/Container/ViewModel/LearnerDashboardViewModelTests.swift
@@ -0,0 +1,192 @@
+//
+// 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 Combine
+import CombineSchedulers
+@testable import Core
+@testable import Student
+import XCTest
+
+final class LearnerDashboardViewModelTests: XCTestCase {
+
+ private var testee: LearnerDashboardViewModel!
+ private var interactor: LearnerDashboardInteractorMock!
+ private var scheduler: TestSchedulerOf!
+
+ override func setUp() {
+ super.setUp()
+ scheduler = DispatchQueue.test
+ interactor = LearnerDashboardInteractorMock()
+ }
+
+ override func tearDown() {
+ testee = nil
+ interactor = nil
+ scheduler = nil
+ super.tearDown()
+ }
+
+ // MARK: - Initialization
+
+ func test_init_shouldLoadWidgets() {
+ let fullWidthWidget = MockWidgetViewModel(
+ id: .fullWidthWidget,
+ isFullWidth: true
+ )
+ let gridWidget = MockWidgetViewModel(
+ id: .widget1,
+ isFullWidth: false
+ )
+
+ testee = LearnerDashboardViewModel(
+ interactor: interactor,
+ mainScheduler: scheduler.eraseToAnyScheduler()
+ )
+ interactor.loadWidgetsPublisher.send((
+ fullWidth: [fullWidthWidget],
+ grid: [gridWidget]
+ ))
+ scheduler.advance()
+
+ XCTAssertEqual(testee.fullWidthWidgets.count, 1)
+ XCTAssertEqual(testee.fullWidthWidgets.first?.id, .fullWidthWidget)
+ XCTAssertEqual(testee.gridWidgets.count, 1)
+ XCTAssertEqual(testee.gridWidgets.first?.id, .widget1)
+ }
+
+ // MARK: - Screen config
+
+ func test_screenConfig_shouldBeConfiguredCorrectly() {
+ testee = LearnerDashboardViewModel(
+ interactor: interactor,
+ mainScheduler: scheduler.eraseToAnyScheduler()
+ )
+
+ XCTAssertEqual(testee.screenConfig.refreshable, true)
+ XCTAssertEqual(testee.screenConfig.showsScrollIndicators, false)
+ XCTAssertEqual(testee.screenConfig.emptyPandaConfig.scene is SpacePanda, true)
+ XCTAssertEqual(
+ testee.screenConfig.emptyPandaConfig.title,
+ String(localized: "Welcome to Canvas!", bundle: .student)
+ )
+ }
+
+ // MARK: - State management
+
+ func test_init_withNoWidgets_shouldKeepLoadingState() {
+ testee = LearnerDashboardViewModel(
+ interactor: interactor,
+ mainScheduler: scheduler.eraseToAnyScheduler()
+ )
+ interactor.loadWidgetsPublisher.send((fullWidth: [], grid: []))
+ scheduler.advance()
+
+ XCTAssertEqual(testee.state, .loading)
+ }
+
+ func test_init_withWidgets_shouldSetDataState() {
+ let widget = MockWidgetViewModel(id: .widget1, isFullWidth: false)
+
+ testee = LearnerDashboardViewModel(
+ interactor: interactor,
+ mainScheduler: scheduler.eraseToAnyScheduler()
+ )
+ interactor.loadWidgetsPublisher.send((fullWidth: [], grid: [widget]))
+ scheduler.advance()
+
+ XCTAssertEqual(testee.state, .data)
+ }
+
+ // MARK: - Refresh
+
+ func test_refresh_shouldCallRefreshOnAllWidgets() {
+ let widget1 = MockWidgetViewModel(id: .widget1, isFullWidth: false)
+ let widget2 = MockWidgetViewModel(id: .widget2, isFullWidth: false)
+ let fullWidthWidget = MockWidgetViewModel(id: .fullWidthWidget, isFullWidth: true)
+
+ testee = LearnerDashboardViewModel(
+ interactor: interactor,
+ mainScheduler: scheduler.eraseToAnyScheduler()
+ )
+ interactor.loadWidgetsPublisher.send((
+ fullWidth: [fullWidthWidget],
+ grid: [widget1, widget2]
+ ))
+ scheduler.advance()
+
+ testee.refresh(ignoreCache: true)
+ scheduler.advance()
+
+ XCTAssertEqual(widget1.refreshCalled, true)
+ XCTAssertEqual(widget1.refreshIgnoreCache, true)
+ XCTAssertEqual(widget2.refreshCalled, true)
+ XCTAssertEqual(widget2.refreshIgnoreCache, true)
+ XCTAssertEqual(fullWidthWidget.refreshCalled, true)
+ XCTAssertEqual(fullWidthWidget.refreshIgnoreCache, true)
+ }
+
+ func test_refresh_shouldCallCompletionWhenAllWidgetsFinish() {
+ let widget = MockWidgetViewModel(id: .widget1, isFullWidth: false)
+
+ testee = LearnerDashboardViewModel(
+ interactor: interactor,
+ mainScheduler: scheduler.eraseToAnyScheduler()
+ )
+ interactor.loadWidgetsPublisher.send((fullWidth: [], grid: [widget]))
+ scheduler.advance()
+
+ let expectation = expectation(description: "refresh completion")
+ testee.refresh(ignoreCache: false) {
+ expectation.fulfill()
+ }
+ scheduler.advance()
+
+ wait(for: [expectation], timeout: 5)
+ XCTAssertEqual(widget.refreshCalled, true)
+ XCTAssertEqual(widget.refreshIgnoreCache, false)
+ }
+}
+
+private final class MockWidgetViewModel: LearnerWidgetViewModel {
+ typealias ViewType = Never
+
+ let id: LearnerDashboardWidgetIdentifier
+ let config: WidgetConfig
+ let isFullWidth: Bool
+ let isEditable = false
+ let state: InstUI.ScreenState = .data
+
+ var refreshCalled = false
+ var refreshIgnoreCache: Bool?
+
+ init(id: LearnerDashboardWidgetIdentifier, isFullWidth: Bool) {
+ self.id = id
+ self.isFullWidth = isFullWidth
+ self.config = WidgetConfig(id: id, order: 7, isVisible: true)
+ }
+
+ func makeView() -> Never {
+ fatalError("Not implemented")
+ }
+
+ func refresh(ignoreCache: Bool) -> AnyPublisher {
+ refreshCalled = true
+ refreshIgnoreCache = ignoreCache
+ return Just(()).eraseToAnyPublisher()
+ }
+}
diff --git a/Student/StudentUnitTests/LearnerDashboard/ViewModel/LearnerDashboardViewModelTests.swift b/Student/StudentUnitTests/LearnerDashboard/ViewModel/LearnerDashboardViewModelTests.swift
deleted file mode 100644
index 3f261ea599..0000000000
--- a/Student/StudentUnitTests/LearnerDashboard/ViewModel/LearnerDashboardViewModelTests.swift
+++ /dev/null
@@ -1,100 +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 Combine
-import CombineSchedulers
-@testable import Core
-@testable import Student
-import XCTest
-
-final class LearnerDashboardViewModelTests: StudentTestCase {
- private var interactor: LearnerDashboardInteractorMock!
- private var scheduler: TestSchedulerOf!
- private var testee: LearnerDashboardViewModel!
-
- override func setUp() {
- super.setUp()
- interactor = LearnerDashboardInteractorMock()
- scheduler = DispatchQueue.test
- testee = LearnerDashboardViewModel(
- interactor: interactor,
- mainScheduler: scheduler.eraseToAnyScheduler()
- )
- }
-
- func test_initialState_shouldBeLoading() {
- XCTAssertEqual(testee.state, .loading)
- }
-
- func test_screenConfig() {
- let expectedTitle = String(localized: "Welcome to Canvas!", bundle: .student)
-
- XCTAssertEqual(testee.screenConfig.refreshable, true)
- XCTAssertEqual(testee.screenConfig.emptyPandaConfig.scene is SpacePanda, true)
- XCTAssertEqual(testee.screenConfig.emptyPandaConfig.title, expectedTitle)
- }
-
- func test_refresh_shouldSetStateToEmpty() {
- let expectation = expectation(description: "Refresh should complete")
-
- // WHEN
- testee.refresh(ignoreCache: true) {
- expectation.fulfill()
- }
-
- interactor.refreshPublisher.send(())
- interactor.refreshPublisher.send(completion: .finished)
- scheduler.advance()
-
- // THEN
- wait(for: [expectation], timeout: 1)
- XCTAssertEqual(testee.state, .empty)
- }
-
- func test_refresh_whenIgnoreCacheIsTrue_shouldCallInteractorWithCorrectParameter() {
- let expectedIgnoreCache = true
-
- // WHEN
- testee.refresh(ignoreCache: expectedIgnoreCache)
-
- interactor.refreshPublisher.send(())
- interactor.refreshPublisher.send(completion: .finished)
- scheduler.advance()
-
- // THEN
- XCTAssertEqual(interactor.refreshIgnoreCacheValue, expectedIgnoreCache)
- }
-
- func test_refresh_whenIgnoreCacheIsFalse_shouldCallInteractorWithCorrectParameter() {
- let expectedIgnoreCache = false
-
- interactor.refreshPublisher.send(())
- interactor.refreshPublisher.send(completion: .finished)
- scheduler.advance()
-
- // WHEN
- testee.refresh(ignoreCache: expectedIgnoreCache)
-
- interactor.refreshPublisher.send(())
- interactor.refreshPublisher.send(completion: .finished)
- scheduler.advance()
-
- // THEN
- XCTAssertEqual(interactor.refreshIgnoreCacheValue, expectedIgnoreCache)
- }
-}
diff --git a/Student/StudentUnitTests/LearnerDashboard/Widgets/Common/Model/SessionDefaults+WidgetConfigTests.swift b/Student/StudentUnitTests/LearnerDashboard/Widgets/Common/Model/SessionDefaults+WidgetConfigTests.swift
new file mode 100644
index 0000000000..19551ed636
--- /dev/null
+++ b/Student/StudentUnitTests/LearnerDashboard/Widgets/Common/Model/SessionDefaults+WidgetConfigTests.swift
@@ -0,0 +1,101 @@
+//
+// 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 .
+//
+
+@testable import Core
+@testable import Student
+import XCTest
+
+final class SessionDefaultsWidgetConfigTests: XCTestCase {
+
+ private var testee: SessionDefaults!
+
+ override func setUp() {
+ super.setUp()
+ testee = SessionDefaults(sessionID: "test-session")
+ }
+
+ override func tearDown() {
+ testee.reset()
+ testee = nil
+ super.tearDown()
+ }
+
+ // MARK: - Get
+
+ func test_getter_whenNoDataStored_shouldReturnNil() {
+ XCTAssertEqual(testee.learnerDashboardWidgetConfigs, nil)
+ }
+
+ func test_getter_whenInvalidDataStored_shouldReturnNil() {
+ testee["learnerDashboardWidgetConfigs"] = Data("invalid json".utf8)
+
+ XCTAssertEqual(testee.learnerDashboardWidgetConfigs, nil)
+ }
+
+ func test_getter_whenValidDataStored_shouldDecodeAndReturnConfigs() {
+ let configs = [
+ WidgetConfig(id: .widget1, order: 7, isVisible: true, settings: "some settings"),
+ WidgetConfig(id: .widget2, order: 42, isVisible: false, settings: nil)
+ ]
+ let data = try! JSONEncoder().encode(configs)
+ testee["learnerDashboardWidgetConfigs"] = data
+
+ let result = testee.learnerDashboardWidgetConfigs
+
+ XCTAssertEqual(result?.count, 2)
+ XCTAssertEqual(result?[0].id, .widget1)
+ XCTAssertEqual(result?[0].order, 7)
+ XCTAssertEqual(result?[0].isVisible, true)
+ XCTAssertEqual(result?[0].settings, "some settings")
+ XCTAssertEqual(result?[1].id, .widget2)
+ XCTAssertEqual(result?[1].order, 42)
+ XCTAssertEqual(result?[1].isVisible, false)
+ XCTAssertEqual(result?[1].settings, nil)
+ }
+
+ // MARK: - Set
+
+ func test_setter_withValidConfigs_shouldEncodeAndStore() {
+ let configs = [
+ WidgetConfig(id: .widget1, order: 7, isVisible: true, settings: "some settings"),
+ WidgetConfig(id: .widget3, order: 100, isVisible: false, settings: nil)
+ ]
+
+ testee.learnerDashboardWidgetConfigs = configs
+
+ let storedData = testee["learnerDashboardWidgetConfigs"] as? Data
+ XCTAssertNotEqual(storedData, nil)
+
+ let decoded = try! JSONDecoder().decode([WidgetConfig].self, from: storedData!)
+ XCTAssertEqual(decoded.count, 2)
+ XCTAssertEqual(decoded[0].id, .widget1)
+ XCTAssertEqual(decoded[0].order, 7)
+ XCTAssertEqual(decoded[1].id, .widget3)
+ XCTAssertEqual(decoded[1].order, 100)
+ }
+
+ func test_setter_withNil_shouldRemoveStoredData() {
+ let configs = [WidgetConfig(id: .widget1, order: 7, isVisible: true)]
+ testee.learnerDashboardWidgetConfigs = configs
+ XCTAssertNotNil(testee["learnerDashboardWidgetConfigs"])
+
+ testee.learnerDashboardWidgetConfigs = nil
+
+ XCTAssertNil(testee["learnerDashboardWidgetConfigs"])
+ }
+}
diff --git a/Student/StudentUnitTests/LearnerDashboard/Widgets/Common/Model/WidgetConfigTests.swift b/Student/StudentUnitTests/LearnerDashboard/Widgets/Common/Model/WidgetConfigTests.swift
new file mode 100644
index 0000000000..6834621973
--- /dev/null
+++ b/Student/StudentUnitTests/LearnerDashboard/Widgets/Common/Model/WidgetConfigTests.swift
@@ -0,0 +1,104 @@
+//
+// 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 .
+//
+
+@testable import Student
+import XCTest
+
+final class WidgetConfigTests: XCTestCase {
+
+ // MARK: - Comparable
+
+ func test_comparable_shouldCompareByOrder() {
+ // WHEN lhs.order < rhs.order
+ var lhs = WidgetConfig(id: .widget1, order: 5, isVisible: true)
+ var rhs = WidgetConfig(id: .widget2, order: 10, isVisible: true)
+ // THEN
+ XCTAssertEqual(lhs < rhs, true)
+
+ // WHEN lhs.order > rhs.order
+ lhs = WidgetConfig(id: .widget1, order: 15, isVisible: true)
+ rhs = WidgetConfig(id: .widget2, order: 10, isVisible: true)
+ // THEN
+ XCTAssertEqual(lhs < rhs, false)
+
+ // WHEN lhs.order == rhs.order
+ lhs = WidgetConfig(id: .widget1, order: 10, isVisible: true)
+ rhs = WidgetConfig(id: .widget2, order: 10, isVisible: true)
+ // THEN
+ XCTAssertEqual(lhs < rhs, false)
+ }
+
+ // MARK: - Array extension
+
+ func test_partitionedByLayout_shouldSeparateAndSortWidgets() {
+ let widgets = [
+ WidgetConfig(id: .widget1, order: 20, isVisible: true),
+ WidgetConfig(id: .fullWidthWidget, order: 5, isVisible: true),
+ WidgetConfig(id: .widget2, order: 10, isVisible: true),
+ WidgetConfig(id: .widget3, order: 30, isVisible: true)
+ ]
+
+ let result = widgets.partitionedByLayout { config in
+ config.id == .fullWidthWidget
+ }
+
+ XCTAssertEqual(result.fullWidth.count, 1)
+ XCTAssertEqual(result.fullWidth.first?.id, .fullWidthWidget)
+ XCTAssertEqual(result.grid.count, 3)
+ XCTAssertEqual(result.grid[0].id, .widget2)
+ XCTAssertEqual(result.grid[1].id, .widget1)
+ XCTAssertEqual(result.grid[2].id, .widget3)
+ }
+
+ func test_partitionedByLayout_withAllFullWidth_shouldReturnAllInFullWidthArray() {
+ let widgets = [
+ WidgetConfig(id: .widget1, order: 20, isVisible: true),
+ WidgetConfig(id: .widget2, order: 10, isVisible: true)
+ ]
+
+ let result = widgets.partitionedByLayout { _ in true }
+
+ XCTAssertEqual(result.fullWidth.count, 2)
+ XCTAssertEqual(result.fullWidth[0].id, .widget2)
+ XCTAssertEqual(result.fullWidth[1].id, .widget1)
+ XCTAssertEqual(result.grid.count, 0)
+ }
+
+ func test_partitionedByLayout_withAllGrid_shouldReturnAllInGridArray() {
+ let widgets = [
+ WidgetConfig(id: .widget1, order: 20, isVisible: true),
+ WidgetConfig(id: .widget2, order: 10, isVisible: true)
+ ]
+
+ let result = widgets.partitionedByLayout { _ in false }
+
+ XCTAssertEqual(result.fullWidth.count, 0)
+ XCTAssertEqual(result.grid.count, 2)
+ XCTAssertEqual(result.grid[0].id, .widget2)
+ XCTAssertEqual(result.grid[1].id, .widget1)
+ }
+
+ func test_partitionedByLayout_withEmptyArray_shouldReturnEmptyArrays() {
+ let widgets: [WidgetConfig] = []
+
+ let result = widgets.partitionedByLayout { _ in true }
+
+ XCTAssertEqual(result.fullWidth.count, 0)
+ XCTAssertEqual(result.grid.count, 0)
+ }
+}
diff --git a/scripts/coverage/config.json b/scripts/coverage/config.json
index 14a2101e1b..eda63002f4 100644
--- a/scripts/coverage/config.json
+++ b/scripts/coverage/config.json
@@ -36,6 +36,12 @@
"Student\/Submissions\/SubmissionButton\/SubmissionButtonPresenter.swift",
"Student\/Student\/Assignments\/AssignmentDetails\/SubAssignmentsCard\/ViewModel\/StudentSubAssignmentsCardItem.swift",
"Student\/Widgets\/",
+ "Student\/Student\/LearnerDashboard\/Widgets\/Common\/Model\/LearnerWidgetViewModel.swift",
+ "Student\/Student\/LearnerDashboard\/Widgets\/Common\/Model\/WidgetPlaceholderData.swift",
+ "Student\/Student\/LearnerDashboard\/Widgets\/FullWidthWidget\/",
+ "Student\/Student\/LearnerDashboard\/Widgets\/Widget1\/",
+ "Student\/Student\/LearnerDashboard\/Widgets\/Widget2\/",
+ "Student\/Student\/LearnerDashboard\/Widgets\/Widget3\/",
"\/Teacher\/SpeedGrader\/",
"Parent\/",
"Horizon\/Horizon\/Sources\/Features\/LearningObjects\/Assignment\/AssignmentDetails\/View\/LTIQuiz\/OnAppear.swift",